Compare commits
7 Commits
backup/sna
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82b1d822f9 | ||
|
|
6b8d069ef4 | ||
|
|
e4bcfb57a6 | ||
|
|
ba79775fbc | ||
|
|
2bd937aca5 | ||
|
|
9a56baef61 | ||
|
|
e9b1c0f8fd |
59
.gitignore
vendored
59
.gitignore
vendored
@ -1,28 +1,31 @@
|
|||||||
# Flutter/Dart
|
# Flutter/Dart
|
||||||
.dart_tool/
|
.dart_tool/
|
||||||
.flutter-plugins
|
.flutter-plugins
|
||||||
.flutter-plugins-dependencies
|
.flutter-plugins-dependencies
|
||||||
.packages
|
.packages
|
||||||
.pub-cache/
|
.pub-cache/
|
||||||
.pub/
|
.pub/
|
||||||
build/
|
build/
|
||||||
|
|
||||||
# Android
|
# Android
|
||||||
android/.gradle/
|
android/.gradle/
|
||||||
android/.kotlin/
|
android/.kotlin/
|
||||||
android/captures/
|
android/captures/
|
||||||
android/local.properties
|
android/local.properties
|
||||||
android/key.properties
|
android/key.properties
|
||||||
android/hs_err_pid*.log
|
android/hs_err_pid*.log
|
||||||
android/replay_pid*.log
|
android/replay_pid*.log
|
||||||
*.jks
|
*.jks
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
*.iml
|
*.iml
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# Client/reference documents (kept locally, not in the repo)
|
||||||
|
/*.pdf
|
||||||
|
|||||||
60
.metadata
60
.metadata
@ -1,30 +1,30 @@
|
|||||||
# This file tracks properties of this Flutter project.
|
# This file tracks properties of this Flutter project.
|
||||||
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||||
#
|
#
|
||||||
# This file should be version controlled and should not be manually edited.
|
# This file should be version controlled and should not be manually edited.
|
||||||
|
|
||||||
version:
|
version:
|
||||||
revision: "db50e20168db8fee486b9abf32fc912de3bc5b6a"
|
revision: "db50e20168db8fee486b9abf32fc912de3bc5b6a"
|
||||||
channel: "stable"
|
channel: "stable"
|
||||||
|
|
||||||
project_type: app
|
project_type: app
|
||||||
|
|
||||||
# Tracks metadata for the flutter migrate command
|
# Tracks metadata for the flutter migrate command
|
||||||
migration:
|
migration:
|
||||||
platforms:
|
platforms:
|
||||||
- platform: root
|
- platform: root
|
||||||
create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
||||||
base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
||||||
- platform: android
|
- platform: android
|
||||||
create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
||||||
base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
||||||
|
|
||||||
# User provided section
|
# User provided section
|
||||||
|
|
||||||
# List of Local paths (relative to this file) that should be
|
# List of Local paths (relative to this file) that should be
|
||||||
# ignored by the migrate tool.
|
# ignored by the migrate tool.
|
||||||
#
|
#
|
||||||
# Files that are not part of the templates will be ignored by default.
|
# Files that are not part of the templates will be ignored by default.
|
||||||
unmanaged_files:
|
unmanaged_files:
|
||||||
- 'lib/main.dart'
|
- 'lib/main.dart'
|
||||||
- 'ios/Runner.xcodeproj/project.pbxproj'
|
- 'ios/Runner.xcodeproj/project.pbxproj'
|
||||||
|
|||||||
BIN
Bluetooth.pdf
BIN
Bluetooth.pdf
Binary file not shown.
32
README.md
32
README.md
@ -1,16 +1,16 @@
|
|||||||
# HV BT Dashboard
|
# HV BT Dashboard
|
||||||
|
|
||||||
HV BT Dashboard is a Flutter Android app for live Honda ECU monitoring over Bluetooth. It connects to a paired Bluetooth ECU adapter, polls S300 or KPro data frames, validates and parses those frames, then displays live sensors, graphs, diagnostic trouble codes, and local datalog recordings.
|
HV BT Dashboard is a Flutter Android app for live Honda ECU monitoring over Bluetooth. It connects to a paired Bluetooth ECU adapter, polls S300 or KPro data frames, validates and parses those frames, then displays live sensors, graphs, diagnostic trouble codes, and local datalog recordings.
|
||||||
|
|
||||||
Full layman-friendly documentation is here:
|
Full layman-friendly documentation is here:
|
||||||
|
|
||||||
- [docs/FULL_PROJECT_DOCUMENTATION.md](docs/FULL_PROJECT_DOCUMENTATION.md)
|
- [docs/FULL_PROJECT_DOCUMENTATION.md](docs/FULL_PROJECT_DOCUMENTATION.md)
|
||||||
|
|
||||||
Common commands:
|
Common commands:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
flutter run
|
flutter run
|
||||||
flutter run --release
|
flutter run --release
|
||||||
flutter build apk --release
|
flutter build apk --release
|
||||||
flutter test
|
flutter test
|
||||||
```
|
```
|
||||||
|
|||||||
BIN
Requrements.pdf
BIN
Requrements.pdf
Binary file not shown.
@ -1,28 +1,28 @@
|
|||||||
# This file configures the analyzer, which statically analyzes Dart code to
|
# This file configures the analyzer, which statically analyzes Dart code to
|
||||||
# check for errors, warnings, and lints.
|
# check for errors, warnings, and lints.
|
||||||
#
|
#
|
||||||
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
|
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
|
||||||
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
|
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
|
||||||
# invoked from the command line by running `flutter analyze`.
|
# invoked from the command line by running `flutter analyze`.
|
||||||
|
|
||||||
# The following line activates a set of recommended lints for Flutter apps,
|
# The following line activates a set of recommended lints for Flutter apps,
|
||||||
# packages, and plugins designed to encourage good coding practices.
|
# packages, and plugins designed to encourage good coding practices.
|
||||||
include: package:flutter_lints/flutter.yaml
|
include: package:flutter_lints/flutter.yaml
|
||||||
|
|
||||||
linter:
|
linter:
|
||||||
# The lint rules applied to this project can be customized in the
|
# The lint rules applied to this project can be customized in the
|
||||||
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
||||||
# included above or to enable additional rules. A list of all available lints
|
# included above or to enable additional rules. A list of all available lints
|
||||||
# and their documentation is published at https://dart.dev/lints.
|
# and their documentation is published at https://dart.dev/lints.
|
||||||
#
|
#
|
||||||
# Instead of disabling a lint rule for the entire project in the
|
# Instead of disabling a lint rule for the entire project in the
|
||||||
# section below, it can also be suppressed for a single line of code
|
# section below, it can also be suppressed for a single line of code
|
||||||
# or a specific dart file by using the `// ignore: name_of_lint` and
|
# or a specific dart file by using the `// ignore: name_of_lint` and
|
||||||
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
|
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
|
||||||
# producing the lint.
|
# producing the lint.
|
||||||
rules:
|
rules:
|
||||||
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||||
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||||
|
|
||||||
# Additional information about this file can be found at
|
# Additional information about this file can be found at
|
||||||
# https://dart.dev/guides/language/analysis-options
|
# https://dart.dev/guides/language/analysis-options
|
||||||
|
|||||||
28
android/.gitignore
vendored
28
android/.gitignore
vendored
@ -1,14 +1,14 @@
|
|||||||
gradle-wrapper.jar
|
gradle-wrapper.jar
|
||||||
/.gradle
|
/.gradle
|
||||||
/captures/
|
/captures/
|
||||||
/gradlew
|
/gradlew
|
||||||
/gradlew.bat
|
/gradlew.bat
|
||||||
/local.properties
|
/local.properties
|
||||||
GeneratedPluginRegistrant.java
|
GeneratedPluginRegistrant.java
|
||||||
.cxx/
|
.cxx/
|
||||||
|
|
||||||
# Remember to never publicly share your keystore.
|
# Remember to never publicly share your keystore.
|
||||||
# See https://flutter.dev/to/reference-keystore
|
# See https://flutter.dev/to/reference-keystore
|
||||||
key.properties
|
key.properties
|
||||||
**/*.keystore
|
**/*.keystore
|
||||||
**/*.jks
|
**/*.jks
|
||||||
|
|||||||
@ -1,198 +0,0 @@
|
|||||||
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 {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
id("kotlin-android")
|
id("kotlin-android")
|
||||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||||
id("dev.flutter.flutter-gradle-plugin")
|
id("dev.flutter.flutter-gradle-plugin")
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.metatroncube.hvbt_dash"
|
namespace = "com.metatroncube.hvbt_dash"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = flutter.compileSdkVersion
|
||||||
ndkVersion = flutter.ndkVersion
|
ndkVersion = flutter.ndkVersion
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
jvmTarget = JavaVersion.VERSION_17.toString()
|
jvmTarget = JavaVersion.VERSION_17.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||||
applicationId = "com.metatroncube.hvbt_dash"
|
applicationId = "com.metatroncube.hvbt_dash"
|
||||||
// You can update the following values to match your application needs.
|
// You can update the following values to match your application needs.
|
||||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = flutter.targetSdkVersion
|
targetSdk = flutter.targetSdkVersion
|
||||||
versionCode = flutter.versionCode
|
versionCode = flutter.versionCode
|
||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
// TODO: Add your own signing config for the release build.
|
// TODO: Add your own signing config for the release build.
|
||||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
signingConfig = signingConfigs.getByName("debug")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
flutter {
|
flutter {
|
||||||
source = "../.."
|
source = "../.."
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<!-- The INTERNET permission is required for development. Specifically,
|
<!-- The INTERNET permission is required for development. Specifically,
|
||||||
the Flutter tool needs it to communicate with the running application
|
the Flutter tool needs it to communicate with the running application
|
||||||
to allow setting breakpoints, to provide hot reload, etc.
|
to allow setting breakpoints, to provide hot reload, etc.
|
||||||
-->
|
-->
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@ -1,51 +1,51 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH"/>
|
<uses-permission android:name="android.permission.BLUETOOTH"/>
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"/>
|
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"/>
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
|
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"/>
|
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"/>
|
||||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
|
||||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
|
||||||
<application
|
<application
|
||||||
android:label="hvbt_dash"
|
android:label="hvbt_dash"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher">
|
android:icon="@mipmap/ic_launcher">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:launchMode="singleTop"
|
android:launchMode="singleTop"
|
||||||
android:taskAffinity=""
|
android:taskAffinity=""
|
||||||
android:theme="@style/LaunchTheme"
|
android:theme="@style/LaunchTheme"
|
||||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||||
android:hardwareAccelerated="true"
|
android:hardwareAccelerated="true"
|
||||||
android:windowSoftInputMode="adjustResize">
|
android:windowSoftInputMode="adjustResize">
|
||||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||||
the Android process has started. This theme is visible to the user
|
the Android process has started. This theme is visible to the user
|
||||||
while the Flutter UI initializes. After that, this theme continues
|
while the Flutter UI initializes. After that, this theme continues
|
||||||
to determine the Window background behind the Flutter UI. -->
|
to determine the Window background behind the Flutter UI. -->
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="io.flutter.embedding.android.NormalTheme"
|
android:name="io.flutter.embedding.android.NormalTheme"
|
||||||
android:resource="@style/NormalTheme"
|
android:resource="@style/NormalTheme"
|
||||||
/>
|
/>
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN"/>
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
<!-- Don't delete the meta-data below.
|
<!-- Don't delete the meta-data below.
|
||||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
android:value="2" />
|
||||||
</application>
|
</application>
|
||||||
<!-- Required to query activities that can process text, see:
|
<!-- Required to query activities that can process text, see:
|
||||||
https://developer.android.com/training/package-visibility and
|
https://developer.android.com/training/package-visibility and
|
||||||
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
|
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
|
||||||
|
|
||||||
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||||
<queries>
|
<queries>
|
||||||
<intent>
|
<intent>
|
||||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||||
<data android:mimeType="text/plain"/>
|
<data android:mimeType="text/plain"/>
|
||||||
</intent>
|
</intent>
|
||||||
</queries>
|
</queries>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
package com.metatroncube.hvbt_dash
|
package com.metatroncube.hvbt_dash
|
||||||
|
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
|
||||||
class MainActivity : FlutterActivity()
|
class MainActivity : FlutterActivity()
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!-- Modify this file to customize your launch splash screen -->
|
<!-- Modify this file to customize your launch splash screen -->
|
||||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<item android:drawable="?android:colorBackground" />
|
<item android:drawable="?android:colorBackground" />
|
||||||
|
|
||||||
<!-- You can insert your own image assets here -->
|
<!-- You can insert your own image assets here -->
|
||||||
<!-- <item>
|
<!-- <item>
|
||||||
<bitmap
|
<bitmap
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:src="@mipmap/launch_image" />
|
android:src="@mipmap/launch_image" />
|
||||||
</item> -->
|
</item> -->
|
||||||
</layer-list>
|
</layer-list>
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!-- Modify this file to customize your launch splash screen -->
|
<!-- Modify this file to customize your launch splash screen -->
|
||||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<item android:drawable="@android:color/white" />
|
<item android:drawable="@android:color/white" />
|
||||||
|
|
||||||
<!-- You can insert your own image assets here -->
|
<!-- You can insert your own image assets here -->
|
||||||
<!-- <item>
|
<!-- <item>
|
||||||
<bitmap
|
<bitmap
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:src="@mipmap/launch_image" />
|
android:src="@mipmap/launch_image" />
|
||||||
</item> -->
|
</item> -->
|
||||||
</layer-list>
|
</layer-list>
|
||||||
|
|||||||
@ -1,18 +1,18 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||||
<!-- Show a splash screen on the activity. Automatically removed when
|
<!-- Show a splash screen on the activity. Automatically removed when
|
||||||
the Flutter engine draws its first frame -->
|
the Flutter engine draws its first frame -->
|
||||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||||
</style>
|
</style>
|
||||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||||
This theme determines the color of the Android Window while your
|
This theme determines the color of the Android Window while your
|
||||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||||
running.
|
running.
|
||||||
|
|
||||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||||
<item name="android:windowBackground">?android:colorBackground</item>
|
<item name="android:windowBackground">?android:colorBackground</item>
|
||||||
</style>
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@ -1,18 +1,18 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||||
<!-- Show a splash screen on the activity. Automatically removed when
|
<!-- Show a splash screen on the activity. Automatically removed when
|
||||||
the Flutter engine draws its first frame -->
|
the Flutter engine draws its first frame -->
|
||||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||||
</style>
|
</style>
|
||||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||||
This theme determines the color of the Android Window while your
|
This theme determines the color of the Android Window while your
|
||||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||||
running.
|
running.
|
||||||
|
|
||||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||||
<item name="android:windowBackground">?android:colorBackground</item>
|
<item name="android:windowBackground">?android:colorBackground</item>
|
||||||
</style>
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<!-- The INTERNET permission is required for development. Specifically,
|
<!-- The INTERNET permission is required for development. Specifically,
|
||||||
the Flutter tool needs it to communicate with the running application
|
the Flutter tool needs it to communicate with the running application
|
||||||
to allow setting breakpoints, to provide hot reload, etc.
|
to allow setting breakpoints, to provide hot reload, etc.
|
||||||
-->
|
-->
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@ -1,24 +1,24 @@
|
|||||||
allprojects {
|
allprojects {
|
||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val newBuildDir: Directory =
|
val newBuildDir: Directory =
|
||||||
rootProject.layout.buildDirectory
|
rootProject.layout.buildDirectory
|
||||||
.dir("../../build")
|
.dir("../../build")
|
||||||
.get()
|
.get()
|
||||||
rootProject.layout.buildDirectory.value(newBuildDir)
|
rootProject.layout.buildDirectory.value(newBuildDir)
|
||||||
|
|
||||||
subprojects {
|
subprojects {
|
||||||
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
|
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
|
||||||
project.layout.buildDirectory.value(newSubprojectBuildDir)
|
project.layout.buildDirectory.value(newSubprojectBuildDir)
|
||||||
}
|
}
|
||||||
subprojects {
|
subprojects {
|
||||||
project.evaluationDependsOn(":app")
|
project.evaluationDependsOn(":app")
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.register<Delete>("clean") {
|
tasks.register<Delete>("clean") {
|
||||||
delete(rootProject.layout.buildDirectory)
|
delete(rootProject.layout.buildDirectory)
|
||||||
}
|
}
|
||||||
|
|||||||
10
android/gradle/wrapper/gradle-wrapper.properties
vendored
10
android/gradle/wrapper/gradle-wrapper.properties
vendored
@ -1,5 +1,5 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@ -1,26 +1,26 @@
|
|||||||
pluginManagement {
|
pluginManagement {
|
||||||
val flutterSdkPath =
|
val flutterSdkPath =
|
||||||
run {
|
run {
|
||||||
val properties = java.util.Properties()
|
val properties = java.util.Properties()
|
||||||
file("local.properties").inputStream().use { properties.load(it) }
|
file("local.properties").inputStream().use { properties.load(it) }
|
||||||
val flutterSdkPath = properties.getProperty("flutter.sdk")
|
val flutterSdkPath = properties.getProperty("flutter.sdk")
|
||||||
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
|
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
|
||||||
flutterSdkPath
|
flutterSdkPath
|
||||||
}
|
}
|
||||||
|
|
||||||
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
|
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
|
||||||
|
|
||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
gradlePluginPortal()
|
gradlePluginPortal()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||||
id("com.android.application") version "8.11.1" apply false
|
id("com.android.application") version "8.11.1" apply false
|
||||||
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
include(":app")
|
include(":app")
|
||||||
|
|||||||
209
docs/FIX_2026-10-12.md
Normal file
209
docs/FIX_2026-10-12.md
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
# 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
Binary file not shown.
@ -1,160 +1,320 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import '../protocol/neg8.dart';
|
import '../logging/app_log.dart';
|
||||||
import 'bt_service.dart';
|
import '../protocol/neg8.dart';
|
||||||
|
import 'bt_service.dart';
|
||||||
enum EcuType { s300, kpro }
|
|
||||||
|
enum EcuType { s300, kpro }
|
||||||
/// Sends the ECU request byte every [pollingInterval] ms,
|
|
||||||
/// accumulates raw bytes into 128-byte frames, validates NEG8,
|
/// ECU type implied by the adapter's Bluetooth name, or null if the name
|
||||||
/// and emits the newest usable frame on [frameStream].
|
/// doesn't say. HV adapters advertise it, e.g. "S300[94]-SPP",
|
||||||
class BtPoller {
|
/// "HVS300-SPP[94]"; a KPro adapter is expected to contain "KPRO"/"K-PRO".
|
||||||
final BtService _service;
|
EcuType? ecuTypeFromDeviceName(String? name) {
|
||||||
EcuType ecuType;
|
if (name == null) return null;
|
||||||
final Duration pollingInterval;
|
final n = name.toUpperCase().replaceAll('-', '');
|
||||||
|
final s300 = n.contains('S300');
|
||||||
// S300 request: [0x1B, 0x00, 0xE5]
|
final kpro = n.contains('KPRO');
|
||||||
static final Uint8List _s300Request = Uint8List.fromList([0x1B, 0x00, 0xE5]);
|
if (s300 == kpro) return null; // neither, or ambiguous
|
||||||
// KPro request: [0x1B, 0x01, 0xE4]
|
return s300 ? EcuType.s300 : EcuType.kpro;
|
||||||
static final Uint8List _kproRequest = Uint8List.fromList([0x1B, 0x01, 0xE4]);
|
}
|
||||||
|
|
||||||
final StreamController<Uint8List> _frameController =
|
typedef _Matcher = Uint8List? Function(List<int> rxBuf);
|
||||||
StreamController<Uint8List>.broadcast();
|
|
||||||
|
class _Pending {
|
||||||
Stream<Uint8List> get frameStream => _frameController.stream;
|
_Pending(this.match);
|
||||||
|
final _Matcher match;
|
||||||
final List<int> _rxBuf = [];
|
final Completer<Uint8List?> completer = Completer<Uint8List?>();
|
||||||
Timer? _pollTimer;
|
}
|
||||||
StreamSubscription<Uint8List>? _rxSub;
|
|
||||||
|
/// Strict request/response poller for the HV BT adapter.
|
||||||
int droppedFrames = 0;
|
///
|
||||||
int validFrames = 0;
|
/// Rules (agreed with the device maker's feedback — the adapter MCU must not
|
||||||
|
/// be flooded):
|
||||||
// Tracks whether a request is still awaiting its response, so the poller
|
/// * Exactly ONE request is outstanding at any time. A new request is only
|
||||||
// never pipelines more than one request ahead of the device. Without this,
|
/// sent after the previous one was answered with a complete, checksum-valid
|
||||||
// a device that answers slower than [pollingInterval] falls permanently
|
/// 128-byte frame, or after [responseTimeout] expired.
|
||||||
// behind — every displayed frame ends up answering a stale, already-queued
|
/// * After a timeout the link is left quiet for [quietAfterTimeout] and the
|
||||||
// request instead of the most recent one, and the dashboard visibly lags
|
/// receive buffer is flushed, so a late reply can't be mistaken for the
|
||||||
// real engine changes by a growing amount.
|
/// answer to the next request.
|
||||||
bool _awaitingResponse = false;
|
/// * Requests are paced: at most one every [pollingInterval] (measured from
|
||||||
DateTime? _lastRequestSentAt;
|
/// request to request) and never less than [minGap] after a reply.
|
||||||
|
/// * After [stallThreshold] consecutive timeouts, [onLinkStalled] fires so the
|
||||||
BtPoller(
|
/// owner can reconnect.
|
||||||
this._service, {
|
///
|
||||||
this.ecuType = EcuType.s300,
|
/// Other commands (DTC reset, protocol probe) go through the same lock, so they
|
||||||
this.pollingInterval = const Duration(milliseconds: 50),
|
/// never interleave with a datalog request.
|
||||||
});
|
class BtPoller {
|
||||||
|
BtPoller(
|
||||||
void start() {
|
this._service, {
|
||||||
_rxBuf.clear();
|
this.ecuType = EcuType.s300,
|
||||||
_awaitingResponse = false;
|
Duration pollingInterval = defaultInterval,
|
||||||
_lastRequestSentAt = null;
|
this.responseTimeout = const Duration(milliseconds: 500),
|
||||||
|
this.onLinkStalled,
|
||||||
// Listen to raw bytes from BT and accumulate into 128-byte frames
|
}) : _pollingInterval = _sanitize(pollingInterval);
|
||||||
_rxSub = _service.rawStream.listen(
|
|
||||||
(Uint8List chunk) {
|
static const Duration defaultInterval = Duration(milliseconds: 100);
|
||||||
_rxBuf.addAll(chunk);
|
static const Duration minInterval = Duration(milliseconds: 80);
|
||||||
_processBuffer();
|
static const Duration minGap = Duration(milliseconds: 20);
|
||||||
},
|
static const Duration quietAfterTimeout = Duration(milliseconds: 150);
|
||||||
onError: (Object e) {
|
static const int stallThreshold = 6;
|
||||||
_frameController.addError(e);
|
static const int frameLength = 128;
|
||||||
stop();
|
static const int _maxBuffer = 4096;
|
||||||
},
|
|
||||||
);
|
// S300 request: [0x1B, 0x00, NEG8] / KPro request: [0x1B, 0x01, NEG8]
|
||||||
|
static final Uint8List _s300Request = Uint8List.fromList([0x1B, 0x00, 0xE5]);
|
||||||
// Check every pollingInterval whether it's time to send the next
|
static final Uint8List _kproRequest = Uint8List.fromList([0x1B, 0x01, 0xE4]);
|
||||||
// request — but only actually send once the prior one has been
|
|
||||||
// answered (or has clearly been lost).
|
final BtService _service;
|
||||||
_pollTimer = Timer.periodic(pollingInterval, (_) => _maybeSendRequest());
|
EcuType ecuType;
|
||||||
_maybeSendRequest();
|
final Duration responseTimeout;
|
||||||
}
|
final void Function()? onLinkStalled;
|
||||||
|
|
||||||
void _maybeSendRequest() {
|
Duration _pollingInterval;
|
||||||
if (!_service.isConnected) return;
|
Duration get pollingInterval => _pollingInterval;
|
||||||
|
set pollingInterval(Duration d) => _pollingInterval = _sanitize(d);
|
||||||
final stalled = _lastRequestSentAt != null &&
|
|
||||||
DateTime.now().difference(_lastRequestSentAt!) > pollingInterval * 4;
|
static Duration _sanitize(Duration d) => d < minInterval ? minInterval : d;
|
||||||
if (_awaitingResponse && !stalled) return;
|
|
||||||
|
final StreamController<Uint8List> _frameController =
|
||||||
_awaitingResponse = true;
|
StreamController<Uint8List>.broadcast();
|
||||||
_lastRequestSentAt = DateTime.now();
|
|
||||||
_service
|
/// Checksum-valid 128-byte frames, one per answered request.
|
||||||
.write(ecuType == EcuType.s300 ? _s300Request : _kproRequest)
|
Stream<Uint8List> get frameStream => _frameController.stream;
|
||||||
.catchError((Object _) {
|
|
||||||
// A failed write must not leave the poller permanently waiting for a
|
final List<int> _rxBuf = [];
|
||||||
// response that was never actually requested — let the next tick retry.
|
StreamSubscription<Uint8List>? _rxSub;
|
||||||
_awaitingResponse = false;
|
_Pending? _pending;
|
||||||
});
|
bool _running = false;
|
||||||
}
|
int _generation = 0;
|
||||||
|
|
||||||
void _processBuffer() {
|
// Serialises every exchange on the link (polls and one-off commands).
|
||||||
// Extract as many 128-byte frames as possible.
|
Future<void> _lockTail = Future<void>.value();
|
||||||
// Frame start sync: first byte should be 0x1B (header marker).
|
|
||||||
// If we have a misaligned buffer, scan forward.
|
// ── Stats (read by the debug screen) ──────────────────────────────────────
|
||||||
Uint8List? latestUsableFrame;
|
int requestsSent = 0;
|
||||||
|
int validFrames = 0;
|
||||||
while (_rxBuf.length >= 128) {
|
int droppedFrames = 0; // checksum failures
|
||||||
// Scan for 0x1B frame header
|
int timeouts = 0;
|
||||||
int startIdx = 0;
|
int consecutiveTimeouts = 0;
|
||||||
while (startIdx < _rxBuf.length && _rxBuf[startIdx] != 0x1B) {
|
Duration? lastResponseTime;
|
||||||
startIdx++;
|
|
||||||
}
|
bool get isRunning => _running;
|
||||||
|
|
||||||
// Not enough data after sync byte
|
void start() {
|
||||||
if (_rxBuf.length - startIdx < 128) {
|
if (_running) return;
|
||||||
// Discard bytes before potential header
|
_running = true;
|
||||||
if (startIdx > 0) {
|
final gen = ++_generation;
|
||||||
_rxBuf.removeRange(0, startIdx);
|
_rxBuf.clear();
|
||||||
}
|
consecutiveTimeouts = 0;
|
||||||
break;
|
_rxSub = _service.rawStream.listen(
|
||||||
}
|
_onRx,
|
||||||
|
onError: (Object e) {
|
||||||
final Uint8List frame =
|
AppLog.w('BT stream error: $e');
|
||||||
Uint8List.fromList(_rxBuf.sublist(startIdx, startIdx + 128));
|
if (!_frameController.isClosed) _frameController.addError(e);
|
||||||
|
},
|
||||||
final checksumOk = validateFrame(frame);
|
);
|
||||||
|
AppLog.i('Poller started (${ecuType.name}, every '
|
||||||
if (checksumOk) {
|
'${_pollingInterval.inMilliseconds} ms, timeout '
|
||||||
validFrames++;
|
'${responseTimeout.inMilliseconds} ms)');
|
||||||
latestUsableFrame = frame;
|
unawaited(_loop(gen));
|
||||||
_rxBuf.removeRange(0, startIdx + 128);
|
}
|
||||||
} else {
|
|
||||||
// Bad checksum — the window is misaligned (or the frame was
|
void stop() {
|
||||||
// corrupted in transit). Drop one byte and rescan for the next
|
if (!_running) return;
|
||||||
// 0x1B so the stream resynchronizes instead of permanently
|
_running = false;
|
||||||
// sliding a fixed-size window over garbage.
|
_generation++;
|
||||||
droppedFrames++;
|
_rxSub?.cancel();
|
||||||
_rxBuf.removeRange(0, startIdx + 1);
|
_rxSub = null;
|
||||||
}
|
final p = _pending;
|
||||||
}
|
if (p != null && !p.completer.isCompleted) p.completer.complete(null);
|
||||||
|
_pending = null;
|
||||||
if (latestUsableFrame != null) {
|
_rxBuf.clear();
|
||||||
_frameController.add(latestUsableFrame);
|
AppLog.i('Poller stopped (sent=$requestsSent ok=$validFrames '
|
||||||
// The device answered — clear the road for the next request. Wait out
|
'timeouts=$timeouts badChecksum=$droppedFrames)');
|
||||||
// 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
|
void dispose() {
|
||||||
// RPM count between reads — hammering it faster than that makes it
|
stop();
|
||||||
// fall back to reporting "no fresh count yet" on every subsequent
|
_frameController.close();
|
||||||
// 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.
|
// ── Main loop ─────────────────────────────────────────────────────────────
|
||||||
_awaitingResponse = false;
|
|
||||||
Timer(pollingInterval, _maybeSendRequest);
|
Future<void> _loop(int gen) async {
|
||||||
}
|
bool alive() => _running && gen == _generation;
|
||||||
}
|
|
||||||
|
while (alive()) {
|
||||||
void stop() {
|
final started = DateTime.now();
|
||||||
_pollTimer?.cancel();
|
|
||||||
_pollTimer = null;
|
await _withLink(() async {
|
||||||
_rxSub?.cancel();
|
if (!alive() || !_service.isConnected) return;
|
||||||
_rxSub = null;
|
final req = ecuType == EcuType.s300 ? _s300Request : _kproRequest;
|
||||||
_rxBuf.clear();
|
final frame = await _transact(req, _matchDatalogFrame, responseTimeout);
|
||||||
}
|
if (!alive()) return;
|
||||||
|
|
||||||
void dispose() {
|
if (frame != null) {
|
||||||
stop();
|
validFrames++;
|
||||||
_frameController.close();
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,56 +1,99 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart';
|
import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart';
|
||||||
|
|
||||||
/// Wraps flutter_bluetooth_serial for SPP/RFCOMM Classic BT.
|
import '../logging/app_log.dart';
|
||||||
class BtService {
|
|
||||||
BluetoothConnection? _connection;
|
/// Wraps flutter_bluetooth_serial for SPP/RFCOMM Classic BT.
|
||||||
final StreamController<Uint8List> _rawFrameController =
|
///
|
||||||
StreamController<Uint8List>.broadcast();
|
/// 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.
|
||||||
bool get isConnected => _connection?.isConnected ?? false;
|
class BtService {
|
||||||
|
BluetoothConnection? _connection;
|
||||||
Stream<Uint8List> get rawStream => _rawFrameController.stream;
|
StreamSubscription<Uint8List>? _inputSub;
|
||||||
|
bool _connecting = false;
|
||||||
Future<List<BluetoothDevice>> getPairedDevices() async {
|
|
||||||
return FlutterBluetoothSerial.instance.getBondedDevices();
|
final StreamController<Uint8List> _rawFrameController =
|
||||||
}
|
StreamController<Uint8List>.broadcast();
|
||||||
|
|
||||||
Future<void> connect(BluetoothDevice device) async {
|
bool get isConnected {
|
||||||
if (_connection != null) {
|
try {
|
||||||
await disconnect();
|
return _connection?.isConnected ?? false;
|
||||||
}
|
} catch (_) {
|
||||||
_connection = await BluetoothConnection.toAddress(device.address);
|
return false;
|
||||||
_connection!.input!.listen(
|
}
|
||||||
(Uint8List data) {
|
}
|
||||||
if (!_rawFrameController.isClosed) {
|
|
||||||
_rawFrameController.add(data);
|
Stream<Uint8List> get rawStream => _rawFrameController.stream;
|
||||||
}
|
|
||||||
},
|
Future<List<BluetoothDevice>> getPairedDevices() async {
|
||||||
onDone: () {
|
return FlutterBluetoothSerial.instance.getBondedDevices();
|
||||||
_rawFrameController.addError('BT connection closed');
|
}
|
||||||
},
|
|
||||||
onError: (Object e) {
|
Future<void> connect(BluetoothDevice device) async {
|
||||||
_rawFrameController.addError(e);
|
if (_connecting) {
|
||||||
},
|
throw StateError('A connection attempt is already in progress');
|
||||||
);
|
}
|
||||||
}
|
_connecting = true;
|
||||||
|
try {
|
||||||
Future<void> write(Uint8List data) async {
|
if (_connection != null) {
|
||||||
if (_connection?.isConnected ?? false) {
|
await disconnect();
|
||||||
_connection!.output.add(data);
|
}
|
||||||
await _connection!.output.allSent;
|
AppLog.i('Connecting to ${device.name ?? '?'} (${device.address})');
|
||||||
}
|
final conn = await BluetoothConnection.toAddress(device.address)
|
||||||
}
|
.timeout(const Duration(seconds: 15));
|
||||||
|
_connection = conn;
|
||||||
Future<void> disconnect() async {
|
_inputSub = conn.input?.listen(
|
||||||
await _connection?.close();
|
(Uint8List data) {
|
||||||
_connection = null;
|
if (!_rawFrameController.isClosed) _rawFrameController.add(data);
|
||||||
}
|
},
|
||||||
|
onDone: () {
|
||||||
void dispose() {
|
AppLog.w('BT input stream closed by remote/OS');
|
||||||
disconnect();
|
if (!_rawFrameController.isClosed && identical(_connection, conn)) {
|
||||||
_rawFrameController.close();
|
_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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
93
lib/core/bluetooth/rpm_glitch_filter.dart
Normal file
93
lib/core/bluetooth/rpm_glitch_filter.dart
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
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 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:sqflite/sqflite.dart';
|
import 'package:sqflite/sqflite.dart';
|
||||||
|
|
||||||
import '../models/datalog_session.dart';
|
import '../models/datalog_session.dart';
|
||||||
|
|
||||||
/// SQLite database wrapper for sessions and raw ECU frames.
|
/// SQLite database wrapper for sessions and raw ECU frames.
|
||||||
class DatalogDb {
|
class DatalogDb {
|
||||||
static const _dbName = 'hvbt_datalog.db';
|
static const _dbName = 'hvbt_datalog.db';
|
||||||
static const _version = 1;
|
static const _version = 1;
|
||||||
|
|
||||||
Database? _db;
|
Database? _db;
|
||||||
|
|
||||||
Future<Database> get _database async {
|
Future<Database> get _database async {
|
||||||
_db ??= await _open();
|
_db ??= await _open();
|
||||||
return _db!;
|
return _db!;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Database> _open() async {
|
Future<Database> _open() async {
|
||||||
final dir = await getApplicationDocumentsDirectory();
|
final dir = await getApplicationDocumentsDirectory();
|
||||||
final path = p.join(dir.path, _dbName);
|
final path = p.join(dir.path, _dbName);
|
||||||
return openDatabase(
|
return openDatabase(
|
||||||
path,
|
path,
|
||||||
version: _version,
|
version: _version,
|
||||||
onCreate: (db, _) async {
|
onCreate: (db, _) async {
|
||||||
await db.execute('''
|
await db.execute('''
|
||||||
CREATE TABLE sessions (
|
CREATE TABLE sessions (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
ecu_type TEXT NOT NULL,
|
ecu_type TEXT NOT NULL,
|
||||||
start_time INTEGER NOT NULL,
|
start_time INTEGER NOT NULL,
|
||||||
end_time INTEGER,
|
end_time INTEGER,
|
||||||
frame_count INTEGER NOT NULL DEFAULT 0
|
frame_count INTEGER NOT NULL DEFAULT 0
|
||||||
)
|
)
|
||||||
''');
|
''');
|
||||||
await db.execute('''
|
await db.execute('''
|
||||||
CREATE TABLE frames (
|
CREATE TABLE frames (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
session_id INTEGER NOT NULL,
|
session_id INTEGER NOT NULL,
|
||||||
timestamp INTEGER NOT NULL,
|
timestamp INTEGER NOT NULL,
|
||||||
data BLOB NOT NULL,
|
data BLOB NOT NULL,
|
||||||
FOREIGN KEY(session_id) REFERENCES sessions(id) ON DELETE CASCADE
|
FOREIGN KEY(session_id) REFERENCES sessions(id) ON DELETE CASCADE
|
||||||
)
|
)
|
||||||
''');
|
''');
|
||||||
await db.execute(
|
await db.execute(
|
||||||
'CREATE INDEX idx_frames_session ON frames(session_id)');
|
'CREATE INDEX idx_frames_session ON frames(session_id)');
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Sessions ──────────────────────────────────────────────────────────────
|
// ── Sessions ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Inserts a new session row. Returns the assigned id.
|
/// Inserts a new session row. Returns the assigned id.
|
||||||
Future<int> insertSession(DatalogSession session) async {
|
Future<int> insertSession(DatalogSession session) async {
|
||||||
final db = await _database;
|
final db = await _database;
|
||||||
return db.insert('sessions', session.toMap()..remove('id'));
|
return db.insert('sessions', session.toMap()..remove('id'));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Updates end_time and frame_count for an existing session.
|
/// Updates end_time and frame_count for an existing session.
|
||||||
Future<void> closeSession(int sessionId, DateTime endTime, int frameCount) async {
|
Future<void> closeSession(int sessionId, DateTime endTime, int frameCount) async {
|
||||||
final db = await _database;
|
final db = await _database;
|
||||||
await db.update(
|
await db.update(
|
||||||
'sessions',
|
'sessions',
|
||||||
{
|
{
|
||||||
'end_time': endTime.millisecondsSinceEpoch,
|
'end_time': endTime.millisecondsSinceEpoch,
|
||||||
'frame_count': frameCount,
|
'frame_count': frameCount,
|
||||||
},
|
},
|
||||||
where: 'id = ?',
|
where: 'id = ?',
|
||||||
whereArgs: [sessionId],
|
whereArgs: [sessionId],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns all sessions ordered newest first.
|
/// Returns all sessions ordered newest first.
|
||||||
Future<List<DatalogSession>> getSessions() async {
|
Future<List<DatalogSession>> getSessions() async {
|
||||||
final db = await _database;
|
final db = await _database;
|
||||||
final rows = await db.query('sessions', orderBy: 'start_time DESC');
|
final rows = await db.query('sessions', orderBy: 'start_time DESC');
|
||||||
return rows.map(DatalogSession.fromMap).toList();
|
return rows.map(DatalogSession.fromMap).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Deletes a session and all its frames (CASCADE).
|
/// Deletes a session and all its frames (CASCADE).
|
||||||
Future<void> deleteSession(int sessionId) async {
|
Future<void> deleteSession(int sessionId) async {
|
||||||
final db = await _database;
|
final db = await _database;
|
||||||
await db.delete('sessions', where: 'id = ?', whereArgs: [sessionId]);
|
await db.delete('sessions', where: 'id = ?', whereArgs: [sessionId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Frames ────────────────────────────────────────────────────────────────
|
// ── Frames ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Batch-inserts multiple frames in a single transaction.
|
/// Batch-inserts multiple frames in a single transaction.
|
||||||
Future<void> insertFrames(
|
Future<void> insertFrames(
|
||||||
int sessionId, List<PendingFrame> frames) async {
|
int sessionId, List<PendingFrame> frames) async {
|
||||||
if (frames.isEmpty) return;
|
if (frames.isEmpty) return;
|
||||||
final db = await _database;
|
final db = await _database;
|
||||||
await db.transaction((txn) async {
|
await db.transaction((txn) async {
|
||||||
final batch = txn.batch();
|
final batch = txn.batch();
|
||||||
for (final f in frames) {
|
for (final f in frames) {
|
||||||
batch.insert('frames', {
|
batch.insert('frames', {
|
||||||
'session_id': sessionId,
|
'session_id': sessionId,
|
||||||
'timestamp': f.timestamp.millisecondsSinceEpoch,
|
'timestamp': f.timestamp.millisecondsSinceEpoch,
|
||||||
'data': f.data,
|
'data': f.data,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
await batch.commit(noResult: true);
|
await batch.commit(noResult: true);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns all raw frames for a session, ordered by timestamp.
|
/// Returns all raw frames for a session, ordered by timestamp.
|
||||||
Future<List<StoredFrame>> getFrames(int sessionId) async {
|
Future<List<StoredFrame>> getFrames(int sessionId) async {
|
||||||
final db = await _database;
|
final db = await _database;
|
||||||
final rows = await db.query(
|
final rows = await db.query(
|
||||||
'frames',
|
'frames',
|
||||||
where: 'session_id = ?',
|
where: 'session_id = ?',
|
||||||
whereArgs: [sessionId],
|
whereArgs: [sessionId],
|
||||||
orderBy: 'timestamp ASC',
|
orderBy: 'timestamp ASC',
|
||||||
);
|
);
|
||||||
return rows
|
return rows
|
||||||
.map((r) => StoredFrame(
|
.map((r) => StoredFrame(
|
||||||
timestamp: DateTime.fromMillisecondsSinceEpoch(
|
timestamp: DateTime.fromMillisecondsSinceEpoch(
|
||||||
r['timestamp'] as int),
|
r['timestamp'] as int),
|
||||||
data: r['data'] as Uint8List,
|
data: r['data'] as Uint8List,
|
||||||
))
|
))
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> close() async => _db?.close();
|
Future<void> close() async => _db?.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── DTOs ─────────────────────────────────────────────────────────────────────
|
// ─── DTOs ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class PendingFrame {
|
class PendingFrame {
|
||||||
final DateTime timestamp;
|
final DateTime timestamp;
|
||||||
final Uint8List data;
|
final Uint8List data;
|
||||||
const PendingFrame(this.timestamp, this.data);
|
const PendingFrame(this.timestamp, this.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
class StoredFrame {
|
class StoredFrame {
|
||||||
final DateTime timestamp;
|
final DateTime timestamp;
|
||||||
final Uint8List data;
|
final Uint8List data;
|
||||||
const StoredFrame({required this.timestamp, required this.data});
|
const StoredFrame({required this.timestamp, required this.data});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,119 +1,122 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'datalog_db.dart';
|
import 'datalog_db.dart';
|
||||||
|
|
||||||
enum PlaybackStatus { idle, playing, paused }
|
enum PlaybackStatus { idle, playing, paused }
|
||||||
|
|
||||||
/// Loads stored frames from SQLite and replays them through a stream
|
/// Loads stored frames from SQLite and replays them through a stream
|
||||||
/// at configurable speed. Consumers watch [frameStream] exactly like
|
/// at configurable speed. Consumers watch [frameStream] exactly like
|
||||||
/// the live BT stream — the parser layer is unaware of the difference.
|
/// the live BT stream — the parser layer is unaware of the difference.
|
||||||
class DatalogPlayer {
|
class DatalogPlayer {
|
||||||
final DatalogDb _db;
|
final DatalogDb _db;
|
||||||
|
|
||||||
List<StoredFrame> _frames = [];
|
List<StoredFrame> _frames = [];
|
||||||
int _position = 0; // index into _frames
|
int _position = 0; // index into _frames
|
||||||
double _speed = 1.0; // 1.0 = real-time, 2.0 = 2× faster
|
double _speed = 1.0; // 1.0 = real-time, 2.0 = 2× faster
|
||||||
|
|
||||||
PlaybackStatus _status = PlaybackStatus.idle;
|
PlaybackStatus _status = PlaybackStatus.idle;
|
||||||
PlaybackStatus get status => _status;
|
PlaybackStatus get status => _status;
|
||||||
int get position => _position;
|
int get position => _position;
|
||||||
int get totalFrames => _frames.length;
|
int get totalFrames => _frames.length;
|
||||||
double get speed => _speed;
|
double get speed => _speed;
|
||||||
set speed(double v) => _speed = v.clamp(0.25, 8.0);
|
set speed(double v) => _speed = v.clamp(0.25, 8.0);
|
||||||
|
|
||||||
final StreamController<Uint8List> _controller =
|
final StreamController<Uint8List> _controller =
|
||||||
StreamController<Uint8List>.broadcast();
|
StreamController<Uint8List>.broadcast();
|
||||||
|
|
||||||
Stream<Uint8List> get frameStream => _controller.stream;
|
Stream<Uint8List> get frameStream => _controller.stream;
|
||||||
|
|
||||||
/// Callback fired on each tick — lets UI update a progress indicator.
|
/// Callback fired on each tick — lets UI update a progress indicator.
|
||||||
void Function(int position, int total)? onProgress;
|
void Function(int position, int total)? onProgress;
|
||||||
|
|
||||||
Timer? _timer;
|
Timer? _timer;
|
||||||
|
|
||||||
DatalogPlayer(this._db);
|
DatalogPlayer(this._db);
|
||||||
|
|
||||||
/// Load all frames for [sessionId] into memory.
|
/// Load all frames for [sessionId] into memory.
|
||||||
Future<void> load(int sessionId) async {
|
Future<void> load(int sessionId) async {
|
||||||
await stop();
|
await stop();
|
||||||
_frames = await _db.getFrames(sessionId);
|
_frames = await _db.getFrames(sessionId);
|
||||||
_position = 0;
|
_position = 0;
|
||||||
_status = PlaybackStatus.idle;
|
_status = PlaybackStatus.idle;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start or resume playback.
|
/// Start or resume playback.
|
||||||
void play() {
|
void play() {
|
||||||
if (_status == PlaybackStatus.playing) return;
|
if (_status == PlaybackStatus.playing) return;
|
||||||
if (_frames.isEmpty) return;
|
if (_frames.isEmpty) return;
|
||||||
_status = PlaybackStatus.playing;
|
_status = PlaybackStatus.playing;
|
||||||
_scheduleNext();
|
_scheduleNext();
|
||||||
}
|
}
|
||||||
|
|
||||||
void pause() {
|
void pause() {
|
||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
_timer = null;
|
_timer = null;
|
||||||
if (_status == PlaybackStatus.playing) {
|
if (_status == PlaybackStatus.playing) {
|
||||||
_status = PlaybackStatus.paused;
|
_status = PlaybackStatus.paused;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> stop() async {
|
Future<void> stop() async {
|
||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
_timer = null;
|
_timer = null;
|
||||||
_position = 0;
|
_position = 0;
|
||||||
_status = PlaybackStatus.idle;
|
_status = PlaybackStatus.idle;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Jump to a specific frame index.
|
/// Jump to a specific frame index. The frame there is emitted at once so
|
||||||
void seek(int index) {
|
/// the displays follow the slider even while paused.
|
||||||
_position = index.clamp(0, _frames.length - 1);
|
void seek(int index) {
|
||||||
onProgress?.call(_position, _frames.length);
|
if (_frames.isEmpty) return;
|
||||||
}
|
_position = index.clamp(0, _frames.length - 1);
|
||||||
|
_controller.add(_frames[_position].data);
|
||||||
void _scheduleNext() {
|
onProgress?.call(_position, _frames.length);
|
||||||
if (_position >= _frames.length) {
|
}
|
||||||
_status = PlaybackStatus.idle;
|
|
||||||
onProgress?.call(_frames.length, _frames.length);
|
void _scheduleNext() {
|
||||||
return;
|
if (_position >= _frames.length) {
|
||||||
}
|
_status = PlaybackStatus.idle;
|
||||||
|
onProgress?.call(_frames.length, _frames.length);
|
||||||
// Calculate delay to next frame using original timestamps
|
return;
|
||||||
Duration delay = const Duration(milliseconds: 100); // default 10 Hz
|
}
|
||||||
if (_position + 1 < _frames.length) {
|
|
||||||
final gap = _frames[_position + 1].timestamp
|
// Calculate delay to next frame using original timestamps
|
||||||
.difference(_frames[_position].timestamp);
|
Duration delay = const Duration(milliseconds: 100); // default 10 Hz
|
||||||
delay = Duration(
|
if (_position + 1 < _frames.length) {
|
||||||
microseconds: (gap.inMicroseconds / _speed).round(),
|
final gap = _frames[_position + 1].timestamp
|
||||||
);
|
.difference(_frames[_position].timestamp);
|
||||||
// Clamp to 10ms–2s to handle gaps from pauses/reconnects
|
delay = Duration(
|
||||||
delay = delay.clamp(
|
microseconds: (gap.inMicroseconds / _speed).round(),
|
||||||
const Duration(milliseconds: 10),
|
);
|
||||||
const Duration(seconds: 2),
|
// 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);
|
_timer = Timer(delay, () {
|
||||||
onProgress?.call(_position, _frames.length);
|
if (_status != PlaybackStatus.playing) return;
|
||||||
_position++;
|
if (_position < _frames.length) {
|
||||||
}
|
_controller.add(_frames[_position].data);
|
||||||
_scheduleNext();
|
onProgress?.call(_position, _frames.length);
|
||||||
});
|
_position++;
|
||||||
}
|
}
|
||||||
|
_scheduleNext();
|
||||||
void dispose() {
|
});
|
||||||
_timer?.cancel();
|
}
|
||||||
_controller.close();
|
|
||||||
}
|
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;
|
extension on Duration {
|
||||||
return this;
|
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:async';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'datalog_db.dart';
|
import 'datalog_db.dart';
|
||||||
import '../models/datalog_session.dart';
|
import '../models/datalog_session.dart';
|
||||||
|
|
||||||
/// Listens to a raw frame stream and batches writes to SQLite every 500ms.
|
/// Listens to a raw frame stream and batches writes to SQLite every 500ms.
|
||||||
class DatalogRecorder {
|
class DatalogRecorder {
|
||||||
final DatalogDb _db;
|
final DatalogDb _db;
|
||||||
|
|
||||||
int? _sessionId;
|
int? _sessionId;
|
||||||
String? _ecuType;
|
String? _ecuType;
|
||||||
DateTime? _startTime;
|
DateTime? _startTime;
|
||||||
int _frameCount = 0;
|
int _frameCount = 0;
|
||||||
|
|
||||||
final List<PendingFrame> _batch = [];
|
final List<PendingFrame> _batch = [];
|
||||||
Timer? _flushTimer;
|
Timer? _flushTimer;
|
||||||
StreamSubscription<Uint8List>? _frameSub;
|
StreamSubscription<Uint8List>? _frameSub;
|
||||||
|
|
||||||
bool get isRecording => _sessionId != null;
|
bool get isRecording => _sessionId != null;
|
||||||
int get frameCount => _frameCount;
|
int get frameCount => _frameCount;
|
||||||
int? get sessionId => _sessionId;
|
int? get sessionId => _sessionId;
|
||||||
|
|
||||||
DatalogRecorder(this._db);
|
DatalogRecorder(this._db);
|
||||||
|
|
||||||
/// Start a new recording session, listening to [frameStream].
|
/// Start a new recording session, listening to [frameStream].
|
||||||
Future<void> start(Stream<Uint8List> frameStream, String ecuType) async {
|
Future<void> start(Stream<Uint8List> frameStream, String ecuType) async {
|
||||||
if (isRecording) await stop();
|
if (isRecording) await stop();
|
||||||
|
|
||||||
_ecuType = ecuType;
|
_ecuType = ecuType;
|
||||||
_startTime = DateTime.now();
|
_startTime = DateTime.now();
|
||||||
_frameCount = 0;
|
_frameCount = 0;
|
||||||
_batch.clear();
|
_batch.clear();
|
||||||
|
|
||||||
final session = DatalogSession(
|
final session = DatalogSession(
|
||||||
ecuType: ecuType,
|
ecuType: ecuType,
|
||||||
startTime: _startTime!,
|
startTime: _startTime!,
|
||||||
);
|
);
|
||||||
_sessionId = await _db.insertSession(session);
|
_sessionId = await _db.insertSession(session);
|
||||||
|
|
||||||
// Collect frames into batch buffer
|
// Collect frames into batch buffer
|
||||||
_frameSub = frameStream.listen((Uint8List frame) {
|
_frameSub = frameStream.listen((Uint8List frame) {
|
||||||
_batch.add(PendingFrame(DateTime.now(), frame));
|
_batch.add(PendingFrame(DateTime.now(), frame));
|
||||||
_frameCount++;
|
_frameCount++;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Flush to DB every 500ms
|
// Flush to DB every 500ms
|
||||||
_flushTimer = Timer.periodic(const Duration(milliseconds: 500), (_) {
|
_flushTimer = Timer.periodic(const Duration(milliseconds: 500), (_) {
|
||||||
_flush();
|
_flush();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stop recording and finalise the session row.
|
/// Stop recording and finalise the session row.
|
||||||
Future<DatalogSession?> stop() async {
|
Future<DatalogSession?> stop() async {
|
||||||
if (!isRecording) return null;
|
if (!isRecording) return null;
|
||||||
|
|
||||||
_flushTimer?.cancel();
|
_flushTimer?.cancel();
|
||||||
_flushTimer = null;
|
_flushTimer = null;
|
||||||
await _frameSub?.cancel();
|
await _frameSub?.cancel();
|
||||||
_frameSub = null;
|
_frameSub = null;
|
||||||
|
|
||||||
await _flush(); // write any remaining buffered frames
|
await _flush(); // write any remaining buffered frames
|
||||||
|
|
||||||
final endTime = DateTime.now();
|
final endTime = DateTime.now();
|
||||||
await _db.closeSession(_sessionId!, endTime, _frameCount);
|
await _db.closeSession(_sessionId!, endTime, _frameCount);
|
||||||
|
|
||||||
final finished = DatalogSession(
|
final finished = DatalogSession(
|
||||||
id: _sessionId,
|
id: _sessionId,
|
||||||
ecuType: _ecuType!,
|
ecuType: _ecuType!,
|
||||||
startTime: _startTime!,
|
startTime: _startTime!,
|
||||||
endTime: endTime,
|
endTime: endTime,
|
||||||
frameCount: _frameCount,
|
frameCount: _frameCount,
|
||||||
);
|
);
|
||||||
|
|
||||||
_sessionId = null;
|
_sessionId = null;
|
||||||
_ecuType = null;
|
_ecuType = null;
|
||||||
_startTime = null;
|
_startTime = null;
|
||||||
_frameCount = 0;
|
_frameCount = 0;
|
||||||
|
|
||||||
return finished;
|
return finished;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _flush() async {
|
Future<void> _flush() async {
|
||||||
if (_batch.isEmpty || _sessionId == null) return;
|
if (_batch.isEmpty || _sessionId == null) return;
|
||||||
final toWrite = List<PendingFrame>.from(_batch);
|
final toWrite = List<PendingFrame>.from(_batch);
|
||||||
_batch.clear();
|
_batch.clear();
|
||||||
await _db.insertFrames(_sessionId!, toWrite);
|
await _db.insertFrames(_sessionId!, toWrite);
|
||||||
}
|
}
|
||||||
|
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_flushTimer?.cancel();
|
_flushTimer?.cancel();
|
||||||
_frameSub?.cancel();
|
_frameSub?.cancel();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
42
lib/core/logging/app_log.dart
Normal file
42
lib/core/logging/app_log.dart
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
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.
|
/// Metadata for a single recorded ECU datalog session.
|
||||||
class DatalogSession {
|
class DatalogSession {
|
||||||
final int? id;
|
final int? id;
|
||||||
final String ecuType;
|
final String ecuType;
|
||||||
final DateTime startTime;
|
final DateTime startTime;
|
||||||
final DateTime? endTime;
|
final DateTime? endTime;
|
||||||
final int frameCount;
|
final int frameCount;
|
||||||
|
|
||||||
const DatalogSession({
|
const DatalogSession({
|
||||||
this.id,
|
this.id,
|
||||||
required this.ecuType,
|
required this.ecuType,
|
||||||
required this.startTime,
|
required this.startTime,
|
||||||
this.endTime,
|
this.endTime,
|
||||||
this.frameCount = 0,
|
this.frameCount = 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
Duration get duration {
|
Duration get duration {
|
||||||
final end = endTime ?? DateTime.now();
|
final end = endTime ?? DateTime.now();
|
||||||
return end.difference(startTime);
|
return end.difference(startTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
String get durationLabel {
|
String get durationLabel {
|
||||||
final d = duration;
|
final d = duration;
|
||||||
final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
|
final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
|
||||||
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
|
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
|
||||||
return '$m:$s';
|
return '$m:$s';
|
||||||
}
|
}
|
||||||
|
|
||||||
DatalogSession copyWith({
|
DatalogSession copyWith({
|
||||||
int? id,
|
int? id,
|
||||||
String? ecuType,
|
String? ecuType,
|
||||||
DateTime? startTime,
|
DateTime? startTime,
|
||||||
DateTime? endTime,
|
DateTime? endTime,
|
||||||
int? frameCount,
|
int? frameCount,
|
||||||
}) =>
|
}) =>
|
||||||
DatalogSession(
|
DatalogSession(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
ecuType: ecuType ?? this.ecuType,
|
ecuType: ecuType ?? this.ecuType,
|
||||||
startTime: startTime ?? this.startTime,
|
startTime: startTime ?? this.startTime,
|
||||||
endTime: endTime ?? this.endTime,
|
endTime: endTime ?? this.endTime,
|
||||||
frameCount: frameCount ?? this.frameCount,
|
frameCount: frameCount ?? this.frameCount,
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> toMap() => {
|
Map<String, dynamic> toMap() => {
|
||||||
'id': id,
|
'id': id,
|
||||||
'ecu_type': ecuType,
|
'ecu_type': ecuType,
|
||||||
'start_time': startTime.millisecondsSinceEpoch,
|
'start_time': startTime.millisecondsSinceEpoch,
|
||||||
'end_time': endTime?.millisecondsSinceEpoch,
|
'end_time': endTime?.millisecondsSinceEpoch,
|
||||||
'frame_count': frameCount,
|
'frame_count': frameCount,
|
||||||
};
|
};
|
||||||
|
|
||||||
factory DatalogSession.fromMap(Map<String, dynamic> m) => DatalogSession(
|
factory DatalogSession.fromMap(Map<String, dynamic> m) => DatalogSession(
|
||||||
id: m['id'] as int?,
|
id: m['id'] as int?,
|
||||||
ecuType: m['ecu_type'] as String,
|
ecuType: m['ecu_type'] as String,
|
||||||
startTime:
|
startTime:
|
||||||
DateTime.fromMillisecondsSinceEpoch(m['start_time'] as int),
|
DateTime.fromMillisecondsSinceEpoch(m['start_time'] as int),
|
||||||
endTime: m['end_time'] == null
|
endTime: m['end_time'] == null
|
||||||
? null
|
? null
|
||||||
: DateTime.fromMillisecondsSinceEpoch(m['end_time'] as int),
|
: DateTime.fromMillisecondsSinceEpoch(m['end_time'] as int),
|
||||||
frameCount: m['frame_count'] as int? ?? 0,
|
frameCount: m['frame_count'] as int? ?? 0,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,14 @@
|
|||||||
/// Definition of a boolean flag sensor shown as indicator pills.
|
import '../protocol/sensor_state.dart';
|
||||||
class FlagDef {
|
|
||||||
final String id;
|
/// Definition of a boolean flag sensor shown as indicator pills.
|
||||||
final String displayName;
|
class FlagDef {
|
||||||
|
final String id;
|
||||||
const FlagDef({required this.id, required this.displayName});
|
final String displayName;
|
||||||
}
|
final bool Function(SensorState s) read;
|
||||||
|
|
||||||
|
const FlagDef({
|
||||||
|
required this.id,
|
||||||
|
required this.displayName,
|
||||||
|
required this.read,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@ -1,16 +1,19 @@
|
|||||||
/// Definition of a single sensor — used for bar gauges, pickers, and graph panels.
|
/// Definition of a single sensor — used for bar gauges, pickers, and graph panels.
|
||||||
class SensorDef {
|
class SensorDef {
|
||||||
final String id;
|
final String id;
|
||||||
final String displayName;
|
final String displayName;
|
||||||
final String unit;
|
final String unit;
|
||||||
final double min;
|
final double min;
|
||||||
final double max;
|
final double max;
|
||||||
|
/// Decimal places shown for this sensor in its base unit.
|
||||||
const SensorDef({
|
final int decimals;
|
||||||
required this.id,
|
|
||||||
required this.displayName,
|
const SensorDef({
|
||||||
required this.unit,
|
required this.id,
|
||||||
required this.min,
|
required this.displayName,
|
||||||
required this.max,
|
required this.unit,
|
||||||
});
|
required this.min,
|
||||||
}
|
required this.max,
|
||||||
|
this.decimals = 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@ -1,67 +1,136 @@
|
|||||||
import 'sensor_def.dart';
|
import '../protocol/sensor_state.dart';
|
||||||
import 'flag_def.dart';
|
import 'sensor_def.dart';
|
||||||
|
import 'flag_def.dart';
|
||||||
/// Master list of all analog sensor definitions.
|
|
||||||
const List<SensorDef> sensorDefs = [
|
/// Master list of all analog sensor definitions. Units, ranges and decimals
|
||||||
SensorDef(id: 'rpm', displayName: 'RPM', unit: 'revs', min: 0, max: 9000),
|
/// are the base (metric) ones; see units.dart for the displayed unit.
|
||||||
SensorDef(id: 'vss', displayName: 'Speed', unit: 'km/h', min: 0, max: 280),
|
const List<SensorDef> sensorDefs = [
|
||||||
SensorDef(
|
SensorDef(
|
||||||
id: 'map',
|
id: 'rpm', displayName: 'RPM', unit: 'rpm', min: 0, max: 9000, decimals: 0),
|
||||||
displayName: 'Manifold pressure',
|
SensorDef(
|
||||||
unit: 'kPa',
|
id: 'vss', displayName: 'Speed', unit: 'km/h', min: 0, max: 280, decimals: 0),
|
||||||
min: 0,
|
SensorDef(
|
||||||
max: 300),
|
id: 'map', displayName: 'Manifold pressure', unit: 'kPa', min: 0, max: 300),
|
||||||
SensorDef(
|
SensorDef(
|
||||||
id: 'tps', displayName: 'Throttle pedal', unit: '%', min: 0, max: 100),
|
id: 'tps', displayName: 'Throttle position', unit: '%', min: 0, max: 100),
|
||||||
SensorDef(
|
SensorDef(
|
||||||
id: 'inj', displayName: 'Injector duration', unit: 'ms', min: 0, max: 20),
|
id: 'inj',
|
||||||
SensorDef(
|
displayName: 'Injector duration',
|
||||||
id: 'ign', displayName: 'Timing advance', unit: 'deg', min: -20, max: 60),
|
unit: 'ms',
|
||||||
SensorDef(
|
min: 0,
|
||||||
id: 'ect', displayName: 'Coolant temp', unit: '°C', min: -40, max: 150),
|
max: 20,
|
||||||
SensorDef(
|
decimals: 2),
|
||||||
id: 'iat',
|
SensorDef(
|
||||||
displayName: 'Intake air temp',
|
id: 'ign', displayName: 'Timing advance', unit: '°', min: -20, max: 60),
|
||||||
unit: '°C',
|
SensorDef(
|
||||||
min: -40,
|
id: 'ect', displayName: 'Coolant temp', unit: '°C', min: -40, max: 150),
|
||||||
max: 150),
|
SensorDef(
|
||||||
SensorDef(
|
id: 'iat',
|
||||||
id: 'bat', displayName: 'Battery voltage', unit: 'V', min: 0, max: 20),
|
displayName: 'Intake air temp',
|
||||||
SensorDef(id: 'o2', displayName: 'O2 sensor', unit: 'V', min: 0, max: 1.5),
|
unit: '°C',
|
||||||
SensorDef(id: 'gear', displayName: 'Gear', unit: '—', min: 1, max: 6),
|
min: -40,
|
||||||
SensorDef(id: 'eth', displayName: 'Ethanol', unit: '%', min: 0, max: 100),
|
max: 150),
|
||||||
SensorDef(
|
SensorDef(
|
||||||
id: 'pa', displayName: 'Baro pressure', unit: 'kPa', min: 0, max: 120),
|
id: 'bat', displayName: 'Battery voltage', unit: 'V', min: 0, max: 20),
|
||||||
SensorDef(id: 'afr', displayName: 'AFR', unit: 'λ', min: 0.5, max: 2.0),
|
SensorDef(
|
||||||
SensorDef(
|
id: 'o2', displayName: 'O2 sensor', unit: 'V', min: 0, max: 5, decimals: 2),
|
||||||
id: 'strim', displayName: 'Short trim', unit: '%', min: -30, max: 30),
|
// Derived: injector duration × RPM (USB tool: 16.67 ms @ 3210 rpm = 44 %).
|
||||||
SensorDef(
|
SensorDef(
|
||||||
id: 'ltrim', displayName: 'Long trim', unit: '%', min: -30, max: 30),
|
id: 'duty', displayName: 'Injector duty', unit: '%', min: 0, max: 100, decimals: 0),
|
||||||
SensorDef(
|
// AFR/AIN scales not yet bench-verified: shown as raw values.
|
||||||
id: 'ain0', displayName: 'Analog input 0', unit: 'V', min: 0, max: 5),
|
SensorDef(
|
||||||
SensorDef(
|
id: 'gear', displayName: 'Gear', unit: '', min: 0, max: 6, decimals: 0),
|
||||||
id: 'ain1', displayName: 'Analog input 1', unit: 'V', min: 0, max: 5),
|
SensorDef(
|
||||||
SensorDef(
|
id: 'eth', displayName: 'Ethanol', unit: '%', min: 0, max: 100, decimals: 0),
|
||||||
id: 'ain2', displayName: 'Analog input 2', unit: 'V', min: 0, max: 5),
|
SensorDef(
|
||||||
SensorDef(
|
id: 'pa', displayName: 'Barometric pressure', unit: 'kPa', min: 0, max: 120),
|
||||||
id: 'ain3', displayName: 'Analog input 3', unit: 'V', min: 0, max: 5),
|
SensorDef(
|
||||||
SensorDef(
|
id: 'afr', displayName: 'AFR', unit: 'raw', min: 0, max: 255, decimals: 0),
|
||||||
id: 'ain4', displayName: 'Analog input 4', unit: 'V', min: 0, max: 5),
|
SensorDef(
|
||||||
SensorDef(
|
id: 'strim', displayName: 'Short trim', unit: '%', min: -30, max: 30),
|
||||||
id: 'ain5', displayName: 'Analog input 5', unit: 'V', min: 0, max: 5),
|
SensorDef(
|
||||||
SensorDef(
|
id: 'ltrim', displayName: 'Long trim', unit: '%', min: -30, max: 30),
|
||||||
id: 'ain6', displayName: 'Analog input 6', unit: 'V', min: 0, max: 5),
|
SensorDef(
|
||||||
SensorDef(
|
id: 'ain0', displayName: 'Analog input 0', unit: 'raw', min: 0, max: 1023, decimals: 0),
|
||||||
id: 'ain7', displayName: 'Analog input 7', unit: 'V', min: 0, max: 5),
|
SensorDef(
|
||||||
];
|
id: 'ain1', displayName: 'Analog input 1', unit: 'raw', min: 0, max: 1023, decimals: 0),
|
||||||
|
SensorDef(
|
||||||
/// Master list of boolean flag sensors.
|
id: 'ain2', displayName: 'Analog input 2', unit: 'raw', min: 0, max: 1023, decimals: 0),
|
||||||
const List<FlagDef> flagDefs = [
|
SensorDef(
|
||||||
FlagDef(id: 'mil', displayName: 'MIL'),
|
id: 'ain3', displayName: 'Analog input 3', unit: 'raw', min: 0, max: 1023, decimals: 0),
|
||||||
FlagDef(id: 'fuelpump', displayName: 'Fuel Pump'),
|
SensorDef(
|
||||||
FlagDef(id: 'fanout', displayName: 'FAN Out'),
|
id: 'ain4', displayName: 'Analog input 4', unit: 'raw', min: 0, max: 1023, decimals: 0),
|
||||||
FlagDef(id: 'vtec', displayName: 'VTEC'),
|
SensorDef(
|
||||||
FlagDef(id: 'knock', displayName: 'Knock'),
|
id: 'ain5', displayName: 'Analog input 5', unit: 'raw', min: 0, max: 1023, decimals: 0),
|
||||||
FlagDef(id: 'revlimit', displayName: 'Rev Limit'),
|
SensorDef(
|
||||||
FlagDef(id: 'launch', displayName: 'Launch'),
|
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);
|
||||||
|
|||||||
101
lib/core/models/units.dart
Normal file
101
lib/core/models/units.dart
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
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,249 +1,281 @@
|
|||||||
/// DTC maps for S300 and KPro ECU protocols.
|
/// DTC maps for S300 and KPro ECU protocols, transcribed from the "DTC map"
|
||||||
///
|
/// sections of hvbt_protocol.pdf.
|
||||||
/// Key format: 'ERRxx_bity' where xx = byte index (00-based), y = bit number (0=LSB).
|
///
|
||||||
/// Value: 'Pxxxx — description'
|
/// Key format: 'ERRxx_bity' where xx = ERR byte index (00-based), y = bit
|
||||||
library;
|
/// number (0=LSB). Value: '<code> — description'. Bits the PDF leaves
|
||||||
|
/// undocumented ("???") use 'ERRxx.y' as the code so the raw bit is still
|
||||||
/// S300 DTC map (ERR00–ERR03, 4 bytes × 8 bits = 32 possible codes)
|
/// visible on screen.
|
||||||
const Map<String, String> s300DtcMap = {
|
library;
|
||||||
// ERR00 (byte 0x31)
|
|
||||||
'ERR00_bit0': 'P0130 — O2 Sensor (front)',
|
/// S300 DTC map (ERR00–ERR03 = frame bytes 37–40). Descriptions include the
|
||||||
'ERR00_bit1': 'P0135 — O2 Sensor Heater (front)',
|
/// Honda blink code and sensor abbreviation from the PDF.
|
||||||
'ERR00_bit2': 'P0505 — Idle Air Control Valve',
|
const Map<String, String> s300DtcMap = {
|
||||||
'ERR00_bit3': 'P0108 — MAP Sensor High',
|
// ERR00
|
||||||
'ERR00_bit4': 'P0107 — MAP Sensor Low',
|
'ERR00_bit0': 'P0130 — Oxygen Sensor #1 (O2A, code 1)',
|
||||||
'ERR00_bit5': 'P0113 — IAT Sensor High',
|
'ERR00_bit1': 'P0150 — Oxygen Sensor #2 (O2B, code 2)',
|
||||||
'ERR00_bit6': 'P0112 — IAT Sensor Low',
|
'ERR00_bit2': 'P0105 — Manifold Absolute Pressure Sensor (MAP, code 3)',
|
||||||
'ERR00_bit7': 'P0118 — ECT Sensor High',
|
'ERR00_bit3': 'P0335 — Crank Position Sensor (CKP, code 4)',
|
||||||
|
'ERR00_bit4': 'P0105 — Manifold Absolute Pressure Sensor (MAP, code 5)',
|
||||||
// ERR01 (byte 0x32)
|
'ERR00_bit5': 'P0106 — Engine Coolant Sensor (ECT, code 6)',
|
||||||
'ERR01_bit0': 'P0117 — ECT Sensor Low',
|
'ERR00_bit6': 'P0123 — Throttle Position Sensor (TPS, code 7)',
|
||||||
'ERR01_bit1': 'P0336 — CKP Sensor',
|
'ERR00_bit7': 'P1362 — Top Dead Center Sensor (TDC, code 8)',
|
||||||
'ERR01_bit2': 'P0341 — CMP Sensor',
|
|
||||||
'ERR01_bit3': 'P1362 — TDC Sensor (No Signal)',
|
// ERR01
|
||||||
'ERR01_bit4': 'P1363 — TDC Sensor (No Signal 2)',
|
'ERR01_bit0': 'P0340 — Cylinder/Cam Sensor (CYL, code 9)',
|
||||||
'ERR01_bit5': 'P0335 — CKP Sensor (No Signal)',
|
'ERR01_bit1': 'P0113 — Intake Air Temperature Sensor (IAT, code 10)',
|
||||||
'ERR01_bit6': 'P1361 — TDC Sensor',
|
'ERR01_bit2': 'P0217 — Engine Overheating (OH, code 11)',
|
||||||
'ERR01_bit7': 'P0300 — Random Misfire',
|
'ERR01_bit3': 'P0403 — EGR Lift Valve (EGR, code 12)',
|
||||||
|
'ERR01_bit4': 'P1106 — Atmospheric Pressure Sensor (BARO, code 13)',
|
||||||
// ERR02 (byte 0x33)
|
'ERR01_bit5': 'P0505 — Idle Air Control Valve (IAC, code 14)',
|
||||||
'ERR02_bit0': 'P0301 — Misfire Cylinder 1',
|
'ERR01_bit6': 'P0350 — Ignition Control Module (IGM, code 15)',
|
||||||
'ERR02_bit1': 'P0302 — Misfire Cylinder 2',
|
'ERR01_bit7': 'P0200 — Fuel Injectors (INJ, code 16)',
|
||||||
'ERR02_bit2': 'P0303 — Misfire Cylinder 3',
|
|
||||||
'ERR02_bit3': 'P0304 — Misfire Cylinder 4',
|
// ERR02
|
||||||
'ERR02_bit4': 'P1259 — VTEC System Malfunction',
|
'ERR02_bit0': 'P0500 — Vehicle Speed Sensor (VSS, code 17)',
|
||||||
'ERR02_bit5': 'P0420 — Catalyst System Low Efficiency',
|
'ERR02_bit1': 'ERR02.1 — Undocumented code',
|
||||||
'ERR02_bit6': 'P0401 — EGR Insufficient Flow',
|
'ERR02_bit2': 'P0700 — Automatic Transmission Lockup Control Valve (code 19)',
|
||||||
'ERR02_bit7': 'P1456 — EVAP Leak (large) Fuel Tank Side',
|
'ERR02_bit3': 'P1298 — Electrical Load Detector (ELD, code 20)',
|
||||||
|
'ERR02_bit4': 'P2649 — VTEC Solenoid (VTS, code 21)',
|
||||||
// ERR03 (byte 0x34)
|
'ERR02_bit5': 'P2647 — VTEC Pressure Valve (VTP, code 22)',
|
||||||
'ERR03_bit0': 'P1457 — EVAP Leak (large) Canister Side',
|
'ERR02_bit6': 'P0325 — Knock Sensor (code 23)',
|
||||||
'ERR03_bit1': 'P0498 — EVAP Canister Vent Shut Valve',
|
'ERR02_bit7': 'P1324 — Knock Sensor (code 24)',
|
||||||
'ERR03_bit2': 'P0497 — EVAP Low Purge Flow',
|
|
||||||
'ERR03_bit3': 'P0122 — TPS Sensor Low',
|
// ERR03
|
||||||
'ERR03_bit4': 'P0123 — TPS Sensor High',
|
'ERR03_bit0': 'P0750 — Automatic Transmission A Signal (ATA, code 30)',
|
||||||
'ERR03_bit5': 'P0562 — Battery Voltage Low',
|
'ERR03_bit1': 'P0755 — Automatic Transmission B Signal (ATB, code 31)',
|
||||||
'ERR03_bit6': 'P0563 — Battery Voltage High',
|
'ERR03_bit2': 'P0135 — Primary Oxygen Sensor Heater (PO2H, code 41)',
|
||||||
'ERR03_bit7': 'P1607 — PCM Internal Circuit',
|
'ERR03_bit3': 'P0135 — Oxygen Sensor Heater',
|
||||||
};
|
'ERR03_bit4': 'P0169 — Fuel Supply System (code 43)',
|
||||||
|
'ERR03_bit5': 'ERR03.5 — Undocumented code',
|
||||||
/// KPro DTC map (ERR00–ERR17, 18 bytes × 8 bits = 144 possible codes)
|
'ERR03_bit6': 'ERR03.6 — Undocumented code',
|
||||||
const Map<String, String> kproDtcMap = {
|
'ERR03_bit7': 'ERR03.7 — Undocumented code',
|
||||||
// ERR00
|
};
|
||||||
'ERR00_bit0': 'P0130 — O2 Sensor (front)',
|
|
||||||
'ERR00_bit1': 'P0135 — O2 Sensor Heater (front)',
|
/// KPro DTC map (ERR00–ERR17 = frame bytes 62–79).
|
||||||
'ERR00_bit2': 'P0505 — Idle Air Control Valve',
|
const Map<String, String> kproDtcMap = {
|
||||||
'ERR00_bit3': 'P0108 — MAP Sensor High',
|
// ERR00
|
||||||
'ERR00_bit4': 'P0107 — MAP Sensor Low',
|
'ERR00_bit0': 'ERR00.0 — Unknown error 0',
|
||||||
'ERR00_bit5': 'P0113 — IAT Sensor High',
|
'ERR00_bit1': 'P1607 — Internal Circuit Malfunction (check calibration type)',
|
||||||
'ERR00_bit6': 'P0112 — IAT Sensor Low',
|
'ERR00_bit2': 'P0131 — O2 Sensor Voltage Low',
|
||||||
'ERR00_bit7': 'P0118 — ECT Sensor High',
|
'ERR00_bit3': 'P0132 — O2 Sensor Voltage High',
|
||||||
|
'ERR00_bit4': 'P0107 — MAP Sensor Low Voltage',
|
||||||
// ERR01
|
'ERR00_bit5': 'P0108 — MAP Sensor High Voltage',
|
||||||
'ERR01_bit0': 'P0117 — ECT Sensor Low',
|
'ERR00_bit6': 'P0335 — CRK Sensor No Signal',
|
||||||
'ERR01_bit1': 'P0336 — CKP Sensor',
|
'ERR00_bit7': 'P0336 — CRK Sensor Intermittent Interruption',
|
||||||
'ERR01_bit2': 'P0341 — CMP Sensor',
|
|
||||||
'ERR01_bit3': 'P1362 — TDC Sensor (No Signal)',
|
// ERR01
|
||||||
'ERR01_bit4': 'P1363 — TDC Sensor (No Signal 2)',
|
'ERR01_bit0': 'P1128 — MAP Sensor Signal Lower Than Expected',
|
||||||
'ERR01_bit5': 'P0335 — CKP Sensor (No Signal)',
|
'ERR01_bit1': 'P1129 — MAP Sensor Signal Higher Than Expected',
|
||||||
'ERR01_bit6': 'P1361 — TDC Sensor',
|
'ERR01_bit2': 'P0117 — ECT Sensor Low Voltage',
|
||||||
'ERR01_bit7': 'P0300 — Random Misfire',
|
'ERR01_bit3': 'P0118 — ECT Sensor High Voltage',
|
||||||
|
'ERR01_bit4': 'P0122 — TPS Low Voltage',
|
||||||
// ERR02
|
'ERR01_bit5': 'P0123 — TPS High Voltage',
|
||||||
'ERR02_bit0': 'P0301 — Misfire Cylinder 1',
|
'ERR01_bit6': 'P1121 — TPS Signal Lower Than Expected',
|
||||||
'ERR02_bit1': 'P0302 — Misfire Cylinder 2',
|
'ERR01_bit7': 'P1122 — TPS Signal Higher Than Expected',
|
||||||
'ERR02_bit2': 'P0303 — Misfire Cylinder 3',
|
|
||||||
'ERR02_bit3': 'P0304 — Misfire Cylinder 4',
|
// ERR02
|
||||||
'ERR02_bit4': 'P1259 — VTEC System Malfunction',
|
'ERR02_bit0': 'P0176 — Ethanol Sensor Input Error',
|
||||||
'ERR02_bit5': 'P0420 — Catalyst System Low Efficiency',
|
'ERR02_bit1': 'ERR02.1 — No description available',
|
||||||
'ERR02_bit6': 'P0401 — EGR Insufficient Flow',
|
'ERR02_bit2': 'ERR02.2 — No description available',
|
||||||
'ERR02_bit7': 'P1456 — EVAP Leak (large) Fuel Tank Side',
|
'ERR02_bit3': 'ERR02.3 — No description available',
|
||||||
|
'ERR02_bit4': 'P1362 — TDC Sensor No Signal',
|
||||||
// ERR03
|
'ERR02_bit5': 'P1361 — TDC Sensor Intermittent Signal Interruption',
|
||||||
'ERR03_bit0': 'P1457 — EVAP Leak (large) Canister Side',
|
'ERR02_bit6': 'P0112 — IAT Sensor Low Voltage',
|
||||||
'ERR03_bit1': 'P0498 — EVAP Canister Vent Shut Valve',
|
'ERR02_bit7': 'P0113 — IAT Sensor High Voltage',
|
||||||
'ERR03_bit2': 'P0497 — EVAP Low Purge Flow',
|
|
||||||
'ERR03_bit3': 'P0122 — TPS Sensor Low',
|
// ERR03
|
||||||
'ERR03_bit4': 'P0123 — TPS Sensor High',
|
'ERR03_bit0': 'P1213 — IMA Sensor Voltage Low',
|
||||||
'ERR03_bit5': 'P0562 — Battery Voltage Low',
|
'ERR03_bit1': 'P1214 — IMA Sensor Voltage High',
|
||||||
'ERR03_bit6': 'P0563 — Battery Voltage High',
|
'ERR03_bit2': 'ERR03.2 — EGR Lift Sensor Voltage Error',
|
||||||
'ERR03_bit7': 'P1607 — PCM Internal Circuit',
|
'ERR03_bit3': 'ERR03.3 — EGR F/B Error',
|
||||||
|
'ERR03_bit4': 'P1107 — Barometric Pressure Low Voltage',
|
||||||
// ERR04
|
'ERR03_bit5': 'P1108 — Barometric Pressure High Voltage',
|
||||||
'ERR04_bit0': 'P0136 — O2 Sensor (rear)',
|
'ERR03_bit6': 'P1106 — Barometric Pressure Sensor Range/Performance',
|
||||||
'ERR04_bit1': 'P0141 — O2 Sensor Heater (rear)',
|
'ERR03_bit7': 'ERR03.7 — Unknown error 37',
|
||||||
'ERR04_bit2': 'P1166 — Air Fuel Ratio (A/F) Sensor Heater',
|
|
||||||
'ERR04_bit3': 'P1167 — Air Fuel Ratio (A/F) Sensor',
|
// ERR04
|
||||||
'ERR04_bit4': 'P2195 — A/F Sensor Signal Stuck Lean',
|
'ERR04_bit0': 'P0505 — Idle Control System Malfunction',
|
||||||
'ERR04_bit5': 'P2196 — A/F Sensor Signal Stuck Rich',
|
'ERR04_bit1': 'P1519 — Idle Air Control Valve Circuit Malfunction',
|
||||||
'ERR04_bit6': 'P2237 — A/F Sensor Pump Cell Current Input Low',
|
'ERR04_bit2': 'P0500 — VSS Malfunction',
|
||||||
'ERR04_bit7': 'P2240 — A/F Sensor Pump Cell Current Input High',
|
'ERR04_bit3': 'ERR04.3 — VSP',
|
||||||
|
'ERR04_bit4': 'P1297 — ELD Circuit Low Voltage',
|
||||||
// ERR05
|
'ERR04_bit5': 'P1298 — ELD Circuit High Voltage',
|
||||||
'ERR05_bit0': 'P2243 — A/F Sensor Reference Voltage Low',
|
'ERR04_bit6': 'P1253 — VTEC Solenoid Drive Time Abnormality',
|
||||||
'ERR05_bit1': 'P2247 — A/F Sensor Reference Voltage High',
|
'ERR04_bit7': 'ERR04.7 — Unknown error 47',
|
||||||
'ERR05_bit2': 'P2251 — A/F Sensor VS Cell Voltage Low',
|
|
||||||
'ERR05_bit3': 'P2254 — A/F Sensor VS Cell Voltage High',
|
// ERR05
|
||||||
'ERR05_bit4': 'P0031 — O2 Sensor Heater Current Low (bank 1, sensor 1)',
|
'ERR05_bit0': 'P1259 — VTEC System Malfunction (Oil Pressure Switch)',
|
||||||
'ERR05_bit5': 'P0032 — O2 Sensor Heater Current High (bank 1, sensor 1)',
|
'ERR05_bit1': 'ERR05.1 — Unknown error 51',
|
||||||
'ERR05_bit6': 'P0037 — O2 Sensor Heater Current Low (bank 1, sensor 2)',
|
'ERR05_bit2': 'P0325 — Knock Sensor Circuit Malfunction',
|
||||||
'ERR05_bit7': 'P0038 — O2 Sensor Heater Current High (bank 1, sensor 2)',
|
'ERR05_bit3': 'P1324 — Knock Sensor Circuit Failure',
|
||||||
|
'ERR05_bit4': 'P0563 — VBU Voltage High',
|
||||||
// ERR06
|
'ERR05_bit5': 'ERR05.5 — Unknown error 55',
|
||||||
'ERR06_bit0': 'P0068 — MAP/MAF vs Throttle Position Correlation',
|
'ERR05_bit6': 'ERR05.6 — Unknown error 56',
|
||||||
'ERR06_bit1': 'P0101 — MAF Sensor Range/Performance',
|
'ERR05_bit7': 'ERR05.7 — Unknown error 57',
|
||||||
'ERR06_bit2': 'P0102 — MAF Sensor Low Input',
|
|
||||||
'ERR06_bit3': 'P0103 — MAF Sensor High Input',
|
// ERR06
|
||||||
'ERR06_bit4': 'P0171 — System Lean (bank 1)',
|
'ERR06_bit0': 'P1162 — Air Fuel Ratio Sensor Circuit Malfunction',
|
||||||
'ERR06_bit5': 'P0172 — System Rich (bank 1)',
|
'ERR06_bit1': 'ERR06.1 — LAF Sensor AFV1 Short',
|
||||||
'ERR06_bit6': 'P0174 — System Lean (bank 2)',
|
'ERR06_bit2': 'ERR06.2 — LAF Sensor AFC1 Short',
|
||||||
'ERR06_bit7': 'P0175 — System Rich (bank 2)',
|
'ERR06_bit3': 'ERR06.3 — Unknown error 63',
|
||||||
|
'ERR06_bit4':
|
||||||
// ERR07
|
'ERR06.4 — Daughter Board Communication Error (battery voltage low when cranking)',
|
||||||
'ERR07_bit0': 'P0325 — Knock Sensor',
|
'ERR06_bit5': 'ERR06.5 — Unknown error 65',
|
||||||
'ERR07_bit1': 'P0330 — Knock Sensor 2',
|
'ERR06_bit6':
|
||||||
'ERR07_bit2': 'P0339 — CKP Sensor Intermittent',
|
'P0600 — Multiplexer Not Present (for engine swaps disable in misc parameters)',
|
||||||
'ERR07_bit3': 'P0344 — CMP Sensor Intermittent',
|
'ERR06_bit7': 'ERR06.7 — Unknown error 67',
|
||||||
'ERR07_bit4': 'P0365 — CMP Sensor B Circuit',
|
|
||||||
'ERR07_bit5': 'P0390 — CMP Sensor B Circuit (bank 2)',
|
// ERR07
|
||||||
'ERR07_bit6': 'P0498 — EVAP Canister Vent Shut Valve Low',
|
'ERR07_bit0': 'P1505 — PCV Negative Pressure',
|
||||||
'ERR07_bit7': 'P0499 — EVAP Canister Vent Shut Valve High',
|
'ERR07_bit1': 'ERR07.1 — Unknown error 71',
|
||||||
|
'ERR07_bit2': 'ERR07.2 — Unknown error 72',
|
||||||
// ERR08
|
'ERR07_bit3': 'ERR07.3 — Idle Valve',
|
||||||
'ERR08_bit0': 'P0600 — Serial Communication Link',
|
'ERR07_bit4': 'P0135 — Primary HO2S Circuit Malfunction',
|
||||||
'ERR08_bit1': 'P0601 — Internal Control Module Memory',
|
'ERR07_bit5': 'P1166 — Primary HO2S Heater Circuit Malfunction',
|
||||||
'ERR08_bit2': 'P0604 — Internal Control Module RAM',
|
'ERR07_bit6': 'P1167 — Primary LAF HO2S Heater System Malfunction',
|
||||||
'ERR08_bit3': 'P0605 — Internal Control Module ROM',
|
'ERR07_bit7': 'P0134 — Air/Fuel Ratio Sensor No Activity Detected',
|
||||||
'ERR08_bit4': 'P0657 — Actuator Supply Voltage A Open',
|
|
||||||
'ERR08_bit5': 'P0685 — ECM/PCM Power Relay Control Open',
|
// ERR08
|
||||||
'ERR08_bit6': 'P0691 — Fan 1 Control Circuit Low',
|
'ERR08_bit0': 'P0172 — Fuel System Too Rich',
|
||||||
'ERR08_bit7': 'P0692 — Fan 1 Control Circuit High',
|
'ERR08_bit1': 'P0171 — Fuel System Too Lean',
|
||||||
|
'ERR08_bit2': 'P1162 — Air/Fuel Ratio (A/F) Sensor Slow Response',
|
||||||
// ERR09
|
'ERR08_bit3': 'ERR08.3 — Unknown error 83',
|
||||||
'ERR09_bit0': 'P0693 — Fan 2 Control Circuit Low',
|
'ERR08_bit4': 'P0010 — VTC Oil Control Solenoid Valve Malfunction',
|
||||||
'ERR09_bit1': 'P0694 — Fan 2 Control Circuit High',
|
'ERR08_bit5': 'P0011 — VTC System Malfunction',
|
||||||
'ERR09_bit2': 'P1454 — EVAP Pressure Sensor Range/Performance',
|
'ERR08_bit6': 'P0340 — CMP Sensor No Signal',
|
||||||
'ERR09_bit3': 'P1455 — EVAP Leak (small)',
|
'ERR08_bit7': 'P0344 — CMP Sensor Intermittent Interruption',
|
||||||
'ERR09_bit4': 'P1508 — IAC Valve Circuit Low',
|
|
||||||
'ERR09_bit5': 'P1509 — IAC Valve Circuit High',
|
// ERR09
|
||||||
'ERR09_bit6': 'P1519 — Idle Air Control Valve Stuck',
|
'ERR09_bit0': 'P0341 — Variable Valve Timing (VTC) Phase Gap',
|
||||||
'ERR09_bit7': 'P1706 — A/T Range Switch Out Of Range',
|
'ERR09_bit1': 'ERR09.1 — Unknown error 91',
|
||||||
|
'ERR09_bit2': 'ERR09.2 — Undocumented code',
|
||||||
// ERR10
|
'ERR09_bit3': 'P1163 — Air Fuel Ratio Sensor Slow Response',
|
||||||
'ERR10_bit0': 'P2101 — Throttle Actuator Control Motor Range',
|
'ERR09_bit4': 'P1164 — Air Fuel Ratio Sensor Range/Performance Problem',
|
||||||
'ERR10_bit1': 'P2118 — Throttle Actuator Control Motor Current Range',
|
'ERR09_bit5': 'ERR09.5 — Undocumented code',
|
||||||
'ERR10_bit2': 'P2119 — Throttle Actuator Control Throttle Body Range',
|
'ERR09_bit6': 'P0137 — Secondary O2 Circuit Low Voltage',
|
||||||
'ERR10_bit3': 'P2122 — Throttle/Pedal Position Sensor D Low Input',
|
'ERR09_bit7': 'P0138 — Secondary O2 Circuit High Voltage',
|
||||||
'ERR10_bit4': 'P2123 — Throttle/Pedal Position Sensor D High Input',
|
|
||||||
'ERR10_bit5': 'P2127 — Throttle/Pedal Position Sensor E Low Input',
|
// ERR10
|
||||||
'ERR10_bit6': 'P2128 — Throttle/Pedal Position Sensor E High Input',
|
'ERR10_bit0': 'P0139 — Secondary O2 Circuit Slow Response',
|
||||||
'ERR10_bit7': 'P2135 — Throttle/Pedal Position Sensor A/B Voltage Correlation',
|
'ERR10_bit1': 'ERR10.1 — Unknown error 101',
|
||||||
|
'ERR10_bit2': 'P0141 — Secondary O2 Circuit Heater Circuit Malfunction',
|
||||||
// ERR11
|
'ERR10_bit3': 'ERR10.3 — Unknown error 103',
|
||||||
'ERR11_bit0': 'P2138 — Throttle/Pedal Position Sensor D/E Voltage Correlation',
|
'ERR10_bit4': 'P0420 — Catalyst System Efficiency Below Threshold',
|
||||||
'ERR11_bit1': 'P2176 — Throttle Actuator Control System Idle Position Not Learned',
|
'ERR10_bit5': 'ERR10.5 — Catalyst / Secondary O2 Sensor',
|
||||||
'ERR11_bit2': 'P2227 — Barometric Pressure Sensor A Circuit Range',
|
'ERR10_bit6': 'ERR10.6 — Unknown error 106',
|
||||||
'ERR11_bit3': 'P2228 — Barometric Pressure Sensor A Circuit Low',
|
'ERR10_bit7': 'ERR10.7 — Unknown error 107',
|
||||||
'ERR11_bit4': 'P2229 — Barometric Pressure Sensor A Circuit High',
|
|
||||||
'ERR11_bit5': 'P2610 — ECM/PCM Internal Engine Off Timer',
|
// ERR11
|
||||||
'ERR11_bit6': 'P2647 — VTEC System High (bank 1)',
|
'ERR11_bit0': 'P0301 — Cylinder #1 Misfire',
|
||||||
'ERR11_bit7': 'P2648 — VTEC System Low (bank 1)',
|
'ERR11_bit1': 'ERR11.1 — Unknown error 111',
|
||||||
|
'ERR11_bit2': 'P0302 — Cylinder #2 Misfire',
|
||||||
// ERR12
|
'ERR11_bit3': 'ERR11.3 — Undocumented code',
|
||||||
'ERR12_bit0': 'P2649 — VTEC System High (bank 2)',
|
'ERR11_bit4': 'P0303 — Cylinder #3 Misfire',
|
||||||
'ERR12_bit1': 'P2650 — VTEC System Low (bank 2)',
|
'ERR11_bit5': 'ERR11.5 — Unknown error 115',
|
||||||
'ERR12_bit2': 'P3400 — VTEC System Stuck Off (bank 1)',
|
'ERR11_bit6': 'P0304 — Cylinder #4 Misfire',
|
||||||
'ERR12_bit3': 'P3497 — VTEC System Stuck Off (bank 2)',
|
'ERR11_bit7': 'ERR11.7 — Unknown error 117',
|
||||||
'ERR12_bit4': 'U0073 — Control Module Communication Bus Off',
|
|
||||||
'ERR12_bit5': 'U0100 — Lost Communication With ECM/PCM A',
|
// ERR12
|
||||||
'ERR12_bit6': 'U0155 — Lost Communication With Instrument Panel Cluster',
|
'ERR12_bit0': 'P0300 — Random Misfire',
|
||||||
'ERR12_bit7': 'U0164 — Lost Communication With HVAC Control Module',
|
'ERR12_bit1': 'ERR12.1 — Unknown error 121',
|
||||||
|
'ERR12_bit2': 'ERR12.2 — EGR Flow',
|
||||||
// ERR13
|
'ERR12_bit3': 'ERR12.3 — Unknown error 123',
|
||||||
'ERR13_bit0': 'P0011 — Camshaft Position A — Timing Over-Advanced (bank 1)',
|
'ERR12_bit4': 'P0116 — ECT Sensor Range/Performance Problem',
|
||||||
'ERR13_bit1': 'P0012 — Camshaft Position A — Timing Over-Retarded (bank 1)',
|
'ERR12_bit5': 'ERR12.5 — Unknown error 125',
|
||||||
'ERR13_bit2': 'P0021 — Camshaft Position B — Timing Over-Advanced (bank 1)',
|
'ERR12_bit6': 'P0128 — Cooling System Malfunction (Thermostat)',
|
||||||
'ERR13_bit3': 'P0022 — Camshaft Position B — Timing Over-Retarded (bank 1)',
|
'ERR12_bit7': 'ERR12.7 — VTC Solenoid',
|
||||||
'ERR13_bit4': 'P0340 — CMP Sensor A Circuit (bank 1)',
|
|
||||||
'ERR13_bit5': 'P0345 — CMP Sensor A Circuit (bank 2)',
|
// ERR13
|
||||||
'ERR13_bit6': 'P0366 — CMP Sensor B Circuit Range/Performance (bank 1)',
|
'ERR13_bit0': 'P1456 — EVAP Control System Leakage (Fuel Tank)',
|
||||||
'ERR13_bit7': 'P0391 — CMP Sensor B Circuit Range/Performance (bank 2)',
|
'ERR13_bit1': 'P1457 — EVAP Control System Leakage (Canister System)',
|
||||||
|
'ERR13_bit2': 'P0452 — Fuel Tank Pressure Sensor Low Voltage',
|
||||||
// ERR14
|
'ERR13_bit3': 'P0453 — Fuel Tank Pressure Sensor High Voltage',
|
||||||
'ERR14_bit0': 'P0016 — Crankshaft/Camshaft Position Correlation (bank 1, sensor A)',
|
'ERR13_bit4': 'P0451 — Fuel Tank Pressure Sensor Range/Performance',
|
||||||
'ERR14_bit1': 'P0017 — Crankshaft/Camshaft Position Correlation (bank 1, sensor B)',
|
'ERR13_bit5': 'ERR13.5 — Undocumented code',
|
||||||
'ERR14_bit2': 'P0018 — Crankshaft/Camshaft Position Correlation (bank 2, sensor A)',
|
'ERR13_bit6': 'P0443 — EVAP Purge Valve Circuit',
|
||||||
'ERR14_bit3': 'P0019 — Crankshaft/Camshaft Position Correlation (bank 2, sensor B)',
|
'ERR13_bit7': 'ERR13.7 — Unknown error 137',
|
||||||
'ERR14_bit4': 'P0008 — Engine Position System (bank 1)',
|
|
||||||
'ERR14_bit5': 'P0009 — Engine Position System (bank 2)',
|
// ERR14
|
||||||
'ERR14_bit6': 'P0014 — Camshaft Position B — Timing Over-Advanced (bank 1)',
|
'ERR14_bit0': 'ERR14.0 — PF2 Low',
|
||||||
'ERR14_bit7': 'P0024 — Camshaft Position B — Timing Over-Advanced (bank 2)',
|
'ERR14_bit1': 'ERR14.1 — PF2 High',
|
||||||
|
'ERR14_bit2': 'ERR14.2 — PF2 Monitor',
|
||||||
// ERR15
|
'ERR14_bit3': 'ERR14.3 — Undocumented code',
|
||||||
'ERR15_bit0': 'P0087 — Fuel Rail/System Pressure Too Low',
|
'ERR14_bit4': 'ERR14.4 — TF2 Low',
|
||||||
'ERR15_bit1': 'P0088 — Fuel Rail/System Pressure Too High',
|
'ERR14_bit5': 'ERR14.5 — TF2 High',
|
||||||
'ERR15_bit2': 'P0089 — Fuel Pressure Regulator Performance',
|
'ERR14_bit6': 'ERR14.6 — PF0 Low',
|
||||||
'ERR15_bit3': 'P0090 — Fuel Pressure Regulator Control Circuit',
|
'ERR14_bit7': 'ERR14.7 — PF0 High',
|
||||||
'ERR15_bit4': 'P0091 — Fuel Pressure Regulator Control Circuit Low',
|
|
||||||
'ERR15_bit5': 'P0092 — Fuel Pressure Regulator Control Circuit High',
|
// ERR15
|
||||||
'ERR15_bit6': 'P0201 — Injector Circuit/Open Cylinder 1',
|
'ERR15_bit0': 'ERR15.0 — TF0 Low',
|
||||||
'ERR15_bit7': 'P0202 — Injector Circuit/Open Cylinder 2',
|
'ERR15_bit1': 'ERR15.1 — TF0 High',
|
||||||
|
'ERR15_bit2': 'ERR15.2 — 3rd O2 Sensor Voltage Low',
|
||||||
// ERR16
|
'ERR15_bit3': 'ERR15.3 — 3rd O2 Sensor Voltage High',
|
||||||
'ERR16_bit0': 'P0203 — Injector Circuit/Open Cylinder 3',
|
'ERR15_bit4': 'ERR15.4 — 3rd O2 Sensor Response',
|
||||||
'ERR16_bit1': 'P0204 — Injector Circuit/Open Cylinder 4',
|
'ERR15_bit5': 'ERR15.5 — Unknown error 155',
|
||||||
'ERR16_bit2': 'P0261 — Injector Circuit Low Cylinder 1',
|
'ERR15_bit6': 'ERR15.6 — 3rd O2 Sensor Heater Current Abnormality',
|
||||||
'ERR16_bit3': 'P0264 — Injector Circuit Low Cylinder 2',
|
'ERR15_bit7': 'ERR15.7 — Unknown error 157',
|
||||||
'ERR16_bit4': 'P0267 — Injector Circuit Low Cylinder 3',
|
|
||||||
'ERR16_bit5': 'P0270 — Injector Circuit Low Cylinder 4',
|
// ERR16
|
||||||
'ERR16_bit6': 'P0262 — Injector Circuit High Cylinder 1',
|
'ERR16_bit0': 'ERR16.0 — NOx Ability Monitor',
|
||||||
'ERR16_bit7': 'P0265 — Injector Circuit High Cylinder 2',
|
'ERR16_bit1': 'ERR16.1 — Unknown error 161',
|
||||||
|
'ERR16_bit2':
|
||||||
// ERR17
|
'P1077 — Intake Manifold Runner Control System Malfunction (low rpm)',
|
||||||
'ERR17_bit0': 'P0268 — Injector Circuit High Cylinder 3',
|
'ERR16_bit3':
|
||||||
'ERR17_bit1': 'P0271 — Injector Circuit High Cylinder 4',
|
'P1078 — Intake Manifold Runner Control System Malfunction (high rpm)',
|
||||||
'ERR17_bit2': 'P0351 — Ignition Coil A Primary/Secondary Circuit',
|
'ERR16_bit4': 'P0661 — Intake Manifold Runner Control Low Voltage',
|
||||||
'ERR17_bit3': 'P0352 — Ignition Coil B Primary/Secondary Circuit',
|
'ERR16_bit5': 'P0662 — Intake Manifold Runner Control High Voltage',
|
||||||
'ERR17_bit4': 'P0353 — Ignition Coil C Primary/Secondary Circuit',
|
'ERR16_bit6': 'ERR16.6 — P1 Switch Abnormality',
|
||||||
'ERR17_bit5': 'P0354 — Ignition Coil D Primary/Secondary Circuit',
|
'ERR16_bit7': 'ERR16.7 — Unknown error 167',
|
||||||
'ERR17_bit6': 'P0627 — Fuel Pump Control Circuit Open',
|
|
||||||
'ERR17_bit7': 'P0628 — Fuel Pump Control Circuit Low',
|
// ERR17
|
||||||
};
|
'ERR17_bit0': 'P0219 — Engine Over-Rev',
|
||||||
|
'ERR17_bit1': 'P0217 — Engine Over-Heating',
|
||||||
/// Returns a list of active DTC descriptions from the given raw error bytes
|
'ERR17_bit2': 'ERR17.2 — Undocumented code',
|
||||||
/// using the provided map.
|
'ERR17_bit3': 'ERR17.3 — Undocumented code',
|
||||||
List<String> decodeDtcs(List<int> errBytes, Map<String, String> dtcMap) {
|
'ERR17_bit4': 'ERR17.4 — Undocumented code',
|
||||||
final List<String> active = [];
|
'ERR17_bit5': 'ERR17.5 — Undocumented code',
|
||||||
for (int byteIdx = 0; byteIdx < errBytes.length; byteIdx++) {
|
'ERR17_bit6': 'ERR17.6 — Undocumented code',
|
||||||
final int b = errBytes[byteIdx];
|
'ERR17_bit7': 'ERR17.7 — Undocumented code',
|
||||||
if (b == 0) continue;
|
};
|
||||||
for (int bit = 0; bit < 8; bit++) {
|
|
||||||
if ((b >> bit) & 1 == 1) {
|
/// True if any of [bits] — (ERR byte index, bit number) pairs — is set.
|
||||||
final String key = 'ERR${byteIdx.toString().padLeft(2, '0')}_bit$bit';
|
/// Used to derive sensor-error flags (IAT/CLT/O2) from the stored codes.
|
||||||
final String? desc = dtcMap[key];
|
bool anyDtcBit(List<int> errBytes, List<(int, int)> bits) {
|
||||||
if (desc != null) active.add(desc);
|
for (final (byte, bit) in bits) {
|
||||||
}
|
if (byte < errBytes.length && (errBytes[byte] >> bit) & 1 == 1) {
|
||||||
}
|
return true;
|
||||||
}
|
}
|
||||||
return active;
|
}
|
||||||
}
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@ -1,147 +1,163 @@
|
|||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
import 'sensor_state.dart';
|
import 'sensor_state.dart';
|
||||||
import 'temp_table.dart';
|
import 'temp_table.dart';
|
||||||
import 'neg8.dart';
|
import 'neg8.dart';
|
||||||
|
import 'dtc_map.dart';
|
||||||
/// Parses a 128-byte KPro ECU response frame into a [SensorState].
|
import 's300_parser.dart' show tpsPercent;
|
||||||
/// Throws [ArgumentError] if frame length is wrong or NEG8 checksum fails.
|
|
||||||
SensorState parseKPro(Uint8List frame) {
|
/// Parses a 128-byte KPro ECU response frame into a [SensorState].
|
||||||
if (frame.length != 128) {
|
/// Throws [ArgumentError] if frame length is wrong or NEG8 checksum fails.
|
||||||
throw ArgumentError('KPro frame must be 128 bytes, got ${frame.length}');
|
SensorState parseKPro(Uint8List frame) {
|
||||||
}
|
if (frame.length != 128) {
|
||||||
if (!validateFrame(frame)) {
|
throw ArgumentError('KPro frame must be 128 bytes, got ${frame.length}');
|
||||||
throw ArgumentError('KPro frame NEG8 checksum failed');
|
}
|
||||||
}
|
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;
|
// 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
|
||||||
// VSS: byte 4 direct km/h
|
// turned out to be wrong, see below.
|
||||||
final double vss = frame[4].toDouble();
|
|
||||||
|
// RPM: bytes 2..3 little-endian / 4. KMGR: 7267 rpm.
|
||||||
// TPS: byte 5 direct %
|
final int rpmRaw = _readUint16LE(frame, 2);
|
||||||
final double tps = frame[5].toDouble();
|
final double rpm = rpmRaw / 4.0;
|
||||||
|
|
||||||
// MAP: bytes 6 (MAP1) and 33..34 (MAP2 L+H)
|
// VSS: byte 4 direct km/h
|
||||||
final int map1Raw = frame[6];
|
final double vss = frame[4].toDouble();
|
||||||
final int map2Raw = _readUint16BE(frame, 33);
|
|
||||||
final double map = (map2Raw != 0) ? map2Raw / 10.0 : map1Raw.toDouble();
|
// TPS: byte 5, (raw - 25) / 2.04. KMGR: raw 163 -> 68 %.
|
||||||
|
final double tps = tpsPercent(frame[5]);
|
||||||
// INJ: byte 11 raw
|
|
||||||
final double inj = frame[11].toDouble();
|
// MAP: bytes 6 (MAP1) and 33..34 (MAP2 L+H)
|
||||||
|
final int map1Raw = frame[6];
|
||||||
// IGN: byte 13 raw degrees
|
final int map2Raw = _readUint16LE(frame, 33); // MAP2L @33, MAP2H @34
|
||||||
final double ign = frame[13].toDouble();
|
final double map = (map2Raw != 0) ? map2Raw / 10.0 : map1Raw.toDouble();
|
||||||
|
|
||||||
// O2: byte 14 raw
|
// INJ: bytes 11..12 little-endian, µs -> ms. KMGR: 00 00 -> 0.00 ms (in
|
||||||
final double o2 = frame[14].toDouble();
|
// fuel cut). Byte order follows the L/H pairs; scale UNVERIFIED.
|
||||||
|
final double inj = _readUint16LE(frame, 11) / 1000.0;
|
||||||
// KRtrd: byte 21
|
|
||||||
final int krtrd = frame[21];
|
// IGN: byte 13, (raw - 128) / 2 degrees. KMGR: raw 138 -> 5°.
|
||||||
|
final double ign = (frame[13] - 128) / 2.0;
|
||||||
// SW bitmaps
|
|
||||||
final int sw1 = frame[31];
|
// O2: byte 14 raw
|
||||||
final int sw2 = frame[32];
|
// O2: volts, raw × 5/256 as on the S300. UNVERIFIED for KPro.
|
||||||
|
final double o2 = frame[14] * 5.0 / 256.0;
|
||||||
// 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
|
// KRtrd: byte 21
|
||||||
// reachable with subtraction).
|
final int krtrd = frame[21];
|
||||||
final double ect = (tempXlt[frame[49]] - 40).toDouble();
|
|
||||||
|
// SW bitmaps
|
||||||
// IAT: byte 50
|
final int sw1 = frame[31];
|
||||||
final double iat = (tempXlt[frame[50]] - 40).toDouble();
|
final int sw2 = frame[32];
|
||||||
|
|
||||||
// PA: byte 51
|
// ECT: byte 49. Table is tempXlt[raw] - 40, not +40 — see s300_parser.dart
|
||||||
final double pa = frame[51].toDouble();
|
// for the reasoning (the declared -40..150 degC sensor range is only
|
||||||
|
// reachable with subtraction).
|
||||||
// BAT: byte 52 — raw / 10
|
final double ect = (tempXlt[frame[49]] - 40).toDouble();
|
||||||
final double bat = frame[52] / 10.0;
|
|
||||||
|
// IAT: byte 50
|
||||||
// STRIM: byte 32 (SW2 shares offset 32, STRIM is byte 20 per spec offset +20)
|
final double iat = (tempXlt[frame[50]] - 40).toDouble();
|
||||||
final double strim = frame[20].toDouble();
|
|
||||||
|
// PA: byte 53, kPa. KMGR: raw 95 -> 95.0 kPa. (The PDF puts PA at 51.)
|
||||||
// LTRIM — not explicitly listed in KPro; use 0
|
final double pa = frame[53].toDouble();
|
||||||
const double ltrim = 0.0;
|
|
||||||
|
// BAT: byte 51, raw / 10. KMGR: raw 114 -> 11.40 V. (The PDF puts BAT at
|
||||||
// ETH — not in KPro spec; use 0
|
// 52; that byte read 232 -> "23.2 V".)
|
||||||
const double eth = 0.0;
|
final double bat = frame[51] / 10.0;
|
||||||
|
|
||||||
// AFR: byte 25 (AFCMD at offset +19, then raw)
|
// STRIM: byte 20, (raw - 128) / 1.28 %. KMGR: raw 128 -> 0 %.
|
||||||
final double afr = frame[25].toDouble();
|
final double strim = (frame[20] - 128) / 1.28;
|
||||||
|
|
||||||
// ERR bytes: 18 bytes starting at offset 0x3E = 62
|
// LTRIM — not explicitly listed in KPro; use 0
|
||||||
final List<int> errBytes = List<int>.generate(18, (i) => frame[62 + i]);
|
const double ltrim = 0.0;
|
||||||
|
|
||||||
// AIN0–AIN7: bytes 0x52..0x61 (offset 82..97, uint16 BE each)
|
// ETH — not in KPro spec; use 0
|
||||||
final double ain0 = _readUint16BE(frame, 82).toDouble();
|
const double eth = 0.0;
|
||||||
final double ain1 = _readUint16BE(frame, 84).toDouble();
|
|
||||||
final double ain2 = _readUint16BE(frame, 86).toDouble();
|
// AFR: byte 25 (AFCMD at offset +19, then raw)
|
||||||
final double ain3 = _readUint16BE(frame, 88).toDouble();
|
final double afr = frame[25].toDouble();
|
||||||
final double ain4 = _readUint16BE(frame, 90).toDouble();
|
|
||||||
final double ain5 = _readUint16BE(frame, 92).toDouble();
|
// ERR bytes: 18 bytes starting at offset 0x3E = 62
|
||||||
final double ain6 = _readUint16BE(frame, 94).toDouble();
|
final List<int> errBytes = List<int>.generate(18, (i) => frame[62 + i]);
|
||||||
final double ain7 = _readUint16BE(frame, 96).toDouble();
|
|
||||||
|
// AIN0–AIN7: bytes 0x52..0x61 (offset 82..97, uint16 LE each)
|
||||||
// Gear — not in KPro frame, default 0
|
final double ain0 = _readUint16LE(frame, 82).toDouble();
|
||||||
const int gear = 0;
|
final double ain1 = _readUint16LE(frame, 84).toDouble();
|
||||||
|
final double ain2 = _readUint16LE(frame, 86).toDouble();
|
||||||
// Flags
|
final double ain3 = _readUint16LE(frame, 88).toDouble();
|
||||||
// SW2 bit2 = MIL
|
final double ain4 = _readUint16LE(frame, 90).toDouble();
|
||||||
final bool mil = (sw2 & 0x04) != 0;
|
final double ain5 = _readUint16LE(frame, 92).toDouble();
|
||||||
// SW1 bit6 = FLR. Unverified: never live-tested for KPro, and the
|
final double ain6 = _readUint16LE(frame, 94).toDouble();
|
||||||
// equivalent S300 bit turned out to be fuel PUMP relay status, not fuel
|
final double ain7 = _readUint16LE(frame, 96).toDouble();
|
||||||
// cut, despite an initially plausible-looking name — treat "FLR" the
|
|
||||||
// same way until confirmed.
|
// Gear — not in KPro frame, default 0
|
||||||
final bool fuelPump = (sw1 & 0x40) != 0;
|
const int gear = 0;
|
||||||
// SW1 bit0 = FANC
|
|
||||||
final bool fanOut = (sw1 & 0x01) != 0;
|
// Flags. The PDF lists the bits LOWEST first. KMGR: SW1 = 0x50 with SCS
|
||||||
// SW2 bit1..0 = VTS (VTEC)
|
// and FLR on -> SCS bit4, FLR bit6.
|
||||||
final bool vtec = (sw2 & 0x03) != 0;
|
// SW1: RVSLCK(0) BKSW(1) ACSW(2) ACCL(3) SCS(4) EPS(5) FLR(6) FANC(7)
|
||||||
// Knock = KRtrd > 0
|
final bool brakeSwitch = (sw1 & 0x02) != 0;
|
||||||
final bool knock = krtrd > 0;
|
final bool acSwitch = (sw1 & 0x04) != 0;
|
||||||
// Rev limit — not directly mapped in KPro SW; false
|
final bool fuelPump = (sw1 & 0x40) != 0; // FLR = fuel pump relay
|
||||||
const bool revLimit = false;
|
final bool fanOut = (sw1 & 0x80) != 0; // FANC
|
||||||
// Launch — not in KPro SW; false
|
// SW2: VTP(0) VTS(1) … MIL(3). KMGR: SW2 = 0x01/0x09 with VTP on, VTS
|
||||||
const bool launch = false;
|
// off, MIL on -> MIL is bit3 (the PDF list has a gap). MIL UNVERIFIED
|
||||||
|
// beyond this one capture.
|
||||||
return SensorState(
|
final bool vtp = (sw2 & 0x01) != 0;
|
||||||
rpm: rpm,
|
final bool vtec = (sw2 & 0x02) != 0; // VTS = VTEC solenoid
|
||||||
vss: vss,
|
final bool mil = (sw2 & 0x08) != 0;
|
||||||
map: map,
|
// Knock = KRtrd > 0
|
||||||
tps: tps,
|
final bool knock = krtrd > 0;
|
||||||
inj: inj,
|
// Rev limit — not directly mapped in KPro SW; false
|
||||||
ign: ign,
|
const bool revLimit = false;
|
||||||
ect: ect,
|
// Launch — not in KPro SW; false
|
||||||
iat: iat,
|
const bool launch = false;
|
||||||
bat: bat,
|
|
||||||
o2: o2,
|
return SensorState(
|
||||||
gear: gear,
|
rpm: rpm,
|
||||||
eth: eth,
|
vss: vss,
|
||||||
pa: pa,
|
map: map,
|
||||||
afr: afr,
|
tps: tps,
|
||||||
strim: strim,
|
inj: inj,
|
||||||
ltrim: ltrim,
|
ign: ign,
|
||||||
ain0: ain0,
|
ect: ect,
|
||||||
ain1: ain1,
|
iat: iat,
|
||||||
ain2: ain2,
|
bat: bat,
|
||||||
ain3: ain3,
|
o2: o2,
|
||||||
ain4: ain4,
|
gear: gear,
|
||||||
ain5: ain5,
|
eth: eth,
|
||||||
ain6: ain6,
|
pa: pa,
|
||||||
ain7: ain7,
|
afr: afr,
|
||||||
mil: mil,
|
strim: strim,
|
||||||
fuelPump: fuelPump,
|
ltrim: ltrim,
|
||||||
fanOut: fanOut,
|
ain0: ain0,
|
||||||
vtec: vtec,
|
ain1: ain1,
|
||||||
knock: knock,
|
ain2: ain2,
|
||||||
revLimit: revLimit,
|
ain3: ain3,
|
||||||
launch: launch,
|
ain4: ain4,
|
||||||
errBytes: errBytes,
|
ain5: ain5,
|
||||||
timestamp: DateTime.now(),
|
ain6: ain6,
|
||||||
);
|
ain7: ain7,
|
||||||
}
|
mil: mil,
|
||||||
|
fuelPump: fuelPump,
|
||||||
int _readUint16BE(Uint8List frame, int offset) {
|
fanOut: fanOut,
|
||||||
return (frame[offset] << 8) | frame[offset + 1];
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@ -1,22 +1,22 @@
|
|||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
/// Calculates the NEG8 checksum for a buffer.
|
/// Calculates the NEG8 checksum for a buffer.
|
||||||
/// NEG8: start at 0, subtract each byte, keep lower 8 bits.
|
/// NEG8: start at 0, subtract each byte, keep lower 8 bits.
|
||||||
/// The result should match the last byte of a valid frame.
|
/// The result should match the last byte of a valid frame.
|
||||||
int calculateNeg8(Uint8List buf) {
|
int calculateNeg8(Uint8List buf) {
|
||||||
int neg8 = 0;
|
int neg8 = 0;
|
||||||
for (int i = 0; i < buf.length; i++) {
|
for (int i = 0; i < buf.length; i++) {
|
||||||
neg8 = (neg8 - buf[i]) & 0xFF;
|
neg8 = (neg8 - buf[i]) & 0xFF;
|
||||||
}
|
}
|
||||||
return neg8;
|
return neg8;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns true if the frame's last byte equals the NEG8 of all preceding bytes.
|
/// Returns true if the frame's last byte equals the NEG8 of all preceding bytes.
|
||||||
bool validateFrame(Uint8List frame) {
|
bool validateFrame(Uint8List frame) {
|
||||||
if (frame.isEmpty) return false;
|
if (frame.isEmpty) return false;
|
||||||
int neg8 = 0;
|
int neg8 = 0;
|
||||||
for (int i = 0; i < frame.length - 1; i++) {
|
for (int i = 0; i < frame.length - 1; i++) {
|
||||||
neg8 = (neg8 - frame[i]) & 0xFF;
|
neg8 = (neg8 - frame[i]) & 0xFF;
|
||||||
}
|
}
|
||||||
return neg8 == frame[frame.length - 1];
|
return neg8 == frame[frame.length - 1];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,237 +1,213 @@
|
|||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
import 'sensor_state.dart';
|
import 'sensor_state.dart';
|
||||||
import 'temp_table.dart';
|
import 'temp_table.dart';
|
||||||
import 'neg8.dart';
|
import 'neg8.dart';
|
||||||
|
import 'dtc_map.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
|
// S300 datalog frame (128 bytes, response to request 1B 00 E5).
|
||||||
/// physical signal.
|
//
|
||||||
///
|
// BYTE OFFSETS: hvbt_protocol.pdf labels its columns inconsistently
|
||||||
/// The frozen region is now known to extend well past byte 16. Bytes 32-36
|
// ("+26 +27 +28 +29 +2A … +2F +30 +31"). The labels are NOT hex offsets.
|
||||||
/// (gear, ECT, IAT, BAT at their current offsets) show the exact same fixed
|
// The real offsets are simply the column count, 0..127. This is proven by
|
||||||
/// byte sequence (`93 a0 a0 dc 7d`) across dozens of unrelated live captures
|
// the fields already confirmed against live USB captures — Gear=27, ECT=33,
|
||||||
/// throughout this project. SW2's flags (fuelPump, mil, vtec, fanOut's ALTC
|
// IAT=34, BAT=36 — which all match plain column counting, and by the adapter's
|
||||||
/// bit) were also seen going wrong during an idle period where nothing
|
// default frame (below) reproducing the PDF example row byte-for-byte.
|
||||||
/// 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
|
// 0 ID1(1B) 1 ID2(00) 2 DLrate 3-4 RPM 5-6 VSS 7-8 MAP 9 TPS 10-11 INJ
|
||||||
/// fuelPump correlating with real changes) only holds while the frame
|
// 12 IGN 13 KRtrd 14-15 Klvl 16 O2 17 SW1 18 SW2 19 SW3 20 SW4 21-26 ?
|
||||||
/// *isn't* templated; during a template frame it goes stale like everything
|
// 27 Gear 28 ? 29 Strim 30 ? 31 Ltrim 32 PA 33 ECT 34 IAT 35 ? 36 BAT
|
||||||
/// else tested so far. SW1 itself was never actually confirmed for template
|
// 37 ERR00 38 ERR01 39 ERR02 40 ERR03 41 Eth 42-44 ? 45 IATc 46 ECTc 47-48 ?
|
||||||
/// frames specifically — the "matches USB exactly" validation that seemed
|
// 49 SW5 50 SW6 51-54 ? 55 AF 56 ? 57 TCrtrd 58 TClim 59 ? 60-81 reserved
|
||||||
/// to support "bytes 17+ are live" was checked against a non-template frame.
|
// 82-97 AIN0..AIN7 (16-bit) 98-126 reserved 127 NEG8
|
||||||
/// 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
|
// BYTE ORDER: multi-byte values are LITTLE-endian on the wire (confirmed live
|
||||||
/// that the whole frame is templated, with only a shrinking set of
|
// for RPM/VSS/MAP; the PDF's "Big Endian" note only applies to the request).
|
||||||
/// 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).
|
/// 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,
|
||||||
/// Callers should hold the last known-good values for every field sourced
|
/// MAP 0x03F2, TPS 0x18, IGN 0x10, O2 0xC4 …; bytes 32-36 = 93 A0 A0 DC 7D).
|
||||||
/// from the frozen span (bytes 0-16, gear/ECT/IAT/BAT, and the SW2 flags)
|
/// The adapter sends it when it has no fresh data from the ECU (typically
|
||||||
/// and decode only what's individually confirmed live.
|
/// because it was asked again before it could finish reading the ECU).
|
||||||
bool isS300TemplateFrame(Uint8List frame) {
|
/// Such a frame carries NO real readings and must not be displayed or logged.
|
||||||
if (frame.length != 128) return false;
|
///
|
||||||
|
/// Both spans must match: bytes 0-16 alone could, in principle, coincide with
|
||||||
return frame[0] == 0x1B &&
|
/// a real key-on/engine-off reading (RPM 0, MAP 101.0, closed throttle), and a
|
||||||
frame[1] == 0x00 &&
|
/// false positive here hides real data and triggers auto-reconnects.
|
||||||
frame[2] == 0x14 &&
|
bool isS300DefaultFrame(Uint8List frame) {
|
||||||
frame[3] == 0x00 &&
|
if (frame.length != 128) return false;
|
||||||
frame[4] == 0x00 &&
|
const head = <int>[
|
||||||
frame[5] == 0xFF &&
|
0x1B, 0x00, 0x14, 0x00, 0x00, 0xFF, 0xFF, 0xF2, 0x03, //
|
||||||
frame[6] == 0xFF &&
|
0x18, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, 0xC4,
|
||||||
frame[7] == 0xF2 &&
|
];
|
||||||
frame[8] == 0x03 &&
|
for (var i = 0; i < head.length; i++) {
|
||||||
frame[9] == 0x18 &&
|
if (frame[i] != head[i]) return false;
|
||||||
frame[10] == 0x00 &&
|
}
|
||||||
frame[11] == 0x00 &&
|
// PA, ECT, IAT, ?, BAT at 32-36.
|
||||||
frame[12] == 0x10 &&
|
const tail = <int>[0x93, 0xA0, 0xA0, 0xDC, 0x7D];
|
||||||
frame[13] == 0x00 &&
|
for (var i = 0; i < tail.length; i++) {
|
||||||
frame[14] == 0x00 &&
|
if (frame[32 + i] != tail[i]) return false;
|
||||||
frame[15] == 0x00 &&
|
}
|
||||||
frame[16] == 0xC4;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parses a 128-byte S300 ECU response frame into a [SensorState].
|
/// Former name, kept so older call sites/tests keep compiling.
|
||||||
/// Throws [ArgumentError] if frame length is wrong, or if [validateChecksum]
|
bool isS300TemplateFrame(Uint8List frame) => isS300DefaultFrame(frame);
|
||||||
/// is true and the NEG8 checksum fails.
|
|
||||||
SensorState parseS300(Uint8List frame, {bool validateChecksum = true}) {
|
/// Parses a 128-byte S300 ECU response frame into a [SensorState].
|
||||||
if (frame.length != 128) {
|
/// Throws [ArgumentError] if frame length is wrong, or if [validateChecksum]
|
||||||
throw ArgumentError('S300 frame must be 128 bytes, got ${frame.length}');
|
/// is true and the NEG8 checksum fails.
|
||||||
}
|
SensorState parseS300(Uint8List frame, {bool validateChecksum = true}) {
|
||||||
if (validateChecksum && !validateFrame(frame)) {
|
if (frame.length != 128) {
|
||||||
throw ArgumentError('S300 frame NEG8 checksum failed');
|
throw ArgumentError('S300 frame must be 128 bytes, got ${frame.length}');
|
||||||
}
|
}
|
||||||
|
if (validateChecksum && !validateFrame(frame)) {
|
||||||
// RPM: bytes +3..4, little-endian, use directly. Confirmed against a live
|
throw ArgumentError('S300 frame NEG8 checksum failed');
|
||||||
// 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.
|
// ── Confirmed against live captures ────────────────────────────────────
|
||||||
final double rpm = _readUint16LE(frame, 3).toDouble();
|
final double rpm = _u16le(frame, 3).toDouble();
|
||||||
|
|
||||||
// VSS: bytes +5..6, little-endian (confirmed against a live capture).
|
final int vssRaw = _u16le(frame, 5);
|
||||||
final int vssRaw = _readUint16LE(frame, 5);
|
final double vss =
|
||||||
final double vss =
|
(vssRaw < 893 || vssRaw == 0xFFFF) ? 0.0 : 228480.0 / vssRaw;
|
||||||
(vssRaw < 893 || vssRaw == 0xFFFF) ? 0.0 : 228480.0 / vssRaw;
|
|
||||||
|
final double map = _u16le(frame, 7) / 10.0;
|
||||||
// MAP: bytes +7..8, little-endian (confirmed against a live capture — the
|
|
||||||
// big-endian read produced multi-thousand-kPa values, physically
|
final double tps = tpsPercent(frame[9]);
|
||||||
// impossible for a 0-300 kPa sensor).
|
|
||||||
final int mapRaw = _readUint16LE(frame, 7);
|
final int gear = frame[27];
|
||||||
final double map = mapRaw / 10.0;
|
|
||||||
|
// Table value minus 40 (live-confirmed; also the default frame's 0xA0
|
||||||
// TPS: byte +9. Linear 0-255 -> 0-100%, confirmed against a live capture
|
// decodes to a sensible 20 °C this way).
|
||||||
// (raw 127 matched a reference tool's ~50% reading; the spec's
|
final double ect = (tempXlt[frame[33]] - 40).toDouble();
|
||||||
// "raw*51/46" formula produced impossible >100% values on the same byte).
|
final double iat = (tempXlt[frame[34]] - 40).toDouble();
|
||||||
final int tpsRaw = frame[9];
|
|
||||||
final double tps = (tpsRaw * 100.0 / 255.0).clamp(0.0, 100.0);
|
final double bat = frame[36] * 26.0 / 270.0;
|
||||||
|
|
||||||
// INJ: bytes +10..11, BE, raw ms.
|
// ── Offsets corrected 2026-10-12 (column counting, see header) ─────────
|
||||||
final double inj = _readUint16BE(frame, 10).toDouble();
|
// Formulas marked UNVERIFIED still need one bench check against the USB
|
||||||
|
// tool; they are sanity-checked against the default frame only.
|
||||||
// IGN: byte +12.
|
|
||||||
final double ign = (frame[12] + 120) / 2.0;
|
// INJ: 16-bit LE (was read big-endian). UNVERIFIED scale: assumes µs.
|
||||||
|
final double inj = _u16le(frame, 10) / 1000.0;
|
||||||
// O2: byte +16, raw.
|
|
||||||
final double o2 = frame[16].toDouble();
|
// IGN: formula from the PDF. UNVERIFIED (one live check disagreed).
|
||||||
|
final double ign = (frame[12] + 120) / 2.0;
|
||||||
// SW bitmaps
|
|
||||||
final int sw1 = frame[0x11]; // +17
|
// O2: volts, raw × 5/256 (USB tool: raw 195 → 3.81 V).
|
||||||
final int sw2 = frame[0x12]; // +18
|
final double o2 = frame[16] * 5.0 / 256.0;
|
||||||
final int sw3 = frame[0x13]; // +19
|
|
||||||
final int sw5 = frame[0x43]; // +43
|
final int sw1 = frame[17];
|
||||||
|
final int sw2 = frame[18];
|
||||||
// Gear: decimal offset 27 (confirmed against a live capture — hex 0x27/39
|
final int sw3 = frame[19];
|
||||||
// read 0x00 there; decimal 27 matched a reference tool's gear exactly).
|
final int sw5 = frame[49]; // was read from byte 67 (reserved area)
|
||||||
final int gear = frame[27];
|
|
||||||
|
// Fuel trims: 0x80 = 0 %. Default frame has 0x80 in both → 0 %.
|
||||||
// Strim: +29 hex = 41 decimal
|
final double strim = (frame[29] - 128) * 100.0 / 128.0; // was byte 41
|
||||||
final double strim = frame[0x29].toDouble();
|
final double ltrim = (frame[31] - 128) * 100.0 / 128.0; // was byte 43
|
||||||
|
|
||||||
// Ltrim: +2B hex = 43 decimal
|
// Baro: byte 32 (was 44). Honda OBD1 baro ADC formula; default frame's
|
||||||
final double ltrim = frame[0x2B].toDouble();
|
// 0x93 → 100.3 kPa (sea level), which supports it. UNVERIFIED on bench.
|
||||||
|
final double pa = frame[32] * 0.716 - 5.0;
|
||||||
// PA: +2C hex = 44 decimal. Unconfirmed — a live capture's closest
|
|
||||||
// candidate (byte 50, decimal) was off by ~3 kPa from the reference
|
// DTC bytes ERR00..ERR03 = 37..40 (were read from 49..52 = SW5/SW6/…,
|
||||||
// tool's 98.7 kPa reading, not a clean enough match to move to yet.
|
// which produced phantom codes such as P0505/P0350 from SW6 = 0x60).
|
||||||
final double pa = frame[0x2C].toDouble();
|
final List<int> errBytes = [frame[37], frame[38], frame[39], frame[40]];
|
||||||
|
|
||||||
// ECT: decimal offset 33. Confirmed via live capture matching the
|
// Ethanol %: byte 41 (was 56).
|
||||||
// reference tool exactly: raw=0xD2(210), tempXlt[210]=38, 38-40=-2°C =
|
final double eth = frame[41].toDouble();
|
||||||
// 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
|
// AF: byte 55 (was 52 = unknown byte). UNVERIFIED scale — shown as raw.
|
||||||
// previous offset (+2D hex = 45 decimal) gave physically impossible
|
final double afr = frame[55].toDouble();
|
||||||
// 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)
|
// AIN0–AIN7: 82..97, 16-bit LE (was big-endian). Raw counts.
|
||||||
// is only reachable with subtraction, since tempXlt entries span 0..190.
|
final double ain0 = _u16le(frame, 82).toDouble();
|
||||||
final double ect = (tempXlt[frame[33]] - 40).toDouble();
|
final double ain1 = _u16le(frame, 84).toDouble();
|
||||||
|
final double ain2 = _u16le(frame, 86).toDouble();
|
||||||
// IAT: decimal offset 34, immediately after ECT. Confirmed via the same
|
final double ain3 = _u16le(frame, 88).toDouble();
|
||||||
// live capture: raw=0xCA(202), tempXlt[202]=43, 43-40=3°C=37.4°F vs the
|
final double ain4 = _u16le(frame, 90).toDouble();
|
||||||
// reference tool's 36.1°F — within ~1.3°F, consistent with table
|
final double ain5 = _u16le(frame, 92).toDouble();
|
||||||
// granularity. The previous offset (+2E hex = 46 decimal) was part of the
|
final double ain6 = _u16le(frame, 94).toDouble();
|
||||||
// same wrong-offset block as the old ECT location.
|
final double ain7 = _u16le(frame, 96).toDouble();
|
||||||
final double iat = (tempXlt[frame[34]] - 40).toDouble();
|
|
||||||
|
// ── Flags (bit maps from the PDF) ──────────────────────────────────────
|
||||||
// BAT: decimal offset 36. Confirmed via live capture: raw=0x79(121),
|
// SW1: ?(7) DistErr(6) SCS(5) PSP(4) REVL(3) BKSW(2) ACSW(1) VTP(0)
|
||||||
// 121*26/270=11.66V vs the reference tool's 11.75V — within one raw unit
|
final bool revLimit = (sw1 & 0x08) != 0;
|
||||||
// of quantization (26/270 V per LSB ≈ 0.096V). This is also where BAT
|
// SW2: ALTC(7) IAB(6) MIL(5) PCS(4) ACCL(3) Fuel(2) VTS(1..0)
|
||||||
// lived before an earlier, unverified "fix" in this project's history
|
final bool mil = (sw2 & 0x20) != 0;
|
||||||
// moved it to +30 hex = 48 decimal — that fix was wrong; this restores
|
// Fuel pump relay, active-LOW (live-confirmed).
|
||||||
// the original, now confirmed-correct, offset.
|
final bool fuelPump = (sw2 & 0x04) == 0;
|
||||||
final double bat = frame[36] * 26.0 / 270.0;
|
final bool vtec = (sw2 & 0x03) != 0;
|
||||||
|
// SW5: … CL(2) … FANC(0). Fan output is FANC only — SW2 bit7 is ALTC
|
||||||
// ERR bytes: +31..+34 hex = 49..52 decimal
|
// (alternator control), not the fan.
|
||||||
final List<int> errBytes = [
|
final bool fanOut = (sw5 & 0x01) != 0;
|
||||||
frame[0x31],
|
final bool knock = frame[13] > 0; // KRtrd
|
||||||
frame[0x32],
|
// SW3: LnchC(7) LnchR(6) IGNC(5) ShftC(4) BstC(3)
|
||||||
frame[0x33],
|
final bool launch = (sw3 & 0x80) != 0;
|
||||||
frame[0x34],
|
final bool launchRetard = (sw3 & 0x40) != 0;
|
||||||
];
|
final bool ignCut = (sw3 & 0x20) != 0;
|
||||||
|
final bool shiftCut = (sw3 & 0x10) != 0;
|
||||||
// Eth: +38 hex = 56 decimal
|
final bool boostCut = (sw3 & 0x08) != 0;
|
||||||
final double eth = frame[0x38].toDouble();
|
final bool closedLoop = (sw5 & 0x04) != 0; // SW5 CL
|
||||||
|
final bool brakeSwitch = (sw1 & 0x04) != 0; // SW1 BKSW
|
||||||
// AFR: spec overlaps +34 with ERR03; reuse that byte per the spec's own table.
|
final bool acSwitch = (sw1 & 0x02) != 0; // SW1 ACSW
|
||||||
final double afr = frame[0x34].toDouble();
|
final bool vtp = (sw1 & 0x01) != 0; // SW1 VTP (VTEC pressure)
|
||||||
|
|
||||||
// AIN0–AIN7: decimal offsets 82..97 (uint16 BE each)
|
return SensorState(
|
||||||
final double ain0 = _readUint16BE(frame, 82).toDouble();
|
rpm: rpm,
|
||||||
final double ain1 = _readUint16BE(frame, 84).toDouble();
|
vss: vss,
|
||||||
final double ain2 = _readUint16BE(frame, 86).toDouble();
|
map: map,
|
||||||
final double ain3 = _readUint16BE(frame, 88).toDouble();
|
tps: tps,
|
||||||
final double ain4 = _readUint16BE(frame, 90).toDouble();
|
inj: inj,
|
||||||
final double ain5 = _readUint16BE(frame, 92).toDouble();
|
ign: ign,
|
||||||
final double ain6 = _readUint16BE(frame, 94).toDouble();
|
ect: ect,
|
||||||
final double ain7 = _readUint16BE(frame, 96).toDouble();
|
iat: iat,
|
||||||
|
bat: bat,
|
||||||
// Flags
|
o2: o2,
|
||||||
// SW1 bit3 = REVL
|
gear: gear,
|
||||||
final bool revLimit = (sw1 & 0x08) != 0;
|
eth: eth,
|
||||||
// SW2 bit5 = MIL
|
pa: pa,
|
||||||
final bool mil = (sw2 & 0x20) != 0;
|
afr: afr,
|
||||||
// SW2 bit2 = Fuel — this is the fuel PUMP relay status, not fuel cut (the
|
strim: strim,
|
||||||
// reference tool's own channel list labels it as such). There's no
|
ltrim: ltrim,
|
||||||
// separate "fuel cut" bit identified in this frame — REVL/IGNC/BstC/
|
ain0: ain0,
|
||||||
// LnchC/ShftC are the actual cut-type flags this ECU exposes.
|
ain1: ain1,
|
||||||
//
|
ain2: ain2,
|
||||||
// Active-LOW: confirmed via an extended live session (RPM swept 400-3842,
|
ain3: ain3,
|
||||||
// continuously above the ~300 threshold where the pump should be on) that
|
ain4: ain4,
|
||||||
// this bit read 0 the entire time — and separately confirmed live that the
|
ain5: ain5,
|
||||||
// app showed "on" below ~200 rpm (pump should be off) and "off" above the
|
ain6: ain6,
|
||||||
// threshold (pump should be on), i.e. exactly inverted from a `!= 0` read.
|
ain7: ain7,
|
||||||
// Bit clear (0) means the pump relay is energized/on; bit set (1) means off.
|
mil: mil,
|
||||||
final bool fuelPump = (sw2 & 0x04) == 0;
|
fuelPump: fuelPump,
|
||||||
// SW2 bit7 = ALTC → fanOut; also SW5 bit0 = FANC
|
fanOut: fanOut,
|
||||||
final bool fanOut = (sw2 & 0x80) != 0 || (sw5 & 0x01) != 0;
|
vtec: vtec,
|
||||||
// SW2 bit1..0 = VTS (VTEC solenoid — nonzero = active)
|
knock: knock,
|
||||||
final bool vtec = (sw2 & 0x03) != 0;
|
revLimit: revLimit,
|
||||||
// Knock = KRtrd > 0 (byte 13)
|
launch: launch,
|
||||||
final bool knock = frame[13] > 0;
|
launchRetard: launchRetard,
|
||||||
// SW3 bit7 = LnchC
|
ignCut: ignCut,
|
||||||
final bool launch = (sw3 & 0x80) != 0;
|
shiftCut: shiftCut,
|
||||||
|
boostCut: boostCut,
|
||||||
return SensorState(
|
closedLoop: closedLoop,
|
||||||
rpm: rpm,
|
brakeSwitch: brakeSwitch,
|
||||||
vss: vss,
|
acSwitch: acSwitch,
|
||||||
map: map,
|
vtp: vtp,
|
||||||
tps: tps,
|
iatError: anyDtcBit(errBytes, s300IatErrorBits),
|
||||||
inj: inj,
|
ectError: anyDtcBit(errBytes, s300EctErrorBits),
|
||||||
ign: ign,
|
o2Error: anyDtcBit(errBytes, s300O2ErrorBits),
|
||||||
ect: ect,
|
errBytes: errBytes,
|
||||||
iat: iat,
|
timestamp: DateTime.now(),
|
||||||
bat: bat,
|
);
|
||||||
o2: o2,
|
}
|
||||||
gear: gear,
|
|
||||||
eth: eth,
|
/// Throttle %, both ECUs: (raw - 25) / 2.04, clamped to 0..100 (the PDF's
|
||||||
pa: pa,
|
/// "TPS < 25 ? 0" threshold). Checked against the USB tools: S300 raw ≈ 88
|
||||||
afr: afr,
|
/// -> 31 %, KPro raw 163 -> 68 %. (The previous raw × 100/255 matched only
|
||||||
strim: strim,
|
/// near 50 %.)
|
||||||
ltrim: ltrim,
|
double tpsPercent(int raw) => ((raw - 25) / 2.04).clamp(0.0, 100.0);
|
||||||
ain0: ain0,
|
|
||||||
ain1: ain1,
|
int _u16le(Uint8List frame, int offset) =>
|
||||||
ain2: ain2,
|
frame[offset] | (frame[offset + 1] << 8);
|
||||||
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,184 +1,219 @@
|
|||||||
/// Fully decoded ECU sensor state. Immutable. Produced by S300 or KPro parser.
|
/// Fully decoded ECU sensor state. Immutable. Produced by S300 or KPro parser.
|
||||||
class SensorState {
|
class SensorState {
|
||||||
final double rpm;
|
final double rpm;
|
||||||
final double vss;
|
final double vss;
|
||||||
final double map;
|
final double map;
|
||||||
final double tps;
|
final double tps;
|
||||||
final double inj;
|
final double inj;
|
||||||
final double ign;
|
final double ign;
|
||||||
final double ect;
|
final double ect;
|
||||||
final double iat;
|
final double iat;
|
||||||
final double bat;
|
final double bat;
|
||||||
final double o2;
|
final double o2;
|
||||||
final int gear;
|
final int gear;
|
||||||
final double eth;
|
final double eth;
|
||||||
final double pa;
|
final double pa;
|
||||||
final double afr;
|
final double afr;
|
||||||
final double strim;
|
final double strim;
|
||||||
final double ltrim;
|
final double ltrim;
|
||||||
// Analog inputs (AIN0–AIN7)
|
// Analog inputs (AIN0–AIN7)
|
||||||
final double ain0;
|
final double ain0;
|
||||||
final double ain1;
|
final double ain1;
|
||||||
final double ain2;
|
final double ain2;
|
||||||
final double ain3;
|
final double ain3;
|
||||||
final double ain4;
|
final double ain4;
|
||||||
final double ain5;
|
final double ain5;
|
||||||
final double ain6;
|
final double ain6;
|
||||||
final double ain7;
|
final double ain7;
|
||||||
// Boolean flags
|
// Boolean flags
|
||||||
final bool mil;
|
final bool mil;
|
||||||
final bool fuelPump;
|
final bool fuelPump;
|
||||||
final bool fanOut;
|
final bool fanOut;
|
||||||
final bool vtec;
|
final bool vtec;
|
||||||
final bool knock;
|
final bool knock;
|
||||||
final bool revLimit;
|
final bool revLimit;
|
||||||
final bool launch;
|
final bool launch;
|
||||||
// Raw DTC bytes (ERR00..ERR17 for KPro, ERR00..ERR03 for S300)
|
// Extra flags (default false where an ECU does not report them).
|
||||||
final List<int> errBytes;
|
// iat/ect/o2Error are derived from the stored DTC bits.
|
||||||
// Frame timestamp
|
final bool ignCut;
|
||||||
final DateTime timestamp;
|
final bool boostCut;
|
||||||
|
final bool shiftCut;
|
||||||
const SensorState({
|
final bool launchRetard;
|
||||||
required this.rpm,
|
final bool closedLoop;
|
||||||
required this.vss,
|
final bool acSwitch;
|
||||||
required this.map,
|
final bool brakeSwitch;
|
||||||
required this.tps,
|
final bool vtp;
|
||||||
required this.inj,
|
final bool iatError;
|
||||||
required this.ign,
|
final bool ectError;
|
||||||
required this.ect,
|
final bool o2Error;
|
||||||
required this.iat,
|
// Raw DTC bytes (ERR00..ERR17 for KPro, ERR00..ERR03 for S300)
|
||||||
required this.bat,
|
final List<int> errBytes;
|
||||||
required this.o2,
|
// Frame timestamp
|
||||||
required this.gear,
|
final DateTime timestamp;
|
||||||
required this.eth,
|
|
||||||
required this.pa,
|
const SensorState({
|
||||||
required this.afr,
|
required this.rpm,
|
||||||
required this.strim,
|
required this.vss,
|
||||||
required this.ltrim,
|
required this.map,
|
||||||
required this.ain0,
|
required this.tps,
|
||||||
required this.ain1,
|
required this.inj,
|
||||||
required this.ain2,
|
required this.ign,
|
||||||
required this.ain3,
|
required this.ect,
|
||||||
required this.ain4,
|
required this.iat,
|
||||||
required this.ain5,
|
required this.bat,
|
||||||
required this.ain6,
|
required this.o2,
|
||||||
required this.ain7,
|
required this.gear,
|
||||||
required this.mil,
|
required this.eth,
|
||||||
required this.fuelPump,
|
required this.pa,
|
||||||
required this.fanOut,
|
required this.afr,
|
||||||
required this.vtec,
|
required this.strim,
|
||||||
required this.knock,
|
required this.ltrim,
|
||||||
required this.revLimit,
|
required this.ain0,
|
||||||
required this.launch,
|
required this.ain1,
|
||||||
required this.errBytes,
|
required this.ain2,
|
||||||
required this.timestamp,
|
required this.ain3,
|
||||||
});
|
required this.ain4,
|
||||||
|
required this.ain5,
|
||||||
factory SensorState.zero() => SensorState(
|
required this.ain6,
|
||||||
rpm: 0,
|
required this.ain7,
|
||||||
vss: 0,
|
required this.mil,
|
||||||
map: 0,
|
required this.fuelPump,
|
||||||
tps: 0,
|
required this.fanOut,
|
||||||
inj: 0,
|
required this.vtec,
|
||||||
ign: 0,
|
required this.knock,
|
||||||
ect: 0,
|
required this.revLimit,
|
||||||
iat: 0,
|
required this.launch,
|
||||||
bat: 0,
|
this.ignCut = false,
|
||||||
o2: 0,
|
this.boostCut = false,
|
||||||
gear: 0,
|
this.shiftCut = false,
|
||||||
eth: 0,
|
this.launchRetard = false,
|
||||||
pa: 0,
|
this.closedLoop = false,
|
||||||
afr: 0,
|
this.acSwitch = false,
|
||||||
strim: 0,
|
this.brakeSwitch = false,
|
||||||
ltrim: 0,
|
this.vtp = false,
|
||||||
ain0: 0,
|
this.iatError = false,
|
||||||
ain1: 0,
|
this.ectError = false,
|
||||||
ain2: 0,
|
this.o2Error = false,
|
||||||
ain3: 0,
|
required this.errBytes,
|
||||||
ain4: 0,
|
required this.timestamp,
|
||||||
ain5: 0,
|
});
|
||||||
ain6: 0,
|
|
||||||
ain7: 0,
|
factory SensorState.zero() => SensorState(
|
||||||
mil: false,
|
rpm: 0,
|
||||||
fuelPump: false,
|
vss: 0,
|
||||||
fanOut: false,
|
map: 0,
|
||||||
vtec: false,
|
tps: 0,
|
||||||
knock: false,
|
inj: 0,
|
||||||
revLimit: false,
|
ign: 0,
|
||||||
launch: false,
|
ect: 0,
|
||||||
errBytes: const [],
|
iat: 0,
|
||||||
timestamp: DateTime(0),
|
bat: 0,
|
||||||
);
|
o2: 0,
|
||||||
|
gear: 0,
|
||||||
SensorState copyWith({
|
eth: 0,
|
||||||
double? rpm,
|
pa: 0,
|
||||||
double? vss,
|
afr: 0,
|
||||||
double? map,
|
strim: 0,
|
||||||
double? tps,
|
ltrim: 0,
|
||||||
double? inj,
|
ain0: 0,
|
||||||
double? ign,
|
ain1: 0,
|
||||||
double? ect,
|
ain2: 0,
|
||||||
double? iat,
|
ain3: 0,
|
||||||
double? bat,
|
ain4: 0,
|
||||||
double? o2,
|
ain5: 0,
|
||||||
int? gear,
|
ain6: 0,
|
||||||
double? eth,
|
ain7: 0,
|
||||||
double? pa,
|
mil: false,
|
||||||
double? afr,
|
fuelPump: false,
|
||||||
double? strim,
|
fanOut: false,
|
||||||
double? ltrim,
|
vtec: false,
|
||||||
double? ain0,
|
knock: false,
|
||||||
double? ain1,
|
revLimit: false,
|
||||||
double? ain2,
|
launch: false,
|
||||||
double? ain3,
|
errBytes: const [],
|
||||||
double? ain4,
|
timestamp: DateTime(0),
|
||||||
double? ain5,
|
);
|
||||||
double? ain6,
|
|
||||||
double? ain7,
|
SensorState copyWith({
|
||||||
bool? mil,
|
double? rpm,
|
||||||
bool? fuelPump,
|
double? vss,
|
||||||
bool? fanOut,
|
double? map,
|
||||||
bool? vtec,
|
double? tps,
|
||||||
bool? knock,
|
double? inj,
|
||||||
bool? revLimit,
|
double? ign,
|
||||||
bool? launch,
|
double? ect,
|
||||||
List<int>? errBytes,
|
double? iat,
|
||||||
DateTime? timestamp,
|
double? bat,
|
||||||
}) {
|
double? o2,
|
||||||
return SensorState(
|
int? gear,
|
||||||
rpm: rpm ?? this.rpm,
|
double? eth,
|
||||||
vss: vss ?? this.vss,
|
double? pa,
|
||||||
map: map ?? this.map,
|
double? afr,
|
||||||
tps: tps ?? this.tps,
|
double? strim,
|
||||||
inj: inj ?? this.inj,
|
double? ltrim,
|
||||||
ign: ign ?? this.ign,
|
double? ain0,
|
||||||
ect: ect ?? this.ect,
|
double? ain1,
|
||||||
iat: iat ?? this.iat,
|
double? ain2,
|
||||||
bat: bat ?? this.bat,
|
double? ain3,
|
||||||
o2: o2 ?? this.o2,
|
double? ain4,
|
||||||
gear: gear ?? this.gear,
|
double? ain5,
|
||||||
eth: eth ?? this.eth,
|
double? ain6,
|
||||||
pa: pa ?? this.pa,
|
double? ain7,
|
||||||
afr: afr ?? this.afr,
|
bool? mil,
|
||||||
strim: strim ?? this.strim,
|
bool? fuelPump,
|
||||||
ltrim: ltrim ?? this.ltrim,
|
bool? fanOut,
|
||||||
ain0: ain0 ?? this.ain0,
|
bool? vtec,
|
||||||
ain1: ain1 ?? this.ain1,
|
bool? knock,
|
||||||
ain2: ain2 ?? this.ain2,
|
bool? revLimit,
|
||||||
ain3: ain3 ?? this.ain3,
|
bool? launch,
|
||||||
ain4: ain4 ?? this.ain4,
|
List<int>? errBytes,
|
||||||
ain5: ain5 ?? this.ain5,
|
DateTime? timestamp,
|
||||||
ain6: ain6 ?? this.ain6,
|
}) {
|
||||||
ain7: ain7 ?? this.ain7,
|
return SensorState(
|
||||||
mil: mil ?? this.mil,
|
rpm: rpm ?? this.rpm,
|
||||||
fuelPump: fuelPump ?? this.fuelPump,
|
vss: vss ?? this.vss,
|
||||||
fanOut: fanOut ?? this.fanOut,
|
map: map ?? this.map,
|
||||||
vtec: vtec ?? this.vtec,
|
tps: tps ?? this.tps,
|
||||||
knock: knock ?? this.knock,
|
inj: inj ?? this.inj,
|
||||||
revLimit: revLimit ?? this.revLimit,
|
ign: ign ?? this.ign,
|
||||||
launch: launch ?? this.launch,
|
ect: ect ?? this.ect,
|
||||||
errBytes: errBytes ?? this.errBytes,
|
iat: iat ?? this.iat,
|
||||||
timestamp: timestamp ?? this.timestamp,
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,22 +1,22 @@
|
|||||||
// Shared temperature lookup table for S300 and KPro ECU protocols.
|
// Shared temperature lookup table for S300 and KPro ECU protocols.
|
||||||
// Index = raw ECT/IAT byte value. Decoded temp = tempXlt[raw] - 40 (°C).
|
// Index = raw ECT/IAT byte value. Decoded temp = tempXlt[raw] - 40 (°C).
|
||||||
// (Subtraction, not addition: table entries span 0..190, and only
|
// (Subtraction, not addition: table entries span 0..190, and only
|
||||||
// subtracting 40 reaches the sensor's declared -40..150°C range.)
|
// subtracting 40 reaches the sensor's declared -40..150°C range.)
|
||||||
const List<int> tempXlt = [
|
const List<int> tempXlt = [
|
||||||
190, 190, 188, 186, 183, 181, 179, 177, 175, 172, 170, 168, 166, 163, 161, 159,
|
190, 190, 188, 186, 183, 181, 179, 177, 175, 172, 170, 168, 166, 163, 161, 159,
|
||||||
157, 155, 153, 151, 149, 146, 144, 142, 140, 138, 137, 136, 134, 133, 132, 130,
|
157, 155, 153, 151, 149, 146, 144, 142, 140, 138, 137, 136, 134, 133, 132, 130,
|
||||||
129, 128, 127, 125, 124, 123, 122, 121, 121, 120, 119, 118, 117, 116, 115, 115,
|
129, 128, 127, 125, 124, 123, 122, 121, 121, 120, 119, 118, 117, 116, 115, 115,
|
||||||
114, 113, 112, 111, 110, 110, 109, 109, 108, 107, 107, 106, 106, 105, 104, 104,
|
114, 113, 112, 111, 110, 110, 109, 109, 108, 107, 107, 106, 106, 105, 104, 104,
|
||||||
103, 102, 102, 101, 101, 100, 99, 99, 98, 98, 97, 96, 96, 95, 95, 94,
|
103, 102, 102, 101, 101, 100, 99, 99, 98, 98, 97, 96, 96, 95, 95, 94,
|
||||||
94, 93, 93, 93, 92, 92, 91, 91, 90, 90, 90, 89, 89, 88, 88, 87,
|
94, 93, 93, 93, 92, 92, 91, 91, 90, 90, 90, 89, 89, 88, 88, 87,
|
||||||
87, 87, 86, 86, 85, 85, 85, 84, 84, 83, 83, 82, 82, 82, 81, 81,
|
87, 87, 86, 86, 85, 85, 85, 84, 84, 83, 83, 82, 82, 82, 81, 81,
|
||||||
80, 80, 79, 79, 79, 78, 78, 77, 77, 76, 76, 76, 75, 75, 74, 74,
|
80, 80, 79, 79, 79, 78, 78, 77, 77, 76, 76, 76, 75, 75, 74, 74,
|
||||||
73, 73, 73, 72, 72, 71, 71, 71, 70, 70, 69, 69, 68, 68, 68, 67,
|
73, 73, 73, 72, 72, 71, 71, 71, 70, 70, 69, 69, 68, 68, 68, 67,
|
||||||
67, 66, 66, 66, 65, 65, 64, 64, 64, 63, 63, 62, 62, 61, 61, 60,
|
67, 66, 66, 66, 65, 65, 64, 64, 64, 63, 63, 62, 62, 61, 61, 60,
|
||||||
60, 60, 59, 59, 58, 58, 58, 57, 57, 56, 56, 55, 55, 55, 54, 54,
|
60, 60, 59, 59, 58, 58, 58, 57, 57, 56, 56, 55, 55, 55, 54, 54,
|
||||||
53, 53, 52, 52, 52, 51, 51, 50, 50, 49, 49, 49, 48, 48, 47, 47,
|
53, 53, 52, 52, 52, 51, 51, 50, 50, 49, 49, 49, 48, 48, 47, 47,
|
||||||
47, 46, 46, 45, 45, 44, 44, 44, 43, 43, 42, 42, 41, 41, 40, 39,
|
47, 46, 46, 45, 45, 44, 44, 44, 43, 43, 42, 42, 41, 41, 40, 39,
|
||||||
39, 39, 38, 37, 37, 36, 36, 35, 34, 34, 33, 33, 32, 31, 31, 30,
|
39, 39, 38, 37, 37, 36, 36, 35, 34, 34, 33, 33, 32, 31, 31, 30,
|
||||||
30, 29, 28, 28, 27, 27, 26, 25, 24, 23, 21, 20, 19, 18, 17, 16,
|
30, 29, 28, 28, 27, 27, 26, 25, 24, 23, 21, 20, 19, 18, 17, 16,
|
||||||
15, 14, 12, 11, 10, 9, 8, 7, 5, 4, 3, 1, 0, 0, 0, 0,
|
15, 14, 12, 11, 10, 9, 8, 7, 5, 4, 3, 1, 0, 0, 0, 0,
|
||||||
];
|
];
|
||||||
|
|||||||
@ -1,236 +1,542 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart';
|
import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../bluetooth/bt_poller.dart';
|
import '../bluetooth/bt_poller.dart';
|
||||||
import '../bluetooth/bt_service.dart';
|
import '../bluetooth/rpm_glitch_filter.dart';
|
||||||
import '../protocol/neg8.dart';
|
import '../bluetooth/bt_service.dart';
|
||||||
import 'settings_provider.dart';
|
import '../logging/app_log.dart';
|
||||||
|
import '../protocol/neg8.dart';
|
||||||
// ─── Connection status enum ──────────────────────────────────────────────────
|
import '../protocol/s300_parser.dart';
|
||||||
|
import 'settings_provider.dart';
|
||||||
enum BtStatus { disconnected, connecting, connected, error }
|
|
||||||
|
// ─── Enums ───────────────────────────────────────────────────────────────────
|
||||||
// ─── State ───────────────────────────────────────────────────────────────────
|
|
||||||
|
enum BtStatus { disconnected, connecting, connected, reconnecting, error }
|
||||||
class BtState {
|
|
||||||
final BtStatus status;
|
/// What the adapter is actually giving us, independent of the BT link.
|
||||||
final String? deviceName;
|
enum EcuDataStatus {
|
||||||
final String? error;
|
/// Not connected / nothing received yet.
|
||||||
final int frameCount;
|
none,
|
||||||
final int droppedFrames;
|
|
||||||
final Uint8List? lastFrame;
|
/// Real ECU readings are arriving.
|
||||||
|
live,
|
||||||
const BtState({
|
|
||||||
required this.status,
|
/// The adapter answers, but only with its built-in default frame
|
||||||
this.deviceName,
|
/// (hvbt_protocol.pdf example values) — it has no fresh ECU data.
|
||||||
this.error,
|
adapterDefaults,
|
||||||
this.frameCount = 0,
|
|
||||||
this.droppedFrames = 0,
|
/// The adapter is not answering requests.
|
||||||
this.lastFrame,
|
noResponse,
|
||||||
});
|
}
|
||||||
|
|
||||||
const BtState.disconnected() : this(status: BtStatus.disconnected);
|
// ─── State ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
BtState connecting(String name) =>
|
class BtState {
|
||||||
BtState(status: BtStatus.connecting, deviceName: name);
|
final BtStatus status;
|
||||||
|
final String? deviceName;
|
||||||
BtState connected(String name) =>
|
final String? error;
|
||||||
BtState(status: BtStatus.connected, deviceName: name);
|
final EcuDataStatus dataStatus;
|
||||||
|
final int requestsSent;
|
||||||
BtState withError(String msg) => BtState(
|
final int frameCount; // valid frames (live + default)
|
||||||
status: BtStatus.error,
|
final int liveFrames;
|
||||||
deviceName: deviceName,
|
final int rpmGlitches; // frames dropped for an implausible RPM jump
|
||||||
error: msg,
|
final int defaultFrames;
|
||||||
frameCount: frameCount,
|
final int droppedFrames; // bad checksum
|
||||||
droppedFrames: droppedFrames,
|
final int timeouts;
|
||||||
lastFrame: lastFrame,
|
final int reconnects;
|
||||||
);
|
final int? lastResponseMs;
|
||||||
|
final Uint8List? lastFrame;
|
||||||
BtState withFrameStats(int frames, int dropped, Uint8List frame) => BtState(
|
|
||||||
status: status,
|
const BtState({
|
||||||
deviceName: deviceName,
|
required this.status,
|
||||||
frameCount: frames,
|
this.deviceName,
|
||||||
droppedFrames: dropped,
|
this.error,
|
||||||
lastFrame: Uint8List.fromList(frame),
|
this.dataStatus = EcuDataStatus.none,
|
||||||
);
|
this.requestsSent = 0,
|
||||||
|
this.frameCount = 0,
|
||||||
bool get isConnected => status == BtStatus.connected;
|
this.liveFrames = 0,
|
||||||
bool get isConnecting => status == BtStatus.connecting;
|
this.rpmGlitches = 0,
|
||||||
}
|
this.defaultFrames = 0,
|
||||||
|
this.droppedFrames = 0,
|
||||||
// ─── Notifier ────────────────────────────────────────────────────────────────
|
this.timeouts = 0,
|
||||||
|
this.reconnects = 0,
|
||||||
class BtNotifier extends StateNotifier<BtState> {
|
this.lastResponseMs,
|
||||||
final Ref _ref;
|
this.lastFrame,
|
||||||
BtPoller? _poller;
|
});
|
||||||
StreamSubscription<Uint8List>? _pollSub;
|
|
||||||
final StreamController<Uint8List> _frameController =
|
const BtState.disconnected() : this(status: BtStatus.disconnected);
|
||||||
StreamController<Uint8List>.broadcast();
|
|
||||||
|
BtState copyWith({
|
||||||
BtNotifier(this._ref) : super(const BtState.disconnected());
|
BtStatus? status,
|
||||||
|
String? deviceName,
|
||||||
/// Raw validated 128-byte frame stream — consumed by sensorStateProvider.
|
String? error,
|
||||||
Stream<Uint8List> get frameStream => _frameController.stream;
|
bool clearError = false,
|
||||||
|
EcuDataStatus? dataStatus,
|
||||||
Future<void> connect(BluetoothDevice device) async {
|
int? requestsSent,
|
||||||
state = state.connecting(device.name ?? device.address);
|
int? frameCount,
|
||||||
final service = _ref.read(btServiceProvider);
|
int? liveFrames,
|
||||||
final settings = _ref.read(settingsProvider);
|
int? rpmGlitches,
|
||||||
|
int? defaultFrames,
|
||||||
try {
|
int? droppedFrames,
|
||||||
await service.connect(device);
|
int? timeouts,
|
||||||
|
int? reconnects,
|
||||||
_poller = BtPoller(
|
int? lastResponseMs,
|
||||||
service,
|
Uint8List? lastFrame,
|
||||||
ecuType: settings.ecuType,
|
}) =>
|
||||||
pollingInterval: settings.pollingInterval,
|
BtState(
|
||||||
);
|
status: status ?? this.status,
|
||||||
|
deviceName: deviceName ?? this.deviceName,
|
||||||
_pollSub = _poller!.frameStream.listen(
|
error: clearError ? null : (error ?? this.error),
|
||||||
(frame) {
|
dataStatus: dataStatus ?? this.dataStatus,
|
||||||
_frameController.add(frame);
|
requestsSent: requestsSent ?? this.requestsSent,
|
||||||
state = state.withFrameStats(
|
frameCount: frameCount ?? this.frameCount,
|
||||||
_poller!.validFrames,
|
liveFrames: liveFrames ?? this.liveFrames,
|
||||||
_poller!.droppedFrames,
|
rpmGlitches: rpmGlitches ?? this.rpmGlitches,
|
||||||
frame,
|
defaultFrames: defaultFrames ?? this.defaultFrames,
|
||||||
);
|
droppedFrames: droppedFrames ?? this.droppedFrames,
|
||||||
},
|
timeouts: timeouts ?? this.timeouts,
|
||||||
onError: (Object e) {
|
reconnects: reconnects ?? this.reconnects,
|
||||||
_frameController.addError(e);
|
lastResponseMs: lastResponseMs ?? this.lastResponseMs,
|
||||||
state = state.withError(e.toString());
|
lastFrame: lastFrame ?? this.lastFrame,
|
||||||
},
|
);
|
||||||
onDone: () => state = state.withError('BT stream closed'),
|
|
||||||
);
|
bool get isConnected => status == BtStatus.connected;
|
||||||
|
bool get isConnecting =>
|
||||||
_poller!.start();
|
status == BtStatus.connecting || status == BtStatus.reconnecting;
|
||||||
state = state.connected(device.name ?? device.address);
|
}
|
||||||
} catch (e) {
|
|
||||||
await service.disconnect();
|
// ─── Notifier ────────────────────────────────────────────────────────────────
|
||||||
state = state.withError(e.toString());
|
|
||||||
}
|
class BtNotifier extends StateNotifier<BtState> {
|
||||||
}
|
BtNotifier(this._ref) : super(const BtState.disconnected()) {
|
||||||
|
// Apply polling-interval changes from Settings to a running poller.
|
||||||
/// One-off diagnostic: sends the official Hondata Bluetooth protocol's
|
_ref.listen<AppSettings>(settingsProvider, (prev, next) {
|
||||||
/// DL_DeviceInfo command ({ Protocol=0x00, ID=0x02, Size=0x0004 LE }) and
|
_poller?.pollingInterval = next.pollingInterval;
|
||||||
/// returns whatever raw bytes come back within the response window. Used
|
_poller?.ecuType = next.ecuType;
|
||||||
/// 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
|
final Ref _ref;
|
||||||
/// traffic doesn't interleave with the probe's reply on the same link.
|
BtPoller? _poller;
|
||||||
Future<Uint8List> probeOfficialProtocol() async {
|
StreamSubscription<Uint8List>? _pollSub;
|
||||||
final service = _ref.read(btServiceProvider);
|
Timer? _statusTimer;
|
||||||
if (!service.isConnected) return Uint8List(0);
|
final StreamController<Uint8List> _frameController =
|
||||||
|
StreamController<Uint8List>.broadcast();
|
||||||
_poller?.stop();
|
|
||||||
|
BluetoothDevice? _device;
|
||||||
// stop() only cancels the poller's own timer and listener — it can't
|
bool _userDisconnected = false;
|
||||||
// un-send a legacy request the poller already wrote moments earlier.
|
bool _reconnecting = false;
|
||||||
// That request's 128-byte reply is very likely still in flight, so
|
// Bumped on every user connect/disconnect. A link opened by an older
|
||||||
// drain and discard whatever arrives here before sending the probe, or
|
// session (e.g. an auto-reconnect still in flight when the user tapped
|
||||||
// that leftover legacy frame gets mistaken for the probe's response.
|
// Disconnect) is closed instead of being adopted.
|
||||||
final drainSub = service.rawStream.listen((_) {});
|
int _session = 0;
|
||||||
await Future.delayed(const Duration(milliseconds: 500));
|
DateTime? _lastAnyFrameAt;
|
||||||
await drainSub.cancel();
|
DateTime? _lastLiveFrameAt;
|
||||||
|
DateTime? _defaultsSince;
|
||||||
final collected = <int>[];
|
DateTime? _lastAutoReconnectAt;
|
||||||
final sub = service.rawStream.listen((chunk) => collected.addAll(chunk));
|
int _liveFrames = 0;
|
||||||
|
int _defaultFrames = 0;
|
||||||
try {
|
int _reconnects = 0;
|
||||||
await service.write(Uint8List.fromList([0x00, 0x02, 0x04, 0x00]));
|
|
||||||
await Future.delayed(const Duration(milliseconds: 800));
|
static const _staleAfter = Duration(milliseconds: 1500);
|
||||||
} finally {
|
static const _defaultsReconnectAfter = Duration(seconds: 8);
|
||||||
await sub.cancel();
|
static const _minTimeBetweenAutoReconnects = Duration(seconds: 45);
|
||||||
_poller?.start();
|
|
||||||
}
|
/// Validated 128-byte frames carrying REAL ECU data (adapter default frames
|
||||||
|
/// are filtered out). Consumed by sensorStateProvider and the datalog.
|
||||||
return Uint8List.fromList(collected);
|
Stream<Uint8List> get frameStream => _frameController.stream;
|
||||||
}
|
|
||||||
|
BtPoller? get poller => _poller;
|
||||||
/// Sends the DTC-reset command from the legacy protocol notes
|
|
||||||
/// (hvbt_protocol.pdf): { 0x1B, 0x70, NEG8 }, NEG8 over the first two
|
Future<void> connect(BluetoothDevice device) async {
|
||||||
/// bytes. The ECU echoes the same 3 bytes back to acknowledge.
|
if (state.isConnecting) return; // ignore double taps
|
||||||
///
|
final session = ++_session;
|
||||||
/// Deliberately does NOT pause the poller first (unlike
|
// The adapter's name says which ECU it is wired to; a stale saved
|
||||||
/// probeOfficialProtocol()) — pausing-then-sending got zero response both
|
// setting for the other ECU makes every request go unanswered.
|
||||||
/// here and for the official-protocol probe, while the regular poll cycle
|
final inferred = ecuTypeFromDeviceName(device.name);
|
||||||
/// always gets answered. One untested explanation: this adapter may only
|
if (inferred != null && inferred != _ref.read(settingsProvider).ecuType) {
|
||||||
/// answer additional commands while it keeps seeing the normal poll
|
AppLog.i('ECU protocol set to ${inferred.name} from adapter name '
|
||||||
/// traffic, not when that traffic stops. So this injects the command
|
'"${device.name}"');
|
||||||
/// alongside ongoing polling and scans the stream for the 3-byte ack
|
_ref.read(settingsProvider.notifier).setEcuType(inferred);
|
||||||
/// appearing anywhere in it — it'll be interleaved with regular 128-byte
|
}
|
||||||
/// datalog frames rather than arriving in isolation — over several poll
|
_device = device;
|
||||||
/// cycles' worth of time, in case the reply is delayed by an EEPROM/flash
|
_userDisconnected = false;
|
||||||
/// write on the ECU rather than a simple RAM read.
|
_resetCounters();
|
||||||
Future<({bool acknowledged, Uint8List raw})> clearDtcs() async {
|
state = BtState(
|
||||||
final service = _ref.read(btServiceProvider);
|
status: BtStatus.connecting,
|
||||||
if (!service.isConnected) return (acknowledged: false, raw: Uint8List(0));
|
deviceName: device.name ?? device.address,
|
||||||
|
);
|
||||||
final command = Uint8List.fromList([0x1B, 0x70, 0x00]);
|
try {
|
||||||
command[2] = calculateNeg8(Uint8List.fromList([0x1B, 0x70]));
|
await _openLink(device);
|
||||||
|
if (session != _session || !mounted) {
|
||||||
final collected = <int>[];
|
AppLog.i('Connect finished after disconnect — closing link');
|
||||||
final sub = service.rawStream.listen((chunk) => collected.addAll(chunk));
|
await _closeLink();
|
||||||
|
return;
|
||||||
bool acknowledged = false;
|
}
|
||||||
try {
|
state = state.copyWith(status: BtStatus.connected, clearError: true);
|
||||||
await service.write(command);
|
} catch (e, st) {
|
||||||
final deadline = DateTime.now().add(const Duration(seconds: 3));
|
AppLog.e('Connect failed', e, st);
|
||||||
while (DateTime.now().isBefore(deadline) && !acknowledged) {
|
if (session != _session || !mounted) return;
|
||||||
await Future.delayed(const Duration(milliseconds: 150));
|
await _closeLink();
|
||||||
acknowledged = _containsAck(collected);
|
state = state.copyWith(status: BtStatus.error, error: _friendly(e));
|
||||||
}
|
}
|
||||||
} finally {
|
}
|
||||||
await sub.cancel();
|
|
||||||
}
|
Future<void> disconnect() async {
|
||||||
|
_session++;
|
||||||
return (acknowledged: acknowledged, raw: Uint8List.fromList(collected));
|
_userDisconnected = true;
|
||||||
}
|
await _closeLink();
|
||||||
|
state = const BtState.disconnected();
|
||||||
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) {
|
// ── Link management ───────────────────────────────────────────────────────
|
||||||
return true;
|
|
||||||
}
|
Future<void> _openLink(BluetoothDevice device) async {
|
||||||
}
|
final service = _ref.read(btServiceProvider);
|
||||||
return false;
|
final settings = _ref.read(settingsProvider);
|
||||||
}
|
await service.connect(device);
|
||||||
|
|
||||||
Future<void> disconnect() async {
|
final poller = BtPoller(
|
||||||
await _pollSub?.cancel();
|
service,
|
||||||
_poller?.stop();
|
ecuType: settings.ecuType,
|
||||||
_poller?.dispose();
|
pollingInterval: settings.pollingInterval,
|
||||||
_poller = null;
|
onLinkStalled: () => _onLinkProblem('adapter stopped answering'),
|
||||||
await _ref.read(btServiceProvider).disconnect();
|
);
|
||||||
state = const BtState.disconnected();
|
_poller = poller;
|
||||||
}
|
_pollSub = poller.frameStream.listen(
|
||||||
|
_onFrame,
|
||||||
@override
|
onError: (Object e) => _onLinkProblem('$e'),
|
||||||
void dispose() {
|
);
|
||||||
_pollSub?.cancel();
|
poller.start();
|
||||||
_poller?.dispose();
|
|
||||||
_frameController.close();
|
_statusTimer?.cancel();
|
||||||
super.dispose();
|
_statusTimer =
|
||||||
}
|
Timer.periodic(const Duration(milliseconds: 500), (_) => _tick());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Providers ───────────────────────────────────────────────────────────────
|
Future<void> _closeLink() async {
|
||||||
|
_statusTimer?.cancel();
|
||||||
/// Singleton BtService — lives as long as the app.
|
_statusTimer = null;
|
||||||
final btServiceProvider = Provider<BtService>((ref) {
|
await _pollSub?.cancel();
|
||||||
final service = BtService();
|
_pollSub = null;
|
||||||
ref.onDispose(service.dispose);
|
_poller?.dispose();
|
||||||
return service;
|
_poller = null;
|
||||||
});
|
await _ref.read(btServiceProvider).disconnect();
|
||||||
|
}
|
||||||
/// BT connection state notifier.
|
|
||||||
final btProvider =
|
void _resetCounters() {
|
||||||
StateNotifierProvider<BtNotifier, BtState>((ref) => BtNotifier(ref));
|
_rpmFilter.reset();
|
||||||
|
_rpmFilter.dropped = 0;
|
||||||
/// Paired BT devices list (re-fetchable via ref.refresh).
|
_glitchesLogged = 0;
|
||||||
final pairedDevicesProvider = FutureProvider<List<BluetoothDevice>>((ref) {
|
_lastAnyFrameAt = null;
|
||||||
return FlutterBluetoothSerial.instance.getBondedDevices();
|
_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();
|
||||||
|
});
|
||||||
|
|||||||
@ -1,206 +1,225 @@
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../datalog/datalog_db.dart';
|
import '../datalog/datalog_db.dart';
|
||||||
import '../datalog/datalog_player.dart';
|
import '../datalog/datalog_player.dart';
|
||||||
import '../datalog/datalog_recorder.dart';
|
import '../datalog/datalog_recorder.dart';
|
||||||
import '../models/datalog_session.dart';
|
import '../models/datalog_session.dart';
|
||||||
import 'bt_provider.dart';
|
import '../bluetooth/bt_poller.dart';
|
||||||
import 'settings_provider.dart';
|
import 'bt_provider.dart';
|
||||||
|
import 'sensor_history_provider.dart';
|
||||||
// ─── Singletons ───────────────────────────────────────────────────────────────
|
import 'settings_provider.dart';
|
||||||
|
|
||||||
final datalogDbProvider = Provider<DatalogDb>((ref) {
|
// ─── Singletons ───────────────────────────────────────────────────────────────
|
||||||
final db = DatalogDb();
|
|
||||||
ref.onDispose(db.close);
|
final datalogDbProvider = Provider<DatalogDb>((ref) {
|
||||||
return db;
|
final db = DatalogDb();
|
||||||
});
|
ref.onDispose(db.close);
|
||||||
|
return db;
|
||||||
final datalogRecorderProvider = Provider<DatalogRecorder>((ref) {
|
});
|
||||||
final recorder = DatalogRecorder(ref.watch(datalogDbProvider));
|
|
||||||
ref.onDispose(recorder.dispose);
|
final datalogRecorderProvider = Provider<DatalogRecorder>((ref) {
|
||||||
return recorder;
|
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);
|
final datalogPlayerProvider = Provider<DatalogPlayer>((ref) {
|
||||||
return player;
|
final player = DatalogPlayer(ref.watch(datalogDbProvider));
|
||||||
});
|
ref.onDispose(player.dispose);
|
||||||
|
return player;
|
||||||
// ─── Recording state ──────────────────────────────────────────────────────────
|
});
|
||||||
|
|
||||||
enum RecordingStatus { idle, recording }
|
// ─── Recording state ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class RecordingState {
|
enum RecordingStatus { idle, recording }
|
||||||
final RecordingStatus status;
|
|
||||||
final int frameCount;
|
class RecordingState {
|
||||||
final int? sessionId;
|
final RecordingStatus status;
|
||||||
|
final int frameCount;
|
||||||
const RecordingState({
|
final int? sessionId;
|
||||||
this.status = RecordingStatus.idle,
|
|
||||||
this.frameCount = 0,
|
const RecordingState({
|
||||||
this.sessionId,
|
this.status = RecordingStatus.idle,
|
||||||
});
|
this.frameCount = 0,
|
||||||
|
this.sessionId,
|
||||||
bool get isRecording => status == RecordingStatus.recording;
|
});
|
||||||
|
|
||||||
RecordingState copyWith({
|
bool get isRecording => status == RecordingStatus.recording;
|
||||||
RecordingStatus? status,
|
|
||||||
int? frameCount,
|
RecordingState copyWith({
|
||||||
int? sessionId,
|
RecordingStatus? status,
|
||||||
}) =>
|
int? frameCount,
|
||||||
RecordingState(
|
int? sessionId,
|
||||||
status: status ?? this.status,
|
}) =>
|
||||||
frameCount: frameCount ?? this.frameCount,
|
RecordingState(
|
||||||
sessionId: sessionId ?? this.sessionId,
|
status: status ?? this.status,
|
||||||
);
|
frameCount: frameCount ?? this.frameCount,
|
||||||
}
|
sessionId: sessionId ?? this.sessionId,
|
||||||
|
);
|
||||||
class RecordingNotifier extends StateNotifier<RecordingState> {
|
}
|
||||||
final Ref _ref;
|
|
||||||
|
class RecordingNotifier extends StateNotifier<RecordingState> {
|
||||||
RecordingNotifier(this._ref) : super(const RecordingState());
|
final Ref _ref;
|
||||||
|
|
||||||
Future<void> startRecording() async {
|
RecordingNotifier(this._ref) : super(const RecordingState());
|
||||||
if (state.isRecording) return;
|
|
||||||
|
Future<void> startRecording() async {
|
||||||
final recorder = _ref.read(datalogRecorderProvider);
|
if (state.isRecording) return;
|
||||||
final btNotifier = _ref.read(btProvider.notifier);
|
|
||||||
final ecuType = _ref.read(settingsProvider).ecuType.name;
|
final recorder = _ref.read(datalogRecorderProvider);
|
||||||
|
final btNotifier = _ref.read(btProvider.notifier);
|
||||||
await recorder.start(btNotifier.frameStream, ecuType);
|
final ecuType = _ref.read(settingsProvider).ecuType.name;
|
||||||
|
|
||||||
state = RecordingState(
|
await recorder.start(btNotifier.frameStream, ecuType);
|
||||||
status: RecordingStatus.recording,
|
|
||||||
sessionId: recorder.sessionId,
|
state = RecordingState(
|
||||||
);
|
status: RecordingStatus.recording,
|
||||||
}
|
sessionId: recorder.sessionId,
|
||||||
|
);
|
||||||
Future<DatalogSession?> stopRecording() async {
|
}
|
||||||
if (!state.isRecording) return null;
|
|
||||||
|
Future<DatalogSession?> stopRecording() async {
|
||||||
final recorder = _ref.read(datalogRecorderProvider);
|
if (!state.isRecording) return null;
|
||||||
final session = await recorder.stop();
|
|
||||||
|
final recorder = _ref.read(datalogRecorderProvider);
|
||||||
state = const RecordingState();
|
final session = await recorder.stop();
|
||||||
// Refresh session list
|
|
||||||
_ref.invalidate(sessionListProvider);
|
state = const RecordingState();
|
||||||
return session;
|
// Refresh session list
|
||||||
}
|
_ref.invalidate(sessionListProvider);
|
||||||
|
return session;
|
||||||
/// Called periodically from UI to update live frame count display.
|
}
|
||||||
void syncFrameCount() {
|
|
||||||
if (!state.isRecording) return;
|
/// Called periodically from UI to update live frame count display.
|
||||||
final count = _ref.read(datalogRecorderProvider).frameCount;
|
void syncFrameCount() {
|
||||||
if (count != state.frameCount) {
|
if (!state.isRecording) return;
|
||||||
state = state.copyWith(frameCount: count);
|
final count = _ref.read(datalogRecorderProvider).frameCount;
|
||||||
}
|
if (count != state.frameCount) {
|
||||||
}
|
state = state.copyWith(frameCount: count);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
final recordingProvider =
|
}
|
||||||
StateNotifierProvider<RecordingNotifier, RecordingState>(
|
|
||||||
(ref) => RecordingNotifier(ref),
|
final recordingProvider =
|
||||||
);
|
StateNotifierProvider<RecordingNotifier, RecordingState>(
|
||||||
|
(ref) => RecordingNotifier(ref),
|
||||||
// ─── Session list ─────────────────────────────────────────────────────────────
|
);
|
||||||
|
|
||||||
final sessionListProvider = FutureProvider<List<DatalogSession>>((ref) {
|
// ─── Session list ─────────────────────────────────────────────────────────────
|
||||||
return ref.watch(datalogDbProvider).getSessions();
|
|
||||||
});
|
final sessionListProvider = FutureProvider<List<DatalogSession>>((ref) {
|
||||||
|
return ref.watch(datalogDbProvider).getSessions();
|
||||||
// ─── Playback state ───────────────────────────────────────────────────────────
|
});
|
||||||
|
|
||||||
class PlaybackState {
|
// ─── Playback state ───────────────────────────────────────────────────────────
|
||||||
final bool isActive;
|
|
||||||
final int? sessionId;
|
class PlaybackState {
|
||||||
final int position;
|
final bool isActive;
|
||||||
final int total;
|
final int? sessionId;
|
||||||
final double speed;
|
/// ECU type the session was recorded with; frames are parsed with it.
|
||||||
final PlaybackStatus status;
|
final EcuType ecuType;
|
||||||
|
final int position;
|
||||||
const PlaybackState({
|
final int total;
|
||||||
this.isActive = false,
|
final double speed;
|
||||||
this.sessionId,
|
final PlaybackStatus status;
|
||||||
this.position = 0,
|
|
||||||
this.total = 0,
|
const PlaybackState({
|
||||||
this.speed = 1.0,
|
this.isActive = false,
|
||||||
this.status = PlaybackStatus.idle,
|
this.sessionId,
|
||||||
});
|
this.ecuType = EcuType.s300,
|
||||||
|
this.position = 0,
|
||||||
double get progress => total == 0 ? 0 : position / total;
|
this.total = 0,
|
||||||
|
this.speed = 1.0,
|
||||||
PlaybackState copyWith({
|
this.status = PlaybackStatus.idle,
|
||||||
bool? isActive,
|
});
|
||||||
int? sessionId,
|
|
||||||
int? position,
|
double get progress => total == 0 ? 0 : position / total;
|
||||||
int? total,
|
|
||||||
double? speed,
|
PlaybackState copyWith({
|
||||||
PlaybackStatus? status,
|
bool? isActive,
|
||||||
}) =>
|
int? sessionId,
|
||||||
PlaybackState(
|
int? position,
|
||||||
isActive: isActive ?? this.isActive,
|
int? total,
|
||||||
sessionId: sessionId ?? this.sessionId,
|
double? speed,
|
||||||
position: position ?? this.position,
|
PlaybackStatus? status,
|
||||||
total: total ?? this.total,
|
}) =>
|
||||||
speed: speed ?? this.speed,
|
PlaybackState(
|
||||||
status: status ?? this.status,
|
isActive: isActive ?? this.isActive,
|
||||||
);
|
sessionId: sessionId ?? this.sessionId,
|
||||||
}
|
ecuType: ecuType,
|
||||||
|
position: position ?? this.position,
|
||||||
class PlaybackNotifier extends StateNotifier<PlaybackState> {
|
total: total ?? this.total,
|
||||||
final Ref _ref;
|
speed: speed ?? this.speed,
|
||||||
|
status: status ?? this.status,
|
||||||
PlaybackNotifier(this._ref) : super(const PlaybackState());
|
);
|
||||||
|
}
|
||||||
Future<void> loadSession(int sessionId) async {
|
|
||||||
final player = _ref.read(datalogPlayerProvider);
|
class PlaybackNotifier extends StateNotifier<PlaybackState> {
|
||||||
await player.load(sessionId);
|
final Ref _ref;
|
||||||
|
|
||||||
player.onProgress = (pos, total) {
|
PlaybackNotifier(this._ref) : super(const PlaybackState());
|
||||||
state = state.copyWith(
|
|
||||||
position: pos,
|
/// Loads a recorded session; while active, the dashboard, sensor list,
|
||||||
total: total,
|
/// graph and DTC screen show the recording instead of live data.
|
||||||
status: player.status,
|
Future<void> loadSession(int sessionId, String ecuType) async {
|
||||||
);
|
final player = _ref.read(datalogPlayerProvider);
|
||||||
};
|
await player.load(sessionId);
|
||||||
|
_ref.read(sensorHistoryProvider.notifier).clear();
|
||||||
state = PlaybackState(
|
|
||||||
isActive: true,
|
player.onProgress = (pos, total) {
|
||||||
sessionId: sessionId,
|
state = state.copyWith(
|
||||||
total: player.totalFrames,
|
position: pos,
|
||||||
speed: player.speed,
|
total: total,
|
||||||
status: PlaybackStatus.idle,
|
status: player.status,
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
void play() {
|
state = PlaybackState(
|
||||||
_ref.read(datalogPlayerProvider).play();
|
isActive: true,
|
||||||
state = state.copyWith(status: PlaybackStatus.playing);
|
sessionId: sessionId,
|
||||||
}
|
ecuType: ecuType == EcuType.kpro.name ? EcuType.kpro : EcuType.s300,
|
||||||
|
total: player.totalFrames,
|
||||||
void pause() {
|
speed: player.speed,
|
||||||
_ref.read(datalogPlayerProvider).pause();
|
status: PlaybackStatus.idle,
|
||||||
state = state.copyWith(status: PlaybackStatus.paused);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> stop() async {
|
void play() {
|
||||||
await _ref.read(datalogPlayerProvider).stop();
|
_ref.read(datalogPlayerProvider).play();
|
||||||
state = const PlaybackState();
|
state = state.copyWith(status: PlaybackStatus.playing);
|
||||||
}
|
}
|
||||||
|
|
||||||
void seek(int index) {
|
void pause() {
|
||||||
_ref.read(datalogPlayerProvider).seek(index);
|
_ref.read(datalogPlayerProvider).pause();
|
||||||
state = state.copyWith(position: index);
|
state = state.copyWith(status: PlaybackStatus.paused);
|
||||||
}
|
}
|
||||||
|
|
||||||
void setSpeed(double speed) {
|
Future<void> stop() async {
|
||||||
_ref.read(datalogPlayerProvider).speed = speed;
|
await _ref.read(datalogPlayerProvider).stop();
|
||||||
state = state.copyWith(speed: speed);
|
state = const PlaybackState();
|
||||||
}
|
_ref.read(sensorHistoryProvider.notifier).clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
final playbackProvider =
|
void seek(int index) {
|
||||||
StateNotifierProvider<PlaybackNotifier, PlaybackState>(
|
_ref.read(datalogPlayerProvider).seek(index);
|
||||||
(ref) => PlaybackNotifier(ref),
|
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));
|
||||||
|
});
|
||||||
|
|||||||
@ -1,88 +1,72 @@
|
|||||||
import 'package:fl_chart/fl_chart.dart';
|
import 'package:fl_chart/fl_chart.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../models/sensor_defs.dart';
|
import '../models/sensor_defs.dart';
|
||||||
import '../protocol/sensor_state.dart';
|
import '../protocol/sensor_state.dart';
|
||||||
import 'sensor_provider.dart';
|
import 'sensor_provider.dart';
|
||||||
|
|
||||||
const _maxPoints = 18000;
|
const _maxPoints = 18000;
|
||||||
|
|
||||||
class SensorHistory {
|
class SensorHistory {
|
||||||
final Map<String, List<FlSpot>> _data = {
|
final Map<String, List<FlSpot>> _data = {
|
||||||
for (final d in sensorDefs) d.id: [],
|
for (final d in sensorDefs) d.id: [],
|
||||||
};
|
};
|
||||||
double? _t0;
|
double? _t0;
|
||||||
|
|
||||||
void add(SensorState state) {
|
void add(SensorState state) {
|
||||||
final tSec = state.timestamp.millisecondsSinceEpoch / 1000.0;
|
final tSec = state.timestamp.millisecondsSinceEpoch / 1000.0;
|
||||||
_t0 ??= tSec;
|
_t0 ??= tSec;
|
||||||
final x = tSec - _t0!;
|
final x = tSec - _t0!;
|
||||||
|
|
||||||
void push(String id, double v) {
|
void push(String id, double v) {
|
||||||
final list = _data[id]!;
|
final list = _data[id]!;
|
||||||
list.add(FlSpot(x, v));
|
list.add(FlSpot(x, v));
|
||||||
if (list.length > _maxPoints) list.removeAt(0);
|
if (list.length > _maxPoints) list.removeAt(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
push('rpm', state.rpm);
|
for (final d in sensorDefs) {
|
||||||
push('vss', state.vss);
|
push(d.id, sensorValue(state, d.id));
|
||||||
push('map', state.map);
|
}
|
||||||
push('tps', state.tps);
|
}
|
||||||
push('inj', state.inj);
|
|
||||||
push('ign', state.ign);
|
List<FlSpot> get(String id) => List.unmodifiable(_data[id] ?? []);
|
||||||
push('ect', state.ect);
|
|
||||||
push('iat', state.iat);
|
void clear() {
|
||||||
push('bat', state.bat);
|
for (final list in _data.values) {
|
||||||
push('o2', state.o2);
|
list.clear();
|
||||||
push('gear', state.gear.toDouble());
|
}
|
||||||
push('eth', state.eth);
|
_t0 = null;
|
||||||
push('pa', state.pa);
|
}
|
||||||
push('afr', state.afr);
|
}
|
||||||
push('strim', state.strim);
|
|
||||||
push('ltrim', state.ltrim);
|
class SensorHistoryNotifier extends StateNotifier<SensorHistory> {
|
||||||
push('ain0', state.ain0);
|
SensorHistoryNotifier() : super(SensorHistory());
|
||||||
push('ain1', state.ain1);
|
|
||||||
push('ain2', state.ain2);
|
// History is mutated in place; StateNotifier's default identical() check
|
||||||
push('ain3', state.ain3);
|
// would swallow `state = state`, so the graph never redrew live.
|
||||||
push('ain4', state.ain4);
|
@override
|
||||||
push('ain5', state.ain5);
|
bool updateShouldNotify(SensorHistory old, SensorHistory current) => true;
|
||||||
push('ain6', state.ain6);
|
|
||||||
push('ain7', state.ain7);
|
void addState(SensorState s) {
|
||||||
}
|
state.add(s);
|
||||||
|
// trigger rebuild by reassigning same object
|
||||||
List<FlSpot> get(String id) => List.unmodifiable(_data[id] ?? []);
|
// ignore: invalid_use_of_protected_member
|
||||||
|
state = state;
|
||||||
void clear() {
|
}
|
||||||
for (final list in _data.values) {
|
|
||||||
list.clear();
|
void clear() {
|
||||||
}
|
state.clear();
|
||||||
_t0 = null;
|
state = SensorHistory();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class SensorHistoryNotifier extends StateNotifier<SensorHistory> {
|
final sensorHistoryProvider =
|
||||||
SensorHistoryNotifier() : super(SensorHistory());
|
StateNotifierProvider<SensorHistoryNotifier, SensorHistory>((ref) {
|
||||||
|
final notifier = SensorHistoryNotifier();
|
||||||
void addState(SensorState s) {
|
|
||||||
state.add(s);
|
// Feed every parsed sensor state into history
|
||||||
// trigger rebuild by reassigning same object
|
ref.listen<AsyncValue<SensorState>>(sensorStateProvider, (_, next) {
|
||||||
// ignore: invalid_use_of_protected_member
|
next.whenData(notifier.addState);
|
||||||
state = state;
|
});
|
||||||
}
|
|
||||||
|
return notifier;
|
||||||
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,12 +1,13 @@
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../bluetooth/bt_poller.dart';
|
import '../bluetooth/bt_poller.dart';
|
||||||
|
import '../logging/app_log.dart';
|
||||||
import '../models/sensor_defs.dart';
|
import '../models/sensor_defs.dart';
|
||||||
import '../protocol/kpro_parser.dart';
|
import '../protocol/kpro_parser.dart';
|
||||||
import '../protocol/s300_parser.dart';
|
import '../protocol/s300_parser.dart';
|
||||||
import '../protocol/sensor_state.dart';
|
import '../protocol/sensor_state.dart';
|
||||||
import 'bt_provider.dart';
|
import 'bt_provider.dart';
|
||||||
import 'settings_provider.dart';
|
import 'datalog_provider.dart';
|
||||||
|
|
||||||
// NEG8 is only an 8-bit checksum, so roughly 1 in 256 corrupted frames can
|
// 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
|
// still pass validation — rare, but enough to occasionally show an
|
||||||
@ -51,137 +52,39 @@ class DecodeStats {
|
|||||||
|
|
||||||
final decodeStatsProvider = StateProvider<DecodeStats>((ref) => const DecodeStats());
|
final decodeStatsProvider = StateProvider<DecodeStats>((ref) => const DecodeStats());
|
||||||
|
|
||||||
/// Emits a parsed [SensorState] for every live data frame received from the ECU.
|
/// Emits a parsed [SensorState] for every live data frame received from the
|
||||||
/// Automatically picks S300 or KPro parser based on [settingsProvider].
|
/// ECU — or, while a datalog plays back, for every recorded frame. Picks the
|
||||||
|
/// S300 or KPro parser via [effectiveEcuTypeProvider].
|
||||||
final sensorStateProvider = StreamProvider<SensorState>((ref) async* {
|
final sensorStateProvider = StreamProvider<SensorState>((ref) async* {
|
||||||
final ecuType = ref.watch(settingsProvider).ecuType;
|
final ecuType = ref.watch(effectiveEcuTypeProvider);
|
||||||
final btNotifier = ref.watch(btProvider.notifier);
|
final playing = ref.watch(playbackProvider.select((p) => p.isActive));
|
||||||
|
final source = playing
|
||||||
|
? ref.watch(datalogPlayerProvider).frameStream
|
||||||
|
: ref.watch(btProvider.notifier).frameStream;
|
||||||
|
|
||||||
// `await for` treats any error *event* on the source stream as fatal and
|
// `await for` treats an error event as fatal; absorb errors upstream so the
|
||||||
// exits the loop permanently — a try/catch inside the loop body cannot
|
// loop keeps waiting for the next frame.
|
||||||
// intercept that, since it happens in the iteration mechanism itself, not
|
final safeStream = source.handleError((Object _, StackTrace __) {});
|
||||||
// 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) {
|
await for (final frame in safeStream) {
|
||||||
// A single bad frame must never take down the rest of the live stream.
|
|
||||||
try {
|
try {
|
||||||
SensorState decoded =
|
SensorState decoded =
|
||||||
ecuType == EcuType.s300 ? parseS300(frame) : parseKPro(frame);
|
ecuType == EcuType.s300 ? parseS300(frame) : parseKPro(frame);
|
||||||
decoded = _clampToRealisticRange(decoded);
|
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);
|
final s = ref.read(decodeStatsProvider);
|
||||||
ref.read(decodeStatsProvider.notifier).state =
|
ref.read(decodeStatsProvider.notifier).state =
|
||||||
s.copyWith(decoded: s.decoded + 1);
|
s.copyWith(decoded: s.decoded + 1);
|
||||||
yield decoded;
|
yield decoded;
|
||||||
} catch (_) {
|
} catch (e) {
|
||||||
// Skip this one frame and keep the stream alive for the next.
|
|
||||||
final s = ref.read(decodeStatsProvider);
|
final s = ref.read(decodeStatsProvider);
|
||||||
ref.read(decodeStatsProvider.notifier).state =
|
ref.read(decodeStatsProvider.notifier).state =
|
||||||
s.copyWith(decodeErrors: s.decodeErrors + 1);
|
s.copyWith(decodeErrors: s.decodeErrors + 1);
|
||||||
|
if (s.decodeErrors < 5) AppLog.w('Frame decode error: $e');
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,48 +1,134 @@
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import '../bluetooth/bt_poller.dart';
|
import '../bluetooth/bt_poller.dart';
|
||||||
|
import '../models/units.dart';
|
||||||
class AppSettings {
|
|
||||||
final EcuType ecuType;
|
/// Polling choices offered in Settings. Never faster than 10 Hz: the adapter
|
||||||
final Duration pollingInterval;
|
/// MCU must have time to read the ECU between requests (device maker's
|
||||||
|
/// feedback). The poller additionally never sends a new request before the
|
||||||
const AppSettings({
|
/// previous one is answered or has timed out.
|
||||||
this.ecuType = EcuType.s300,
|
const List<int> pollingIntervalOptionsMs = [100, 150, 200, 300];
|
||||||
this.pollingInterval = const Duration(milliseconds: 50),
|
|
||||||
});
|
class AppSettings {
|
||||||
|
final EcuType ecuType;
|
||||||
AppSettings copyWith({EcuType? ecuType, Duration? pollingInterval}) =>
|
final Duration pollingInterval;
|
||||||
AppSettings(
|
|
||||||
ecuType: ecuType ?? this.ecuType,
|
const AppSettings({
|
||||||
pollingInterval: pollingInterval ?? this.pollingInterval,
|
this.ecuType = EcuType.s300,
|
||||||
);
|
this.pollingInterval = BtPoller.defaultInterval,
|
||||||
}
|
});
|
||||||
|
|
||||||
const _ecuTypeKey = 'ecuType';
|
AppSettings copyWith({EcuType? ecuType, Duration? pollingInterval}) =>
|
||||||
|
AppSettings(
|
||||||
class SettingsNotifier extends StateNotifier<AppSettings> {
|
ecuType: ecuType ?? this.ecuType,
|
||||||
SettingsNotifier() : super(const AppSettings()) {
|
pollingInterval: pollingInterval ?? this.pollingInterval,
|
||||||
_load();
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _load() async {
|
const _ecuTypeKey = 'ecuType';
|
||||||
final prefs = await SharedPreferences.getInstance();
|
const _pollingKey = 'pollingIntervalMs';
|
||||||
final saved = prefs.getString(_ecuTypeKey);
|
|
||||||
if (saved == EcuType.kpro.name) {
|
class SettingsNotifier extends StateNotifier<AppSettings> {
|
||||||
state = state.copyWith(ecuType: EcuType.kpro);
|
SettingsNotifier() : super(const AppSettings()) {
|
||||||
}
|
_load();
|
||||||
}
|
}
|
||||||
|
|
||||||
void setEcuType(EcuType type) {
|
Future<void> _load() async {
|
||||||
state = state.copyWith(ecuType: type);
|
try {
|
||||||
SharedPreferences.getInstance()
|
final prefs = await SharedPreferences.getInstance();
|
||||||
.then((prefs) => prefs.setString(_ecuTypeKey, type.name));
|
final saved = prefs.getString(_ecuTypeKey);
|
||||||
}
|
final ms = prefs.getInt(_pollingKey);
|
||||||
|
var next = state;
|
||||||
void setPollingInterval(Duration d) =>
|
if (saved == EcuType.kpro.name) next = next.copyWith(ecuType: EcuType.kpro);
|
||||||
state = state.copyWith(pollingInterval: d);
|
if (ms != null && pollingIntervalOptionsMs.contains(ms)) {
|
||||||
}
|
next = next.copyWith(pollingInterval: Duration(milliseconds: ms));
|
||||||
|
}
|
||||||
final settingsProvider = StateNotifierProvider<SettingsNotifier, AppSettings>(
|
if (mounted) state = next;
|
||||||
(ref) => SettingsNotifier(),
|
} 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(),
|
||||||
|
);
|
||||||
|
|||||||
@ -1,32 +1,55 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
enum AppThemeVariant { redDark, redLight, greenDark, greenLight }
|
|
||||||
|
enum AppThemeVariant { redDark, redLight, greenDark, greenLight }
|
||||||
class ThemeNotifier extends StateNotifier<AppThemeVariant> {
|
|
||||||
ThemeNotifier() : super(AppThemeVariant.redDark);
|
const _themeKey = 'themeVariant';
|
||||||
|
|
||||||
void set(AppThemeVariant v) => state = v;
|
/// Colour scheme (red/green) × brightness (dark/light), persisted.
|
||||||
}
|
class ThemeNotifier extends StateNotifier<AppThemeVariant> {
|
||||||
|
ThemeNotifier() : super(AppThemeVariant.redDark) {
|
||||||
final themeProvider =
|
_load();
|
||||||
StateNotifierProvider<ThemeNotifier, AppThemeVariant>(
|
}
|
||||||
(ref) => ThemeNotifier(),
|
|
||||||
);
|
Future<void> _load() async {
|
||||||
|
try {
|
||||||
/// Returns the accent color for the current theme variant.
|
final prefs = await SharedPreferences.getInstance();
|
||||||
extension AppThemeVariantX on AppThemeVariant {
|
final saved = prefs.getString(_themeKey);
|
||||||
Color get accentColor {
|
for (final v in AppThemeVariant.values) {
|
||||||
switch (this) {
|
if (v.name == saved && mounted) state = v;
|
||||||
case AppThemeVariant.redDark:
|
}
|
||||||
case AppThemeVariant.redLight:
|
} catch (_) {
|
||||||
return const Color(0xFFFF4444);
|
// Keep the default if preferences can't be read.
|
||||||
case AppThemeVariant.greenDark:
|
}
|
||||||
case AppThemeVariant.greenLight:
|
}
|
||||||
return const Color(0xFF00E676);
|
|
||||||
}
|
void set(AppThemeVariant v) {
|
||||||
}
|
state = v;
|
||||||
|
SharedPreferences.getInstance()
|
||||||
bool get isDark =>
|
.then((prefs) => prefs.setString(_themeKey, v.name))
|
||||||
this == AppThemeVariant.redDark || this == AppThemeVariant.greenDark;
|
.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;
|
||||||
|
}
|
||||||
|
|||||||
@ -1,26 +1,69 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'dart:async';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'core/providers/theme_provider.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'ui/theme/app_theme.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'ui/screens/bt_picker/bt_picker_screen.dart';
|
|
||||||
|
import 'core/logging/app_log.dart';
|
||||||
void main() {
|
import 'core/providers/theme_provider.dart';
|
||||||
runApp(const ProviderScope(child: HvbtApp()));
|
import 'ui/theme/app_theme.dart';
|
||||||
}
|
import 'ui/screens/bt_picker/bt_picker_screen.dart';
|
||||||
|
|
||||||
class HvbtApp extends ConsumerWidget {
|
void main() {
|
||||||
const HvbtApp({super.key});
|
runZonedGuarded(() {
|
||||||
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
// Catch every Dart-side error, log it (copyable from the Debug screen)
|
||||||
final themeVariant = ref.watch(themeProvider);
|
// and keep the app running instead of showing a red/grey error screen.
|
||||||
|
FlutterError.onError = (FlutterErrorDetails details) {
|
||||||
return MaterialApp(
|
AppLog.e('Flutter error', details.exception, details.stack);
|
||||||
title: 'HV BT Dashboard',
|
};
|
||||||
debugShowCheckedModeBanner: false,
|
WidgetsBinding.instance.platformDispatcher.onError = (error, stack) {
|
||||||
theme: AppTheme.build(themeVariant),
|
AppLog.e('Uncaught platform error', error, stack);
|
||||||
home: const BtPickerScreen(),
|
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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,154 +1,180 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart';
|
import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
import '../../../core/providers/bt_provider.dart';
|
import '../../../core/providers/bt_provider.dart';
|
||||||
import '../../widgets/main_nav.dart';
|
import '../../widgets/main_nav.dart';
|
||||||
|
import '../../../core/logging/app_log.dart';
|
||||||
class BtPickerScreen extends ConsumerStatefulWidget {
|
|
||||||
const BtPickerScreen({super.key});
|
class BtPickerScreen extends ConsumerStatefulWidget {
|
||||||
|
const BtPickerScreen({super.key});
|
||||||
@override
|
|
||||||
ConsumerState<BtPickerScreen> createState() => _BtPickerScreenState();
|
@override
|
||||||
}
|
ConsumerState<BtPickerScreen> createState() => _BtPickerScreenState();
|
||||||
|
}
|
||||||
class _BtPickerScreenState extends ConsumerState<BtPickerScreen> {
|
|
||||||
String? _error;
|
class _BtPickerScreenState extends ConsumerState<BtPickerScreen> {
|
||||||
bool _initDone = false;
|
String? _error;
|
||||||
|
bool _initDone = false;
|
||||||
@override
|
|
||||||
void initState() {
|
@override
|
||||||
super.initState();
|
void initState() {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) => _initBluetooth());
|
super.initState();
|
||||||
}
|
WidgetsBinding.instance.addPostFrameCallback((_) => _initBluetooth());
|
||||||
|
}
|
||||||
Future<void> _initBluetooth() async {
|
|
||||||
setState(() => _error = null);
|
bool _permissionsOk = false;
|
||||||
|
|
||||||
final statuses = await [
|
Future<void> _initBluetooth() async {
|
||||||
Permission.bluetoothConnect,
|
setState(() {
|
||||||
Permission.bluetoothScan,
|
_error = null;
|
||||||
Permission.locationWhenInUse,
|
_initDone = false;
|
||||||
].request();
|
});
|
||||||
|
|
||||||
final denied = statuses.entries
|
try {
|
||||||
.where((e) => e.value.isDenied || e.value.isPermanentlyDenied)
|
// Android 12+: BLUETOOTH_CONNECT is the only permission needed to list
|
||||||
.map((e) => e.key.toString())
|
// paired devices and open an RFCOMM socket. On Android 11 and older
|
||||||
.toList();
|
// permission_handler reports it as granted. Scan/location are requested
|
||||||
|
// but not required (we never run discovery).
|
||||||
if (denied.isNotEmpty) {
|
final statuses = await [
|
||||||
setState(() {
|
Permission.bluetoothConnect,
|
||||||
_error = 'Permissions denied: ${denied.join(", ")}\n\n'
|
Permission.bluetoothScan,
|
||||||
'Go to Settings → Apps → HV BT Dashboard → Permissions.';
|
Permission.locationWhenInUse,
|
||||||
_initDone = true;
|
].request();
|
||||||
});
|
|
||||||
return;
|
final connect = statuses[Permission.bluetoothConnect];
|
||||||
}
|
if (connect != null && !connect.isGranted && !connect.isLimited) {
|
||||||
|
AppLog.w('Bluetooth permission not granted: $connect');
|
||||||
final btState = await FlutterBluetoothSerial.instance.state;
|
if (!mounted) return;
|
||||||
if (btState != BluetoothState.STATE_ON) {
|
setState(() {
|
||||||
await FlutterBluetoothSerial.instance.requestEnable();
|
_permissionsOk = false;
|
||||||
await Future<void>.delayed(const Duration(seconds: 1));
|
_error = 'Bluetooth permission ("Nearby devices") was not granted.\n\n'
|
||||||
}
|
'Go to Settings → Apps → HV BT Dashboard → Permissions.';
|
||||||
|
_initDone = true;
|
||||||
setState(() => _initDone = true);
|
});
|
||||||
ref.invalidate(pairedDevicesProvider);
|
if (connect.isPermanentlyDenied) await openAppSettings();
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
// ECU protocol is chosen once via Settings (or defaults to S300) and
|
_permissionsOk = true;
|
||||||
// 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,
|
final btState = await FlutterBluetoothSerial.instance.state;
|
||||||
// which was pure friction once the user had already told the app which
|
if (btState != BluetoothState.STATE_ON) {
|
||||||
// ECU they have.
|
await FlutterBluetoothSerial.instance.requestEnable();
|
||||||
Future<void> _connect(BluetoothDevice device) async {
|
await Future<void>.delayed(const Duration(seconds: 1));
|
||||||
await ref.read(btProvider.notifier).connect(device);
|
}
|
||||||
if (!mounted) return;
|
} catch (e, st) {
|
||||||
if (ref.read(btProvider).isConnected) {
|
AppLog.e('Bluetooth init failed', e, st);
|
||||||
Navigator.of(context).pushReplacement(
|
if (!mounted) return;
|
||||||
MaterialPageRoute(builder: (_) => const MainNav()),
|
setState(() => _error = 'Bluetooth init failed: $e');
|
||||||
);
|
}
|
||||||
}
|
|
||||||
}
|
if (!mounted) return;
|
||||||
|
setState(() => _initDone = true);
|
||||||
@override
|
if (_permissionsOk) ref.invalidate(pairedDevicesProvider);
|
||||||
Widget build(BuildContext context) {
|
}
|
||||||
final btState = ref.watch(btProvider);
|
|
||||||
final devicesAsync = ref.watch(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
|
||||||
return Scaffold(
|
// dialog here used to re-prompt "S300 or KPro?" on every single tap,
|
||||||
appBar: AppBar(
|
// which was pure friction once the user had already told the app which
|
||||||
title: const Text('Connect to ECU'),
|
// ECU they have.
|
||||||
actions: [
|
Future<void> _connect(BluetoothDevice device) async {
|
||||||
IconButton(
|
if (ref.read(btProvider).isConnecting) return;
|
||||||
icon: const Icon(Icons.refresh),
|
await ref.read(btProvider.notifier).connect(device);
|
||||||
onPressed: _loading ? null : _initBluetooth,
|
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.
|
||||||
body: Column(
|
Navigator.of(context).pushAndRemoveUntil(
|
||||||
children: [
|
MaterialPageRoute(builder: (_) => const MainNav()),
|
||||||
if (btState.status == BtStatus.error)
|
(_) => false,
|
||||||
_banner('Connection failed: ${btState.error}'),
|
);
|
||||||
if (_error != null) _banner(_error!),
|
}
|
||||||
Expanded(
|
}
|
||||||
child: !_initDone
|
|
||||||
? const Center(child: CircularProgressIndicator())
|
@override
|
||||||
: devicesAsync.when(
|
Widget build(BuildContext context) {
|
||||||
loading: () => const Center(
|
final btState = ref.watch(btProvider);
|
||||||
child: CircularProgressIndicator()),
|
// Do NOT touch the paired-device list before permission is granted:
|
||||||
error: (e, _) =>
|
// on Android 12+ that call throws inside the Bluetooth plugin.
|
||||||
Center(child: _banner('Failed to load: $e')),
|
final AsyncValue<List<BluetoothDevice>> devicesAsync = _permissionsOk
|
||||||
data: (devices) => devices.isEmpty
|
? ref.watch(pairedDevicesProvider)
|
||||||
? const Center(
|
: const AsyncValue<List<BluetoothDevice>>.data([]);
|
||||||
child: Padding(
|
|
||||||
padding: EdgeInsets.all(24),
|
return Scaffold(
|
||||||
child: Text(
|
appBar: AppBar(
|
||||||
'No paired BT devices.\nPair your ECU in Android Settings first.',
|
title: const Text('Connect to ECU'),
|
||||||
textAlign: TextAlign.center,
|
actions: [
|
||||||
style: TextStyle(color: Colors.grey),
|
IconButton(
|
||||||
),
|
icon: const Icon(Icons.refresh),
|
||||||
),
|
onPressed: _loading ? null : _initBluetooth,
|
||||||
)
|
),
|
||||||
: ListView.separated(
|
],
|
||||||
itemCount: devices.length,
|
),
|
||||||
separatorBuilder: (_, __) =>
|
body: Column(
|
||||||
const Divider(height: 1),
|
children: [
|
||||||
itemBuilder: (_, i) {
|
if (btState.status == BtStatus.error)
|
||||||
final d = devices[i];
|
_banner('Connection failed: ${btState.error}'),
|
||||||
final connecting = btState.isConnecting &&
|
if (_error != null) _banner(_error!),
|
||||||
btState.deviceName ==
|
Expanded(
|
||||||
(d.name ?? d.address);
|
child: !_initDone
|
||||||
return ListTile(
|
? const Center(child: CircularProgressIndicator())
|
||||||
leading: const Icon(Icons.bluetooth),
|
: devicesAsync.when(
|
||||||
title: Text(d.name ?? 'Unknown'),
|
loading: () => const Center(
|
||||||
subtitle: Text(d.address),
|
child: CircularProgressIndicator()),
|
||||||
trailing: connecting
|
error: (e, _) =>
|
||||||
? const SizedBox(
|
Center(child: _banner('Failed to load: $e')),
|
||||||
width: 20,
|
data: (devices) => devices.isEmpty
|
||||||
height: 20,
|
? const Center(
|
||||||
child: CircularProgressIndicator(
|
child: Padding(
|
||||||
strokeWidth: 2))
|
padding: EdgeInsets.all(24),
|
||||||
: const Icon(
|
child: Text(
|
||||||
Icons.arrow_forward_ios,
|
'No paired BT devices.\nPair your ECU in Android Settings first.',
|
||||||
size: 14),
|
textAlign: TextAlign.center,
|
||||||
onTap: connecting ? null : () => _connect(d),
|
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 &&
|
||||||
bool get _loading => !_initDone || ref.read(btProvider).isConnecting;
|
btState.deviceName ==
|
||||||
|
(d.name ?? d.address);
|
||||||
Widget _banner(String msg) => Container(
|
return ListTile(
|
||||||
width: double.infinity,
|
leading: const Icon(Icons.bluetooth),
|
||||||
color: const Color(0xFF4A0000),
|
title: Text(d.name ?? 'Unknown'),
|
||||||
padding: const EdgeInsets.all(12),
|
subtitle: Text(d.address),
|
||||||
child: Text(msg,
|
trailing: connecting
|
||||||
style: const TextStyle(color: Colors.redAccent, fontSize: 12)),
|
? 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,113 +1,82 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../../core/providers/sensor_provider.dart';
|
import '../../../core/providers/sensor_provider.dart';
|
||||||
import '../../../core/providers/theme_provider.dart';
|
import '../../../core/providers/settings_provider.dart';
|
||||||
import '../../../ui/theme/app_colors.dart';
|
import '../../theme/app_colors.dart';
|
||||||
import 'widgets/analog_gauge.dart';
|
import 'dashboard_slots_provider.dart';
|
||||||
import 'widgets/flag_pill.dart';
|
import 'widgets/dashboard_common.dart';
|
||||||
import 'widgets/sensor_tile.dart';
|
import 'widgets/flag_pill.dart';
|
||||||
import 'widgets/sensor_picker_sheet.dart';
|
import 'widgets/sensor_tile.dart';
|
||||||
import 'dashboard_slots_provider.dart';
|
|
||||||
|
/// Landscape layout (requirements): main + sub sensor in the centre,
|
||||||
class DashboardHorizontal extends ConsumerWidget {
|
/// 5 values left, 5 values right, 4 flags along the bottom.
|
||||||
const DashboardHorizontal({super.key});
|
class DashboardHorizontal extends ConsumerWidget {
|
||||||
|
const DashboardHorizontal({super.key});
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
@override
|
||||||
final state = ref.watch(latestSensorProvider);
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final themeVariant = ref.watch(themeProvider);
|
final c = AppColors.of(context);
|
||||||
final colors = AppColors.forTheme(themeVariant);
|
final state = ref.watch(latestSensorProvider);
|
||||||
final slots = ref.watch(dashboardSlotsProvider);
|
final config = ref.watch(dashboardConfigProvider);
|
||||||
|
final units = ref.watch(unitPrefsProvider);
|
||||||
return Container(
|
|
||||||
color: const Color(0xFF0A0A0A),
|
Widget column(int from) => SizedBox(
|
||||||
padding: const EdgeInsets.all(8),
|
width: 190,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
for (int i = from; i < from + 5; i++)
|
||||||
child: Row(
|
Expanded(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
child: Padding(
|
||||||
children: [
|
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||||
// Left tiles (slots 0-2)
|
child: SensorTile(
|
||||||
SizedBox(
|
sensorId: config.values[i],
|
||||||
width: 140,
|
state: state,
|
||||||
child: Column(
|
units: units,
|
||||||
children: [
|
onLongPress: () => editDashboardValue(context, ref, i),
|
||||||
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,
|
return Container(
|
||||||
accentColor: colors.accent,
|
color: c.background,
|
||||||
onTap: () => _pickSensor(context, ref, i),
|
// 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(
|
||||||
const SizedBox(width: 8),
|
children: [
|
||||||
// Center gauge
|
Expanded(
|
||||||
Expanded(
|
child: Row(
|
||||||
child: Center(
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
child: AnalogGauge(
|
children: [
|
||||||
value: state.rpm,
|
column(0),
|
||||||
minValue: 0,
|
const SizedBox(width: 8),
|
||||||
maxValue: 9000,
|
Expanded(
|
||||||
label: 'RPM',
|
child: Center(child: MainGauge(state: state)),
|
||||||
unit: 'rpm',
|
),
|
||||||
accentColor: colors.accent,
|
const SizedBox(width: 8),
|
||||||
),
|
column(5),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
),
|
||||||
// Right tiles (slots 3-5)
|
const SizedBox(height: 6),
|
||||||
SizedBox(
|
Padding(
|
||||||
width: 140,
|
padding: const EdgeInsets.symmetric(horizontal: 198),
|
||||||
child: Column(
|
child: FlagRow(
|
||||||
children: [
|
state: state,
|
||||||
for (int i = 3; i < 6; i++)
|
flagIds: config.flags,
|
||||||
Expanded(
|
onLongPress: (i) => editDashboardFlag(context, ref, i),
|
||||||
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/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'dashboard_horizontal.dart';
|
import 'dashboard_horizontal.dart';
|
||||||
import 'dashboard_vertical.dart';
|
import 'dashboard_vertical.dart';
|
||||||
|
|
||||||
class DashboardScreen extends ConsumerWidget {
|
class DashboardScreen extends ConsumerWidget {
|
||||||
const DashboardScreen({super.key});
|
const DashboardScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
return OrientationBuilder(
|
return OrientationBuilder(
|
||||||
builder: (context, orientation) {
|
builder: (context, orientation) {
|
||||||
return orientation == Orientation.landscape
|
return orientation == Orientation.landscape
|
||||||
? const DashboardHorizontal()
|
? const DashboardHorizontal()
|
||||||
: const DashboardVertical();
|
: const DashboardVertical();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,19 +1,125 @@
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
// Default sensor IDs for the 6 dashboard tile slots
|
|
||||||
const _defaultSlots = ['vss', 'map', 'tps', 'ect', 'iat', 'bat'];
|
import '../../../core/models/sensor_defs.dart';
|
||||||
|
|
||||||
class DashboardSlotsNotifier extends StateNotifier<List<String>> {
|
/// What the dashboard shows, per the requirement document: a selectable
|
||||||
DashboardSlotsNotifier() : super(List.from(_defaultSlots));
|
/// main sensor (gauge) + sub sensor, 10 selectable values (landscape: 5 left
|
||||||
|
/// + 5 right; portrait: 2×5) and 4 selectable flags. Persisted.
|
||||||
void setSlot(int index, String sensorId) {
|
class DashboardConfig {
|
||||||
final next = List<String>.from(state);
|
static const valueCount = 10;
|
||||||
next[index] = sensorId;
|
static const flagCount = 4;
|
||||||
state = next;
|
|
||||||
}
|
final String main;
|
||||||
}
|
final String sub;
|
||||||
|
final List<String> values;
|
||||||
final dashboardSlotsProvider =
|
final List<String> flags;
|
||||||
StateNotifierProvider<DashboardSlotsNotifier, List<String>>(
|
|
||||||
(ref) => DashboardSlotsNotifier(),
|
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(),
|
||||||
|
);
|
||||||
|
|||||||
@ -1,81 +1,94 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'dart:math' as math;
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
import '../../../core/providers/sensor_provider.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../../core/providers/theme_provider.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../../ui/theme/app_colors.dart';
|
import '../../../core/providers/sensor_provider.dart';
|
||||||
import 'widgets/analog_gauge.dart';
|
import '../../../core/providers/settings_provider.dart';
|
||||||
import 'widgets/flag_pill.dart';
|
import '../../theme/app_colors.dart';
|
||||||
import 'widgets/sensor_tile.dart';
|
import 'dashboard_slots_provider.dart';
|
||||||
import 'widgets/sensor_picker_sheet.dart';
|
import 'widgets/dashboard_common.dart';
|
||||||
import 'dashboard_slots_provider.dart';
|
import 'widgets/flag_pill.dart';
|
||||||
|
import 'widgets/sensor_tile.dart';
|
||||||
class DashboardVertical extends ConsumerWidget {
|
|
||||||
const DashboardVertical({super.key});
|
/// 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
|
||||||
@override
|
/// height the flags and values leave.
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
class DashboardVertical extends ConsumerWidget {
|
||||||
final state = ref.watch(latestSensorProvider);
|
const DashboardVertical({super.key});
|
||||||
final themeVariant = ref.watch(themeProvider);
|
|
||||||
final colors = AppColors.forTheme(themeVariant);
|
static const _tileHeight = 64.0;
|
||||||
final slots = ref.watch(dashboardSlotsProvider);
|
static const _gap = 6.0;
|
||||||
|
static const _flagsHeight = 30.0 + 2 * _gap;
|
||||||
return Container(
|
|
||||||
color: const Color(0xFF0A0A0A),
|
@override
|
||||||
child: SingleChildScrollView(
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
child: Column(
|
final c = AppColors.of(context);
|
||||||
children: [
|
final state = ref.watch(latestSensorProvider);
|
||||||
Padding(
|
final config = ref.watch(dashboardConfigProvider);
|
||||||
padding: const EdgeInsets.fromLTRB(24, 8, 24, 0),
|
final units = ref.watch(unitPrefsProvider);
|
||||||
child: AnalogGauge(
|
|
||||||
value: state.rpm,
|
final tiles = Padding(
|
||||||
minValue: 0,
|
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
|
||||||
maxValue: 9000,
|
child: Column(
|
||||||
label: 'RPM',
|
children: [
|
||||||
unit: 'rpm',
|
// Left column is values 0-4, right column 5-9, matching landscape.
|
||||||
accentColor: colors.accent,
|
for (int row = 0; row < 5; row++)
|
||||||
),
|
Padding(
|
||||||
),
|
padding: EdgeInsets.only(top: row == 0 ? 0 : _gap),
|
||||||
Padding(
|
child: SizedBox(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12),
|
height: _tileHeight,
|
||||||
child: FlagRow(state: state, accentColor: colors.accent),
|
child: Row(
|
||||||
),
|
children: [
|
||||||
Padding(
|
for (final i in [row, row + 5]) ...[
|
||||||
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
|
if (i != row) const SizedBox(width: _gap),
|
||||||
child: GridView.count(
|
Expanded(
|
||||||
shrinkWrap: true,
|
child: SensorTile(
|
||||||
crossAxisCount: 2,
|
sensorId: config.values[i],
|
||||||
mainAxisSpacing: 6,
|
state: state,
|
||||||
crossAxisSpacing: 6,
|
units: units,
|
||||||
childAspectRatio: 2.4,
|
onLongPress: () =>
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
editDashboardValue(context, ref, i),
|
||||||
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),
|
],
|
||||||
),
|
),
|
||||||
],
|
);
|
||||||
),
|
|
||||||
),
|
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),
|
||||||
void _pickSensor(BuildContext context, WidgetRef ref, int slotIndex) {
|
);
|
||||||
showModalBottomSheet<void>(
|
// Scrolls only if the screen is too short even for a small gauge.
|
||||||
context: context,
|
return SingleChildScrollView(
|
||||||
builder: (_) => SensorPickerSheet(
|
child: Column(
|
||||||
currentId: ref.read(dashboardSlotsProvider)[slotIndex],
|
children: [
|
||||||
onSelected: (id) {
|
SizedBox(
|
||||||
ref.read(dashboardSlotsProvider.notifier).setSlot(slotIndex, id);
|
height: gaugeSize,
|
||||||
Navigator.pop(context);
|
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,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,263 +1,343 @@
|
|||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
class AnalogGauge extends StatefulWidget {
|
class AnalogGauge extends StatefulWidget {
|
||||||
final double value;
|
final double value;
|
||||||
final double minValue;
|
final double minValue;
|
||||||
final double maxValue;
|
final double maxValue;
|
||||||
final String label;
|
final String label;
|
||||||
final String unit;
|
final String unit;
|
||||||
final Color accentColor;
|
final Color accentColor;
|
||||||
|
/// Formatted main value; defaults to [value] with no decimals.
|
||||||
const AnalogGauge({
|
final String? valueText;
|
||||||
super.key,
|
/// Second line under the unit, e.g. the sub sensor "120 km/h".
|
||||||
required this.value,
|
final String? subText;
|
||||||
required this.minValue,
|
final Color foreground;
|
||||||
required this.maxValue,
|
final Color muted;
|
||||||
required this.label,
|
final Color track;
|
||||||
required this.unit,
|
final Color background;
|
||||||
required this.accentColor,
|
|
||||||
});
|
const AnalogGauge({
|
||||||
|
super.key,
|
||||||
@override
|
required this.value,
|
||||||
State<AnalogGauge> createState() => _AnalogGaugeState();
|
required this.minValue,
|
||||||
}
|
required this.maxValue,
|
||||||
|
required this.label,
|
||||||
class _AnalogGaugeState extends State<AnalogGauge>
|
required this.unit,
|
||||||
with SingleTickerProviderStateMixin {
|
required this.accentColor,
|
||||||
late AnimationController _ctrl;
|
this.valueText,
|
||||||
late Animation<double> _anim;
|
this.subText,
|
||||||
|
this.foreground = Colors.white,
|
||||||
double get _normalized =>
|
this.muted = Colors.white54,
|
||||||
((widget.value - widget.minValue) /
|
this.track = const Color(0xFF2A2A2A),
|
||||||
(widget.maxValue - widget.minValue))
|
this.background = const Color(0xFF0A0A0A),
|
||||||
.clamp(0.0, 1.0);
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
State<AnalogGauge> createState() => _AnalogGaugeState();
|
||||||
super.initState();
|
}
|
||||||
_ctrl = AnimationController(
|
|
||||||
vsync: this,
|
class _AnalogGaugeState extends State<AnalogGauge>
|
||||||
duration: const Duration(milliseconds: 150),
|
with SingleTickerProviderStateMixin {
|
||||||
);
|
late AnimationController _ctrl;
|
||||||
_anim = Tween<double>(begin: 0, end: _normalized)
|
late Animation<double> _anim;
|
||||||
.animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOut));
|
|
||||||
_ctrl.forward();
|
double get _normalized =>
|
||||||
}
|
((widget.value - widget.minValue) /
|
||||||
|
(widget.maxValue - widget.minValue))
|
||||||
@override
|
.clamp(0.0, 1.0);
|
||||||
void didUpdateWidget(AnalogGauge old) {
|
|
||||||
super.didUpdateWidget(old);
|
@override
|
||||||
if (old.value != widget.value) {
|
void initState() {
|
||||||
_anim = Tween<double>(begin: _anim.value, end: _normalized)
|
super.initState();
|
||||||
.animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOut));
|
_ctrl = AnimationController(
|
||||||
_ctrl.forward(from: 0);
|
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).
|
||||||
@override
|
duration: const Duration(milliseconds: 110),
|
||||||
void dispose() {
|
);
|
||||||
_ctrl.dispose();
|
_anim = Tween<double>(begin: 0, end: _normalized)
|
||||||
super.dispose();
|
.animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOut));
|
||||||
}
|
_ctrl.forward();
|
||||||
|
}
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
@override
|
||||||
return AspectRatio(
|
void didUpdateWidget(AnalogGauge old) {
|
||||||
aspectRatio: 1,
|
super.didUpdateWidget(old);
|
||||||
child: AnimatedBuilder(
|
if (old.value != widget.value) {
|
||||||
animation: _anim,
|
_anim = Tween<double>(begin: _anim.value, end: _normalized)
|
||||||
builder: (_, __) => CustomPaint(
|
.animate(CurvedAnimation(parent: _ctrl, curve: Curves.linear));
|
||||||
painter: _GaugePainter(
|
_ctrl.forward(from: 0);
|
||||||
normalizedValue: _anim.value,
|
}
|
||||||
accentColor: widget.accentColor,
|
}
|
||||||
displayValue: widget.value,
|
|
||||||
unit: widget.unit,
|
@override
|
||||||
label: widget.label,
|
void dispose() {
|
||||||
),
|
_ctrl.dispose();
|
||||||
),
|
super.dispose();
|
||||||
),
|
}
|
||||||
);
|
|
||||||
}
|
@override
|
||||||
}
|
Widget build(BuildContext context) {
|
||||||
|
return AspectRatio(
|
||||||
class _GaugePainter extends CustomPainter {
|
aspectRatio: 1,
|
||||||
final double normalizedValue; // 0.0–1.0
|
child: AnimatedBuilder(
|
||||||
final Color accentColor;
|
animation: _anim,
|
||||||
final double displayValue;
|
builder: (_, __) => CustomPaint(
|
||||||
final String unit;
|
painter: _GaugePainter(
|
||||||
final String label;
|
normalizedValue: _anim.value,
|
||||||
|
accentColor: widget.accentColor,
|
||||||
static const double _startAngle = 135 * pi / 180; // 7:30 position
|
valueText: widget.valueText ?? widget.value.toStringAsFixed(0),
|
||||||
static const double _sweepAngle = 270 * pi / 180; // 270° sweep
|
subText: widget.subText,
|
||||||
|
unit: widget.unit,
|
||||||
_GaugePainter({
|
label: widget.label,
|
||||||
required this.normalizedValue,
|
foreground: widget.foreground,
|
||||||
required this.accentColor,
|
muted: widget.muted,
|
||||||
required this.displayValue,
|
track: widget.track,
|
||||||
required this.unit,
|
background: widget.background,
|
||||||
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);
|
class _GaugePainter extends CustomPainter {
|
||||||
|
final double normalizedValue; // 0.0–1.0
|
||||||
// ── Background arc ──────────────────────────────────────────────
|
final Color accentColor;
|
||||||
final bgPaint = Paint()
|
final String valueText;
|
||||||
..color = const Color(0xFF2A2A2A)
|
final String? subText;
|
||||||
..style = PaintingStyle.stroke
|
final String unit;
|
||||||
..strokeWidth = size.width * 0.06
|
final String label;
|
||||||
..strokeCap = StrokeCap.round;
|
final Color foreground;
|
||||||
canvas.drawArc(rect, _startAngle, _sweepAngle, false, bgPaint);
|
final Color muted;
|
||||||
|
final Color track;
|
||||||
// ── Red zone (last 20%) ──────────────────────────────────────────
|
final Color background;
|
||||||
final redPaint = Paint()
|
|
||||||
..color = Colors.red.withAlpha(120)
|
static const double _startAngle = 135 * pi / 180; // 7:30 position
|
||||||
..style = PaintingStyle.stroke
|
static const double _sweepAngle = 270 * pi / 180; // 270° sweep
|
||||||
..strokeWidth = size.width * 0.06
|
|
||||||
..strokeCap = StrokeCap.round;
|
_GaugePainter({
|
||||||
canvas.drawArc(
|
required this.normalizedValue,
|
||||||
rect,
|
required this.accentColor,
|
||||||
_startAngle + _sweepAngle * 0.8,
|
required this.valueText,
|
||||||
_sweepAngle * 0.2,
|
required this.subText,
|
||||||
false,
|
required this.unit,
|
||||||
redPaint,
|
required this.label,
|
||||||
);
|
required this.foreground,
|
||||||
|
required this.muted,
|
||||||
// ── Value arc ────────────────────────────────────────────────────
|
required this.track,
|
||||||
if (normalizedValue > 0) {
|
required this.background,
|
||||||
final isRedZone = normalizedValue > 0.8;
|
});
|
||||||
final valPaint = Paint()
|
|
||||||
..color = isRedZone ? Colors.red : accentColor
|
@override
|
||||||
..style = PaintingStyle.stroke
|
void paint(Canvas canvas, Size size) {
|
||||||
..strokeWidth = size.width * 0.06
|
final center = Offset(size.width / 2, size.height / 2);
|
||||||
..strokeCap = StrokeCap.round;
|
final radius = size.width * 0.42;
|
||||||
canvas.drawArc(
|
final rect = Rect.fromCircle(center: center, radius: radius);
|
||||||
rect,
|
|
||||||
_startAngle,
|
// ── Background arc ──────────────────────────────────────────────
|
||||||
_sweepAngle * normalizedValue,
|
final bgPaint = Paint()
|
||||||
false,
|
..color = track
|
||||||
valPaint,
|
..style = PaintingStyle.stroke
|
||||||
);
|
..strokeWidth = size.width * 0.06
|
||||||
}
|
..strokeCap = StrokeCap.round;
|
||||||
|
canvas.drawArc(rect, _startAngle, _sweepAngle, false, bgPaint);
|
||||||
// ── Tick marks ───────────────────────────────────────────────────
|
|
||||||
_drawTicks(canvas, center, radius, size);
|
// ── Red zone (last 20%) ──────────────────────────────────────────
|
||||||
|
final redPaint = Paint()
|
||||||
// ── Needle ────────────────────────────────────────────────────────
|
..color = Colors.red.withAlpha(120)
|
||||||
_drawNeedle(canvas, center, radius, size);
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = size.width * 0.06
|
||||||
// ── Center text ──────────────────────────────────────────────────
|
..strokeCap = StrokeCap.round;
|
||||||
_drawCenterText(canvas, center, size);
|
canvas.drawArc(
|
||||||
}
|
rect,
|
||||||
|
_startAngle + _sweepAngle * 0.8,
|
||||||
void _drawTicks(Canvas canvas, Offset center, double radius, Size size) {
|
_sweepAngle * 0.2,
|
||||||
final majorPaint = Paint()
|
false,
|
||||||
..color = Colors.white54
|
redPaint,
|
||||||
..strokeWidth = size.width * 0.012
|
);
|
||||||
..strokeCap = StrokeCap.round;
|
|
||||||
final minorPaint = Paint()
|
// ── Value arc ────────────────────────────────────────────────────
|
||||||
..color = Colors.white24
|
if (normalizedValue > 0) {
|
||||||
..strokeWidth = size.width * 0.006
|
final isRedZone = normalizedValue > 0.8;
|
||||||
..strokeCap = StrokeCap.round;
|
final valPaint = Paint()
|
||||||
|
..color = isRedZone ? Colors.red : accentColor
|
||||||
const totalTicks = 50; // 2% each
|
..style = PaintingStyle.stroke
|
||||||
for (int i = 0; i <= totalTicks; i++) {
|
..strokeWidth = size.width * 0.06
|
||||||
final t = i / totalTicks;
|
..strokeCap = StrokeCap.round;
|
||||||
final angle = _startAngle + _sweepAngle * t;
|
canvas.drawArc(
|
||||||
final isMajor = i % 5 == 0;
|
rect,
|
||||||
final paint = isMajor ? majorPaint : minorPaint;
|
_startAngle,
|
||||||
final outerR = radius - size.width * 0.01;
|
_sweepAngle * normalizedValue,
|
||||||
final innerR = outerR - (isMajor ? size.width * 0.06 : size.width * 0.03);
|
false,
|
||||||
canvas.drawLine(
|
valPaint,
|
||||||
center + Offset(cos(angle) * innerR, sin(angle) * innerR),
|
);
|
||||||
center + Offset(cos(angle) * outerR, sin(angle) * outerR),
|
}
|
||||||
paint,
|
|
||||||
);
|
// ── Tick marks ───────────────────────────────────────────────────
|
||||||
}
|
_drawTicks(canvas, center, radius, size);
|
||||||
}
|
|
||||||
|
// ── Needle ────────────────────────────────────────────────────────
|
||||||
void _drawNeedle(Canvas canvas, Offset center, double radius, Size size) {
|
_drawNeedle(canvas, center, radius, size);
|
||||||
final angle = _startAngle + _sweepAngle * normalizedValue;
|
|
||||||
final tipR = radius - size.width * 0.08;
|
// ── Center text ──────────────────────────────────────────────────
|
||||||
final tailR = size.width * 0.08;
|
_drawCenterText(canvas, center, size);
|
||||||
|
}
|
||||||
final needlePaint = Paint()
|
|
||||||
..color = Colors.white
|
void _drawTicks(Canvas canvas, Offset center, double radius, Size size) {
|
||||||
..strokeWidth = size.width * 0.018
|
final majorPaint = Paint()
|
||||||
..strokeCap = StrokeCap.round;
|
..color = muted
|
||||||
|
..strokeWidth = size.width * 0.012
|
||||||
canvas.drawLine(
|
..strokeCap = StrokeCap.round;
|
||||||
center - Offset(cos(angle) * tailR, sin(angle) * tailR),
|
final minorPaint = Paint()
|
||||||
center + Offset(cos(angle) * tipR, sin(angle) * tipR),
|
..color = muted.withAlpha(90)
|
||||||
needlePaint,
|
..strokeWidth = size.width * 0.006
|
||||||
);
|
..strokeCap = StrokeCap.round;
|
||||||
|
|
||||||
// Center dot
|
const totalTicks = 50; // 2% each
|
||||||
canvas.drawCircle(
|
for (int i = 0; i <= totalTicks; i++) {
|
||||||
center,
|
final t = i / totalTicks;
|
||||||
size.width * 0.04,
|
final angle = _startAngle + _sweepAngle * t;
|
||||||
Paint()..color = Colors.white,
|
final isMajor = i % 5 == 0;
|
||||||
);
|
final paint = isMajor ? majorPaint : minorPaint;
|
||||||
canvas.drawCircle(
|
final outerR = radius - size.width * 0.01;
|
||||||
center,
|
final innerR = outerR - (isMajor ? size.width * 0.06 : size.width * 0.03);
|
||||||
size.width * 0.025,
|
canvas.drawLine(
|
||||||
Paint()..color = const Color(0xFF0A0A0A),
|
center + Offset(cos(angle) * innerR, sin(angle) * innerR),
|
||||||
);
|
center + Offset(cos(angle) * outerR, sin(angle) * outerR),
|
||||||
}
|
paint,
|
||||||
|
);
|
||||||
void _drawCenterText(Canvas canvas, Offset center, Size size) {
|
}
|
||||||
// Value
|
}
|
||||||
final valSpan = TextSpan(
|
|
||||||
text: displayValue.toStringAsFixed(0),
|
void _drawNeedle(Canvas canvas, Offset center, double radius, Size size) {
|
||||||
style: TextStyle(
|
final angle = _startAngle + _sweepAngle * normalizedValue;
|
||||||
color: Colors.white,
|
final tipR = radius - size.width * 0.08;
|
||||||
fontSize: size.width * 0.14,
|
final tailR = size.width * 0.08;
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
letterSpacing: -1,
|
final needlePaint = Paint()
|
||||||
),
|
..color = foreground
|
||||||
);
|
..strokeWidth = size.width * 0.018
|
||||||
final valPainter = TextPainter(
|
..strokeCap = StrokeCap.round;
|
||||||
text: valSpan,
|
|
||||||
textDirection: TextDirection.ltr,
|
canvas.drawLine(
|
||||||
)..layout();
|
center - Offset(cos(angle) * tailR, sin(angle) * tailR),
|
||||||
valPainter.paint(
|
center + Offset(cos(angle) * tipR, sin(angle) * tipR),
|
||||||
canvas,
|
needlePaint,
|
||||||
center +
|
);
|
||||||
Offset(
|
|
||||||
-valPainter.width / 2,
|
// Center dot
|
||||||
size.width * 0.1,
|
canvas.drawCircle(
|
||||||
),
|
center,
|
||||||
);
|
size.width * 0.04,
|
||||||
|
Paint()..color = foreground,
|
||||||
// Unit
|
);
|
||||||
final unitSpan = TextSpan(
|
canvas.drawCircle(
|
||||||
text: unit,
|
center,
|
||||||
style: TextStyle(
|
size.width * 0.025,
|
||||||
color: Colors.white54,
|
Paint()..color = background,
|
||||||
fontSize: size.width * 0.07,
|
);
|
||||||
),
|
}
|
||||||
);
|
|
||||||
final unitPainter = TextPainter(
|
void _drawCenterText(Canvas canvas, Offset center, Size size) {
|
||||||
text: unitSpan,
|
// Value
|
||||||
textDirection: TextDirection.ltr,
|
final valSpan = TextSpan(
|
||||||
)..layout();
|
text: valueText,
|
||||||
unitPainter.paint(
|
style: TextStyle(
|
||||||
canvas,
|
color: foreground,
|
||||||
center +
|
fontSize: size.width * 0.14,
|
||||||
Offset(
|
fontWeight: FontWeight.bold,
|
||||||
-unitPainter.width / 2,
|
letterSpacing: -1,
|
||||||
size.width * 0.1 + valPainter.height + 2,
|
),
|
||||||
),
|
);
|
||||||
);
|
final valPainter = TextPainter(
|
||||||
}
|
text: valSpan,
|
||||||
|
textDirection: TextDirection.ltr,
|
||||||
@override
|
)..layout();
|
||||||
bool shouldRepaint(_GaugePainter old) =>
|
valPainter.paint(
|
||||||
old.normalizedValue != normalizedValue ||
|
canvas,
|
||||||
old.accentColor != accentColor ||
|
center +
|
||||||
old.displayValue != displayValue;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
95
lib/ui/screens/dashboard/widgets/dashboard_common.dart
Normal file
95
lib/ui/screens/dashboard/widgets/dashboard_common.dart
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
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,66 +1,106 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../../../core/protocol/sensor_state.dart';
|
import '../../../../core/models/sensor_defs.dart';
|
||||||
|
import '../../../../core/protocol/sensor_state.dart';
|
||||||
class FlagPill extends StatelessWidget {
|
import '../../../theme/app_colors.dart';
|
||||||
final String label;
|
|
||||||
final bool active;
|
/// Colour of an active flag. Faults red, warnings amber, status green/blue.
|
||||||
final Color color;
|
Color flagColor(String id) {
|
||||||
|
switch (id) {
|
||||||
const FlagPill({
|
case 'mil':
|
||||||
super.key,
|
case 'knock':
|
||||||
required this.label,
|
case 'o2error':
|
||||||
required this.active,
|
case 'iaterror':
|
||||||
required this.color,
|
case 'ecterror':
|
||||||
});
|
return Colors.red;
|
||||||
|
case 'revlimit':
|
||||||
@override
|
case 'igncut':
|
||||||
Widget build(BuildContext context) {
|
case 'boostcut':
|
||||||
return AnimatedContainer(
|
case 'launch':
|
||||||
duration: const Duration(milliseconds: 100),
|
case 'launchretard':
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
case 'shiftcut':
|
||||||
decoration: BoxDecoration(
|
return Colors.orange;
|
||||||
color: active ? color.withAlpha(40) : const Color(0xFF1A1A1A),
|
case 'vtec':
|
||||||
borderRadius: BorderRadius.circular(20),
|
case 'vtp':
|
||||||
border: Border.all(
|
return Colors.blue;
|
||||||
color: active ? color : Colors.white12,
|
default:
|
||||||
width: 1.5,
|
return Colors.green;
|
||||||
),
|
}
|
||||||
),
|
}
|
||||||
child: Text(
|
|
||||||
label,
|
class FlagPill extends StatelessWidget {
|
||||||
style: TextStyle(
|
final String label;
|
||||||
color: active ? color : Colors.grey,
|
final bool active;
|
||||||
fontSize: 11,
|
final Color color;
|
||||||
fontWeight: active ? FontWeight.bold : FontWeight.normal,
|
|
||||||
letterSpacing: 1,
|
const FlagPill({
|
||||||
),
|
super.key,
|
||||||
),
|
required this.label,
|
||||||
);
|
required this.active,
|
||||||
}
|
required this.color,
|
||||||
}
|
});
|
||||||
|
|
||||||
/// Row of all flag pills derived from a SensorState.
|
@override
|
||||||
class FlagRow extends StatelessWidget {
|
Widget build(BuildContext context) {
|
||||||
final SensorState state;
|
final c = AppColors.of(context);
|
||||||
final Color accentColor;
|
return AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 100),
|
||||||
const FlagRow({super.key, required this.state, required this.accentColor});
|
height: 30,
|
||||||
|
alignment: Alignment.center,
|
||||||
@override
|
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||||
Widget build(BuildContext context) {
|
decoration: BoxDecoration(
|
||||||
return Wrap(
|
color: active ? color.withAlpha(200) : c.surface,
|
||||||
spacing: 6,
|
borderRadius: BorderRadius.circular(6),
|
||||||
runSpacing: 6,
|
border: Border.all(
|
||||||
alignment: WrapAlignment.center,
|
color: active ? color : c.accent.withAlpha(90),
|
||||||
children: [
|
width: 1.2,
|
||||||
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),
|
child: FittedBox(
|
||||||
FlagPill(label: 'FUEL PUMP', active: state.fuelPump, color: Colors.cyan),
|
fit: BoxFit.scaleDown,
|
||||||
FlagPill(label: 'REV LIM', active: state.revLimit, color: Colors.orange),
|
child: Text(
|
||||||
FlagPill(label: 'FAN', active: state.fanOut, color: Colors.green),
|
label,
|
||||||
FlagPill(label: 'LAUNCH', active: state.launch, color: Colors.purple),
|
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]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,85 +1,147 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../../../core/models/sensor_defs.dart';
|
import '../../../../core/models/sensor_defs.dart';
|
||||||
|
import '../../../../core/models/units.dart';
|
||||||
class SensorPickerSheet extends StatelessWidget {
|
import '../../../theme/app_colors.dart';
|
||||||
final String currentId;
|
|
||||||
final void Function(String id) onSelected;
|
/// One choosable entry in [showPickerSheet].
|
||||||
|
class PickerItem {
|
||||||
const SensorPickerSheet({
|
final String id;
|
||||||
super.key,
|
final String title;
|
||||||
required this.currentId,
|
final String? subtitle;
|
||||||
required this.onSelected,
|
const PickerItem(this.id, this.title, [this.subtitle]);
|
||||||
});
|
}
|
||||||
|
|
||||||
@override
|
/// Bottom sheet listing [items]; resolves to the chosen id, or null.
|
||||||
Widget build(BuildContext context) {
|
Future<String?> showPickerSheet(
|
||||||
return DraggableScrollableSheet(
|
BuildContext context, {
|
||||||
expand: false,
|
required String title,
|
||||||
initialChildSize: 0.6,
|
required List<PickerItem> items,
|
||||||
minChildSize: 0.4,
|
required String currentId,
|
||||||
maxChildSize: 0.9,
|
}) {
|
||||||
builder: (context, scrollController) {
|
return showModalBottomSheet<String>(
|
||||||
return Container(
|
context: context,
|
||||||
decoration: const BoxDecoration(
|
isScrollControlled: true,
|
||||||
color: Color(0xFF1A1A1A),
|
backgroundColor: Colors.transparent,
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
builder: (_) => _PickerSheet(
|
||||||
),
|
title: title,
|
||||||
child: Column(
|
items: items,
|
||||||
children: [
|
currentId: currentId,
|
||||||
const SizedBox(height: 8),
|
),
|
||||||
Container(
|
);
|
||||||
width: 40, height: 4,
|
}
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white24,
|
/// Picker over every analog sensor, showing the unit currently in use.
|
||||||
borderRadius: BorderRadius.circular(2),
|
Future<String?> pickSensor(
|
||||||
),
|
BuildContext context, {
|
||||||
),
|
required String title,
|
||||||
const Padding(
|
required String currentId,
|
||||||
padding: EdgeInsets.all(16),
|
required UnitPrefs units,
|
||||||
child: Text(
|
}) {
|
||||||
'SELECT SENSOR',
|
return showPickerSheet(
|
||||||
style: TextStyle(
|
context,
|
||||||
color: Colors.white70,
|
title: title,
|
||||||
fontSize: 12,
|
currentId: currentId,
|
||||||
letterSpacing: 2,
|
items: [
|
||||||
fontWeight: FontWeight.w600,
|
for (final d in sensorDefs)
|
||||||
),
|
PickerItem(d.id, d.displayName, sensorView(d.id, units).unit),
|
||||||
),
|
],
|
||||||
),
|
);
|
||||||
const Divider(height: 1, color: Colors.white10),
|
}
|
||||||
Expanded(
|
|
||||||
child: ListView.builder(
|
/// Picker over every flag.
|
||||||
controller: scrollController,
|
Future<String?> pickFlag(BuildContext context, {required String currentId}) {
|
||||||
itemCount: sensorDefs.length,
|
return showPickerSheet(
|
||||||
itemBuilder: (context, i) {
|
context,
|
||||||
final def = sensorDefs[i];
|
title: 'SELECT FLAG',
|
||||||
final isSelected = def.id == currentId;
|
currentId: currentId,
|
||||||
return ListTile(
|
items: [for (final f in flagDefs) PickerItem(f.id, f.displayName)],
|
||||||
title: Text(
|
);
|
||||||
def.displayName,
|
}
|
||||||
style: TextStyle(
|
|
||||||
color: isSelected ? Colors.white : Colors.white70,
|
class _PickerSheet extends StatelessWidget {
|
||||||
fontWeight: isSelected
|
final String title;
|
||||||
? FontWeight.bold
|
final List<PickerItem> items;
|
||||||
: FontWeight.normal,
|
final String currentId;
|
||||||
),
|
|
||||||
),
|
const _PickerSheet({
|
||||||
subtitle: Text(
|
required this.title,
|
||||||
def.unit,
|
required this.items,
|
||||||
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
required this.currentId,
|
||||||
),
|
});
|
||||||
trailing: isSelected
|
|
||||||
? const Icon(Icons.check, color: Colors.green, size: 18)
|
@override
|
||||||
: null,
|
Widget build(BuildContext context) {
|
||||||
onTap: () => onSelected(def.id),
|
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),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,128 +1,139 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../../../core/models/sensor_defs.dart';
|
import '../../../../core/models/sensor_defs.dart';
|
||||||
import '../../../../core/protocol/sensor_state.dart';
|
import '../../../../core/models/units.dart';
|
||||||
|
import '../../../../core/protocol/sensor_state.dart';
|
||||||
class SensorTile extends StatelessWidget {
|
import '../../../theme/app_colors.dart';
|
||||||
final int slotIndex;
|
|
||||||
final String sensorId;
|
/// One selectable dashboard value: "unit (name)" caption over a large
|
||||||
final SensorState state;
|
/// number, as in the requirement layouts. Long-press to change the sensor.
|
||||||
final Color accentColor;
|
class SensorTile extends StatelessWidget {
|
||||||
final VoidCallback onTap;
|
final String sensorId;
|
||||||
|
final SensorState state;
|
||||||
const SensorTile({
|
final UnitPrefs units;
|
||||||
super.key,
|
final VoidCallback onLongPress;
|
||||||
required this.slotIndex,
|
|
||||||
required this.sensorId,
|
const SensorTile({
|
||||||
required this.state,
|
super.key,
|
||||||
required this.accentColor,
|
required this.sensorId,
|
||||||
required this.onTap,
|
required this.state,
|
||||||
});
|
required this.units,
|
||||||
|
required this.onLongPress,
|
||||||
@override
|
});
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final def = sensorDefs.firstWhere(
|
@override
|
||||||
(d) => d.id == sensorId,
|
Widget build(BuildContext context) {
|
||||||
orElse: () => sensorDefs.first,
|
final c = AppColors.of(context);
|
||||||
);
|
final view = sensorView(sensorId, units);
|
||||||
final value = _getValue(state, sensorId);
|
final base = sensorValue(state, sensorId);
|
||||||
final normalized =
|
final fraction = view.fraction(base);
|
||||||
((value - def.min) / (def.max - def.min)).clamp(0.0, 1.0);
|
final caption =
|
||||||
|
view.unit.isEmpty ? view.name : '${view.unit} (${view.name})';
|
||||||
return GestureDetector(
|
|
||||||
onLongPress: onTap,
|
return GestureDetector(
|
||||||
child: Container(
|
onLongPress: onLongPress,
|
||||||
decoration: BoxDecoration(
|
child: LayoutBuilder(
|
||||||
color: const Color(0xFF1A1A1A),
|
builder: (context, box) {
|
||||||
borderRadius: BorderRadius.circular(10),
|
final valueText = Text(
|
||||||
border: Border.all(color: Colors.white10),
|
view.format(base),
|
||||||
),
|
style: TextStyle(
|
||||||
padding: const EdgeInsets.fromLTRB(10, 8, 10, 6),
|
color: fraction > 0.9 ? Colors.red : c.onSurface,
|
||||||
child: Column(
|
fontSize: 26,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
fontWeight: FontWeight.bold,
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
fontStyle: FontStyle.italic,
|
||||||
children: [
|
letterSpacing: -0.5,
|
||||||
Text(
|
),
|
||||||
def.displayName.toUpperCase(),
|
);
|
||||||
style: const TextStyle(
|
final captionText = Text(
|
||||||
color: Colors.grey,
|
caption,
|
||||||
fontSize: 10,
|
maxLines: 2,
|
||||||
letterSpacing: 1,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
style: TextStyle(
|
||||||
),
|
color: c.onSurfaceMuted,
|
||||||
Row(
|
fontSize: 10,
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
fontStyle: FontStyle.italic,
|
||||||
children: [
|
fontWeight: FontWeight.w600,
|
||||||
Text(
|
),
|
||||||
_format(value, sensorId),
|
);
|
||||||
style: const TextStyle(
|
// Short tiles (5 per side in landscape): caption beside the value
|
||||||
color: Colors.white,
|
// so the number keeps its size.
|
||||||
fontSize: 22,
|
if (box.maxHeight < 56) {
|
||||||
fontWeight: FontWeight.bold,
|
return Container(
|
||||||
letterSpacing: -0.5,
|
decoration: BoxDecoration(
|
||||||
),
|
color: c.surface,
|
||||||
),
|
borderRadius: BorderRadius.circular(8),
|
||||||
const SizedBox(width: 3),
|
border: Border.all(color: c.divider),
|
||||||
Padding(
|
),
|
||||||
padding: const EdgeInsets.only(bottom: 2),
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||||
child: Text(
|
child: Row(
|
||||||
def.unit,
|
children: [
|
||||||
style: TextStyle(color: accentColor, fontSize: 11),
|
Expanded(flex: 4, child: captionText),
|
||||||
),
|
Expanded(
|
||||||
),
|
flex: 5,
|
||||||
],
|
child: FittedBox(
|
||||||
),
|
fit: BoxFit.scaleDown,
|
||||||
// Bar gauge
|
alignment: Alignment.centerRight,
|
||||||
ClipRRect(
|
child: valueText,
|
||||||
borderRadius: BorderRadius.circular(2),
|
),
|
||||||
child: LinearProgressIndicator(
|
),
|
||||||
value: normalized,
|
],
|
||||||
minHeight: 4,
|
),
|
||||||
backgroundColor: Colors.white10,
|
);
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(
|
}
|
||||||
normalized > 0.8 ? Colors.red : accentColor,
|
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,
|
||||||
String _format(double v, String id) {
|
children: [
|
||||||
if (id == 'gear') return v.toStringAsFixed(0);
|
Text(
|
||||||
if (id == 'rpm') return v.toStringAsFixed(0);
|
caption,
|
||||||
if (v.abs() >= 100) return v.toStringAsFixed(1);
|
maxLines: 1,
|
||||||
return v.toStringAsFixed(2);
|
overflow: TextOverflow.ellipsis,
|
||||||
}
|
style: TextStyle(
|
||||||
}
|
color: c.onSurfaceMuted,
|
||||||
|
fontSize: 10,
|
||||||
double _getValue(SensorState s, String id) {
|
fontStyle: FontStyle.italic,
|
||||||
switch (id) {
|
fontWeight: FontWeight.w600,
|
||||||
case 'rpm': return s.rpm;
|
),
|
||||||
case 'vss': return s.vss;
|
),
|
||||||
case 'map': return s.map;
|
Expanded(
|
||||||
case 'tps': return s.tps;
|
child: FittedBox(
|
||||||
case 'inj': return s.inj;
|
fit: BoxFit.scaleDown,
|
||||||
case 'ign': return s.ign;
|
alignment: Alignment.centerRight,
|
||||||
case 'ect': return s.ect;
|
child: Text(
|
||||||
case 'iat': return s.iat;
|
view.format(base),
|
||||||
case 'bat': return s.bat;
|
style: TextStyle(
|
||||||
case 'o2': return s.o2;
|
color: fraction > 0.9 ? Colors.red : c.onSurface,
|
||||||
case 'gear': return s.gear.toDouble();
|
fontSize: 26,
|
||||||
case 'eth': return s.eth;
|
fontWeight: FontWeight.bold,
|
||||||
case 'pa': return s.pa;
|
fontStyle: FontStyle.italic,
|
||||||
case 'afr': return s.afr;
|
letterSpacing: -0.5,
|
||||||
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;
|
if (showBar)
|
||||||
case 'ain3': return s.ain3;
|
ClipRRect(
|
||||||
case 'ain4': return s.ain4;
|
borderRadius: BorderRadius.circular(2),
|
||||||
case 'ain5': return s.ain5;
|
child: LinearProgressIndicator(
|
||||||
case 'ain6': return s.ain6;
|
value: fraction,
|
||||||
case 'ain7': return s.ain7;
|
minHeight: 3,
|
||||||
default: return 0;
|
backgroundColor: c.track,
|
||||||
}
|
valueColor: AlwaysStoppedAnimation<Color>(
|
||||||
}
|
fraction > 0.9 ? Colors.red : c.accent,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,124 +1,127 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import '../../../core/models/datalog_session.dart';
|
import '../../../core/models/datalog_session.dart';
|
||||||
import '../../../core/providers/datalog_provider.dart';
|
import '../../../core/providers/datalog_provider.dart';
|
||||||
import 'datalog_playback_bar.dart';
|
import 'datalog_playback_bar.dart';
|
||||||
|
|
||||||
class DatalogListScreen extends ConsumerWidget {
|
class DatalogListScreen extends ConsumerWidget {
|
||||||
const DatalogListScreen({super.key});
|
const DatalogListScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final sessionsAsync = ref.watch(sessionListProvider);
|
final sessionsAsync = ref.watch(sessionListProvider);
|
||||||
final playback = ref.watch(playbackProvider);
|
final playback = ref.watch(playbackProvider);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Saved Sessions')),
|
appBar: AppBar(title: const Text('Saved Sessions')),
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
if (playback.isActive)
|
if (playback.isActive)
|
||||||
DatalogPlaybackBar(state: playback),
|
DatalogPlaybackBar(state: playback),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: sessionsAsync.when(
|
child: sessionsAsync.when(
|
||||||
loading: () =>
|
loading: () =>
|
||||||
const Center(child: CircularProgressIndicator()),
|
const Center(child: CircularProgressIndicator()),
|
||||||
error: (e, _) => Center(
|
error: (e, _) => Center(
|
||||||
child: Text('Error: $e',
|
child: Text('Error: $e',
|
||||||
style: const TextStyle(color: Colors.red))),
|
style: const TextStyle(color: Colors.red))),
|
||||||
data: (sessions) => sessions.isEmpty
|
data: (sessions) => sessions.isEmpty
|
||||||
? const Center(
|
? const Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.videocam_off,
|
Icon(Icons.videocam_off,
|
||||||
size: 56, color: Colors.grey),
|
size: 56, color: Colors.grey),
|
||||||
SizedBox(height: 12),
|
SizedBox(height: 12),
|
||||||
Text('No recorded sessions',
|
Text('No recorded sessions',
|
||||||
style: TextStyle(color: Colors.grey)),
|
style: TextStyle(color: Colors.grey)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: ListView.separated(
|
: ListView.separated(
|
||||||
itemCount: sessions.length,
|
itemCount: sessions.length,
|
||||||
separatorBuilder: (_, __) =>
|
separatorBuilder: (_, __) =>
|
||||||
const Divider(height: 1, color: Colors.white10),
|
const Divider(height: 1),
|
||||||
itemBuilder: (context, i) =>
|
itemBuilder: (context, i) =>
|
||||||
_SessionTile(session: sessions[i]),
|
_SessionTile(session: sessions[i]),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SessionTile extends ConsumerWidget {
|
class _SessionTile extends ConsumerWidget {
|
||||||
final DatalogSession session;
|
final DatalogSession session;
|
||||||
const _SessionTile({required this.session});
|
const _SessionTile({required this.session});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final fmt = DateFormat('dd MMM yyyy HH:mm');
|
final fmt = DateFormat('dd MMM yyyy HH:mm');
|
||||||
final isPlaying =
|
final isPlaying =
|
||||||
ref.watch(playbackProvider).sessionId == session.id;
|
ref.watch(playbackProvider).sessionId == session.id;
|
||||||
|
|
||||||
return Dismissible(
|
return Dismissible(
|
||||||
key: ValueKey(session.id),
|
key: ValueKey(session.id),
|
||||||
direction: DismissDirection.endToStart,
|
direction: DismissDirection.endToStart,
|
||||||
background: Container(
|
background: Container(
|
||||||
color: Colors.red,
|
color: Colors.red,
|
||||||
alignment: Alignment.centerRight,
|
alignment: Alignment.centerRight,
|
||||||
padding: const EdgeInsets.only(right: 20),
|
padding: const EdgeInsets.only(right: 20),
|
||||||
child: const Icon(Icons.delete, color: Colors.white),
|
child: const Icon(Icons.delete, color: Colors.white),
|
||||||
),
|
),
|
||||||
confirmDismiss: (_) async {
|
confirmDismiss: (_) async {
|
||||||
return await showDialog<bool>(
|
return await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
title: const Text('Delete session?'),
|
title: const Text('Delete session?'),
|
||||||
content: const Text('This cannot be undone.'),
|
content: const Text('This cannot be undone.'),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(ctx, false),
|
onPressed: () => Navigator.pop(ctx, false),
|
||||||
child: const Text('Cancel')),
|
child: const Text('Cancel')),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(ctx, true),
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
child: const Text('Delete',
|
child: const Text('Delete',
|
||||||
style: TextStyle(color: Colors.red))),
|
style: TextStyle(color: Colors.red))),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
onDismissed: (_) async {
|
onDismissed: (_) async {
|
||||||
await ref
|
await ref
|
||||||
.read(datalogDbProvider)
|
.read(datalogDbProvider)
|
||||||
.deleteSession(session.id!);
|
.deleteSession(session.id!);
|
||||||
ref.invalidate(sessionListProvider);
|
ref.invalidate(sessionListProvider);
|
||||||
},
|
},
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
leading: Icon(
|
leading: Icon(
|
||||||
isPlaying ? Icons.play_circle : Icons.videocam,
|
isPlaying ? Icons.play_circle : Icons.videocam,
|
||||||
color: isPlaying ? Colors.green : Colors.grey,
|
color: isPlaying ? Colors.green : Colors.grey,
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
fmt.format(session.startTime),
|
fmt.format(session.startTime),
|
||||||
style: const TextStyle(fontSize: 13),
|
style: const TextStyle(fontSize: 13),
|
||||||
),
|
),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
'${session.ecuType.toUpperCase()} • '
|
'${session.ecuType.toUpperCase()} • '
|
||||||
'${session.frameCount} frames • ${session.durationLabel}',
|
'${session.frameCount} frames • ${session.durationLabel}',
|
||||||
style: const TextStyle(color: Colors.grey, fontSize: 11),
|
style: const TextStyle(color: Colors.grey, fontSize: 11),
|
||||||
),
|
),
|
||||||
trailing: const Icon(Icons.play_arrow, size: 18),
|
trailing: const Icon(Icons.play_arrow, size: 18),
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
await ref
|
await ref
|
||||||
.read(playbackProvider.notifier)
|
.read(playbackProvider.notifier)
|
||||||
.loadSession(session.id!);
|
.loadSession(session.id!, session.ecuType);
|
||||||
ref.read(playbackProvider.notifier).play();
|
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);
|
||||||
}
|
},
|
||||||
}
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,90 +1,91 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../../core/datalog/datalog_player.dart';
|
import '../../../core/datalog/datalog_player.dart';
|
||||||
import '../../../core/providers/datalog_provider.dart';
|
import '../../../core/providers/datalog_provider.dart';
|
||||||
|
import '../../theme/app_colors.dart';
|
||||||
class DatalogPlaybackBar extends ConsumerWidget {
|
|
||||||
final PlaybackState state;
|
class DatalogPlaybackBar extends ConsumerWidget {
|
||||||
const DatalogPlaybackBar({super.key, required this.state});
|
final PlaybackState state;
|
||||||
|
const DatalogPlaybackBar({super.key, required this.state});
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
@override
|
||||||
final notifier = ref.read(playbackProvider.notifier);
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final isPlaying = state.status == PlaybackStatus.playing;
|
final notifier = ref.read(playbackProvider.notifier);
|
||||||
|
final isPlaying = state.status == PlaybackStatus.playing;
|
||||||
return Container(
|
final c = AppColors.of(context);
|
||||||
color: const Color(0xFF1A1A1A),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
return Container(
|
||||||
child: Column(
|
color: c.surface,
|
||||||
mainAxisSize: MainAxisSize.min,
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
children: [
|
child: Column(
|
||||||
// Progress slider
|
mainAxisSize: MainAxisSize.min,
|
||||||
SliderTheme(
|
children: [
|
||||||
data: SliderTheme.of(context).copyWith(
|
// Progress slider
|
||||||
trackHeight: 3,
|
SliderTheme(
|
||||||
thumbShape:
|
data: SliderTheme.of(context).copyWith(
|
||||||
const RoundSliderThumbShape(enabledThumbRadius: 6),
|
trackHeight: 3,
|
||||||
overlayShape:
|
thumbShape:
|
||||||
const RoundSliderOverlayShape(overlayRadius: 12),
|
const RoundSliderThumbShape(enabledThumbRadius: 6),
|
||||||
),
|
overlayShape:
|
||||||
child: Slider(
|
const RoundSliderOverlayShape(overlayRadius: 12),
|
||||||
value: state.progress,
|
),
|
||||||
onChanged: (v) =>
|
child: Slider(
|
||||||
notifier.seek((v * state.total).round()),
|
value: state.progress,
|
||||||
),
|
onChanged: (v) =>
|
||||||
),
|
notifier.seek((v * state.total).round()),
|
||||||
// Controls row
|
),
|
||||||
Row(
|
),
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
// Controls row
|
||||||
children: [
|
Row(
|
||||||
// Frame counter
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
Text(
|
children: [
|
||||||
'${state.position} / ${state.total}',
|
// Frame counter
|
||||||
style:
|
Text(
|
||||||
const TextStyle(color: Colors.grey, fontSize: 11),
|
'${state.position} / ${state.total}',
|
||||||
),
|
style:
|
||||||
// Play / Pause / Stop
|
TextStyle(color: c.onSurfaceMuted, fontSize: 11),
|
||||||
Row(
|
),
|
||||||
children: [
|
// Play / Pause / Stop
|
||||||
IconButton(
|
Row(
|
||||||
icon: Icon(
|
children: [
|
||||||
isPlaying ? Icons.pause : Icons.play_arrow,
|
IconButton(
|
||||||
size: 22,
|
icon: Icon(
|
||||||
),
|
isPlaying ? Icons.pause : Icons.play_arrow,
|
||||||
onPressed: isPlaying ? notifier.pause : notifier.play,
|
size: 22,
|
||||||
padding: const EdgeInsets.all(4),
|
),
|
||||||
constraints: const BoxConstraints(),
|
onPressed: isPlaying ? notifier.pause : notifier.play,
|
||||||
),
|
padding: const EdgeInsets.all(4),
|
||||||
const SizedBox(width: 8),
|
constraints: const BoxConstraints(),
|
||||||
IconButton(
|
),
|
||||||
icon: const Icon(Icons.stop, size: 22),
|
const SizedBox(width: 8),
|
||||||
onPressed: () async => notifier.stop(),
|
IconButton(
|
||||||
padding: const EdgeInsets.all(4),
|
icon: const Icon(Icons.stop, size: 22),
|
||||||
constraints: const BoxConstraints(),
|
onPressed: () async => notifier.stop(),
|
||||||
),
|
padding: const EdgeInsets.all(4),
|
||||||
],
|
constraints: const BoxConstraints(),
|
||||||
),
|
),
|
||||||
// Speed selector
|
],
|
||||||
DropdownButton<double>(
|
),
|
||||||
value: state.speed,
|
// Speed selector
|
||||||
isDense: true,
|
DropdownButton<double>(
|
||||||
underline: const SizedBox(),
|
value: state.speed,
|
||||||
style: const TextStyle(
|
isDense: true,
|
||||||
color: Colors.white70, fontSize: 12),
|
underline: const SizedBox(),
|
||||||
items: const [
|
style: TextStyle(color: c.onSurface, fontSize: 12),
|
||||||
DropdownMenuItem(value: 0.5, child: Text('0.5×')),
|
items: const [
|
||||||
DropdownMenuItem(value: 1.0, child: Text('1×')),
|
DropdownMenuItem(value: 0.5, child: Text('0.5×')),
|
||||||
DropdownMenuItem(value: 2.0, child: Text('2×')),
|
DropdownMenuItem(value: 1.0, child: Text('1×')),
|
||||||
DropdownMenuItem(value: 5.0, child: Text('5×')),
|
DropdownMenuItem(value: 2.0, child: Text('2×')),
|
||||||
],
|
DropdownMenuItem(value: 5.0, child: Text('5×')),
|
||||||
onChanged: (v) {
|
],
|
||||||
if (v != null) notifier.setSpeed(v);
|
onChanged: (v) {
|
||||||
},
|
if (v != null) notifier.setSpeed(v);
|
||||||
),
|
},
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
);
|
),
|
||||||
}
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,391 +1,419 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../../../core/bluetooth/bt_poller.dart';
|
import '../../../core/bluetooth/bt_poller.dart';
|
||||||
import '../../../core/providers/bt_provider.dart';
|
import '../../../core/logging/app_log.dart';
|
||||||
import '../../../core/providers/sensor_provider.dart';
|
import '../../../core/providers/bt_provider.dart';
|
||||||
import '../../../core/providers/settings_provider.dart';
|
import '../../../core/providers/sensor_provider.dart';
|
||||||
import '../../../core/protocol/neg8.dart';
|
import '../../../core/providers/settings_provider.dart';
|
||||||
import '../../../core/protocol/sensor_state.dart';
|
import '../../../core/protocol/neg8.dart';
|
||||||
|
import '../../../core/protocol/sensor_state.dart';
|
||||||
class RawFrameDebugScreen extends ConsumerStatefulWidget {
|
|
||||||
const RawFrameDebugScreen({super.key});
|
class RawFrameDebugScreen extends ConsumerStatefulWidget {
|
||||||
|
const RawFrameDebugScreen({super.key});
|
||||||
@override
|
|
||||||
ConsumerState<RawFrameDebugScreen> createState() =>
|
@override
|
||||||
_RawFrameDebugScreenState();
|
ConsumerState<RawFrameDebugScreen> createState() =>
|
||||||
}
|
_RawFrameDebugScreenState();
|
||||||
|
}
|
||||||
class _RawFrameDebugScreenState extends ConsumerState<RawFrameDebugScreen> {
|
|
||||||
Uint8List? _probeResult;
|
class _RawFrameDebugScreenState extends ConsumerState<RawFrameDebugScreen> {
|
||||||
bool _probing = false;
|
Uint8List? _probeResult;
|
||||||
|
bool _probing = false;
|
||||||
Future<void> _runProbe() async {
|
|
||||||
setState(() {
|
Future<void> _runProbe() async {
|
||||||
_probing = true;
|
setState(() {
|
||||||
_probeResult = null;
|
_probing = true;
|
||||||
});
|
_probeResult = null;
|
||||||
final result =
|
});
|
||||||
await ref.read(btProvider.notifier).probeOfficialProtocol();
|
final result =
|
||||||
if (!mounted) return;
|
await ref.read(btProvider.notifier).probeOfficialProtocol();
|
||||||
setState(() {
|
if (!mounted) return;
|
||||||
_probing = false;
|
setState(() {
|
||||||
_probeResult = result;
|
_probing = false;
|
||||||
});
|
_probeResult = result;
|
||||||
}
|
});
|
||||||
|
}
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
@override
|
||||||
final bt = ref.watch(btProvider);
|
Widget build(BuildContext context) {
|
||||||
final settings = ref.watch(settingsProvider);
|
final bt = ref.watch(btProvider);
|
||||||
final sensorAsync = ref.watch(sensorStateProvider);
|
final settings = ref.watch(settingsProvider);
|
||||||
final state = ref.watch(latestSensorProvider);
|
final sensorAsync = ref.watch(sensorStateProvider);
|
||||||
final decodeStats = ref.watch(decodeStatsProvider);
|
final state = ref.watch(latestSensorProvider);
|
||||||
final frame = bt.lastFrame;
|
final decodeStats = ref.watch(decodeStatsProvider);
|
||||||
final hex = frame == null ? '' : _hex(frame);
|
final frame = bt.lastFrame;
|
||||||
|
final hex = frame == null ? '' : _hex(frame);
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(
|
return Scaffold(
|
||||||
title: const Text('ECU Frame Debug'),
|
appBar: AppBar(
|
||||||
actions: [
|
title: const Text('ECU Frame Debug'),
|
||||||
IconButton(
|
actions: [
|
||||||
tooltip: 'Copy raw frame',
|
IconButton(
|
||||||
icon: const Icon(Icons.copy),
|
tooltip: 'Copy app log',
|
||||||
onPressed: frame == null
|
icon: const Icon(Icons.description_outlined),
|
||||||
? null
|
onPressed: () async {
|
||||||
: () async {
|
await Clipboard.setData(ClipboardData(text: AppLog.dump()));
|
||||||
await Clipboard.setData(ClipboardData(text: hex));
|
if (context.mounted) {
|
||||||
if (context.mounted) {
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
SnackBar(
|
||||||
const SnackBar(content: Text('Raw frame copied')),
|
content: Text(
|
||||||
);
|
'App log copied (${AppLog.length} lines) — paste it into an email/message')),
|
||||||
}
|
);
|
||||||
},
|
}
|
||||||
),
|
},
|
||||||
],
|
),
|
||||||
),
|
IconButton(
|
||||||
body: ListView(
|
tooltip: 'Copy raw frame',
|
||||||
padding: const EdgeInsets.all(12),
|
icon: const Icon(Icons.copy),
|
||||||
children: [
|
onPressed: frame == null
|
||||||
_Section(
|
? null
|
||||||
title: 'Connection',
|
: () async {
|
||||||
rows: [
|
await Clipboard.setData(ClipboardData(text: hex));
|
||||||
_RowData('Protocol', settings.ecuType.name.toUpperCase()),
|
if (context.mounted) {
|
||||||
_RowData('Frames (raw, from poller)', bt.frameCount.toString()),
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
_RowData('Dropped (bad checksum)', bt.droppedFrames.toString()),
|
const SnackBar(content: Text('Raw frame copied')),
|
||||||
_RowData('Last frame bytes', frame?.length.toString() ?? 'none'),
|
);
|
||||||
if (frame != null)
|
}
|
||||||
_RowData(
|
},
|
||||||
'Checksum',
|
),
|
||||||
validateFrame(frame) ? 'valid' : 'bad',
|
],
|
||||||
),
|
),
|
||||||
],
|
body: ListView(
|
||||||
),
|
padding: const EdgeInsets.all(12),
|
||||||
const SizedBox(height: 12),
|
children: [
|
||||||
_ProtocolProbeCard(
|
_Section(
|
||||||
connected: bt.isConnected,
|
title: 'Connection',
|
||||||
probing: _probing,
|
rows: [
|
||||||
result: _probeResult,
|
_RowData('Protocol', settings.ecuType.name.toUpperCase()),
|
||||||
onProbe: _runProbe,
|
_RowData('Link status', bt.status.name),
|
||||||
),
|
_RowData('ECU data', bt.dataStatus.name),
|
||||||
const SizedBox(height: 12),
|
_RowData('Poll interval', '${settings.pollingInterval.inMilliseconds} ms'),
|
||||||
_Section(
|
_RowData('Requests sent', bt.requestsSent.toString()),
|
||||||
title: 'Decode Pipeline',
|
_RowData('Valid replies', bt.frameCount.toString()),
|
||||||
rows: [
|
_RowData(' live ECU frames', bt.liveFrames.toString()),
|
||||||
_RowData('Decoded -> UI', decodeStats.decoded.toString()),
|
_RowData(' adapter default frames', bt.defaultFrames.toString()),
|
||||||
_RowData('Skipped (decode error)', decodeStats.decodeErrors.toString()),
|
_RowData(' RPM glitch frames dropped', bt.rpmGlitches.toString()),
|
||||||
_RowData(
|
_RowData('Timeouts (no reply)', bt.timeouts.toString()),
|
||||||
'sensorStateProvider raw',
|
_RowData('Dropped (bad checksum)', bt.droppedFrames.toString()),
|
||||||
sensorAsync.when(
|
_RowData('Last reply time', bt.lastResponseMs == null ? '-' : '${bt.lastResponseMs} ms'),
|
||||||
data: (s) =>
|
_RowData('Auto-reconnects', bt.reconnects.toString()),
|
||||||
'data(rpm=${s.rpm.toStringAsFixed(0)}, age=${DateTime.now().difference(s.timestamp).inMilliseconds}ms)',
|
_RowData('Last frame bytes', frame?.length.toString() ?? 'none'),
|
||||||
loading: () => 'loading (no frame yet)',
|
if (frame != null)
|
||||||
error: (e, st) => 'error: $e',
|
_RowData(
|
||||||
),
|
'Checksum',
|
||||||
),
|
validateFrame(frame) ? 'valid' : 'bad',
|
||||||
_RowData(
|
),
|
||||||
'latestSensorProvider age',
|
],
|
||||||
'${DateTime.now().difference(state.timestamp).inMilliseconds}ms since decode',
|
),
|
||||||
),
|
const SizedBox(height: 12),
|
||||||
],
|
_ProtocolProbeCard(
|
||||||
),
|
connected: bt.isConnected,
|
||||||
const SizedBox(height: 12),
|
probing: _probing,
|
||||||
_Section(
|
result: _probeResult,
|
||||||
title: 'Current App Decode',
|
onProbe: _runProbe,
|
||||||
rows: _decodedRows(state),
|
),
|
||||||
),
|
const SizedBox(height: 12),
|
||||||
const SizedBox(height: 12),
|
_Section(
|
||||||
if (frame == null)
|
title: 'Decode Pipeline',
|
||||||
const Center(
|
rows: [
|
||||||
child: Padding(
|
_RowData('Decoded -> UI', decodeStats.decoded.toString()),
|
||||||
padding: EdgeInsets.all(24),
|
_RowData('Skipped (decode error)', decodeStats.decodeErrors.toString()),
|
||||||
child: Text(
|
_RowData(
|
||||||
'No ECU frame received yet.',
|
'sensorStateProvider raw',
|
||||||
style: TextStyle(color: Colors.grey),
|
sensorAsync.when(
|
||||||
),
|
data: (s) =>
|
||||||
),
|
'data(rpm=${s.rpm.toStringAsFixed(0)}, age=${DateTime.now().difference(s.timestamp).inMilliseconds}ms)',
|
||||||
)
|
loading: () => 'loading (no frame yet)',
|
||||||
else ...[
|
error: (e, st) => 'error: $e',
|
||||||
if (settings.ecuType == EcuType.s300) ...[
|
),
|
||||||
_Section(
|
),
|
||||||
title: 'S300 Raw Candidates',
|
_RowData(
|
||||||
rows: _s300CandidateRows(frame),
|
'latestSensorProvider age',
|
||||||
),
|
'${DateTime.now().difference(state.timestamp).inMilliseconds}ms since decode',
|
||||||
const SizedBox(height: 12),
|
),
|
||||||
],
|
],
|
||||||
_RawHexBlock(hex: hex),
|
),
|
||||||
],
|
const SizedBox(height: 12),
|
||||||
],
|
_Section(
|
||||||
),
|
title: 'Current App Decode',
|
||||||
);
|
rows: _decodedRows(state),
|
||||||
}
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
List<_RowData> _decodedRows(SensorState s) => [
|
if (frame == null)
|
||||||
_RowData('RPM', s.rpm.toStringAsFixed(0)),
|
const Center(
|
||||||
_RowData('Speed km/h', s.vss.toStringAsFixed(2)),
|
child: Padding(
|
||||||
_RowData('MAP kPa', s.map.toStringAsFixed(2)),
|
padding: EdgeInsets.all(24),
|
||||||
_RowData('TPS', s.tps.toStringAsFixed(2)),
|
child: Text(
|
||||||
_RowData('ECT', s.ect.toStringAsFixed(2)),
|
'No ECU frame received yet.',
|
||||||
_RowData('IAT', s.iat.toStringAsFixed(2)),
|
style: TextStyle(color: Colors.grey),
|
||||||
_RowData('Battery', s.bat.toStringAsFixed(2)),
|
),
|
||||||
_RowData('O2', s.o2.toStringAsFixed(2)),
|
),
|
||||||
_RowData('AFR', s.afr.toStringAsFixed(2)),
|
)
|
||||||
_RowData('MIL (unverified)', s.mil.toString()),
|
else ...[
|
||||||
_RowData('Fuel Pump', s.fuelPump.toString()),
|
if (settings.ecuType == EcuType.s300) ...[
|
||||||
_RowData('Fan Out (unverified)', s.fanOut.toString()),
|
_Section(
|
||||||
_RowData('VTEC (unverified)', s.vtec.toString()),
|
title: 'S300 Raw Candidates',
|
||||||
_RowData('Knock', s.knock.toString()),
|
rows: _s300CandidateRows(frame),
|
||||||
_RowData('Rev Limit (unverified)', s.revLimit.toString()),
|
),
|
||||||
_RowData('Launch (unverified)', s.launch.toString()),
|
const SizedBox(height: 12),
|
||||||
];
|
],
|
||||||
|
_RawHexBlock(hex: hex),
|
||||||
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)}'),
|
List<_RowData> _decodedRows(SensorState s) => [
|
||||||
_RowData('RPM raw @3 LE', rpmLe.toString()),
|
_RowData('RPM', s.rpm.toStringAsFixed(0)),
|
||||||
_RowData('VSS raw @5 LE', vssLe.toString()),
|
_RowData('Speed km/h', s.vss.toStringAsFixed(2)),
|
||||||
_RowData('VSS current km/h', _s300Vss(vssLe).toStringAsFixed(2)),
|
_RowData('MAP kPa', s.map.toStringAsFixed(2)),
|
||||||
_RowData('MAP raw @7 LE', mapLe.toString()),
|
_RowData('TPS', s.tps.toStringAsFixed(2)),
|
||||||
_RowData('MAP current kPa', _s300Map(mapLe).toStringAsFixed(2)),
|
_RowData('ECT', s.ect.toStringAsFixed(2)),
|
||||||
_RowData('TPS raw @9', f[9].toString()),
|
_RowData('IAT', s.iat.toStringAsFixed(2)),
|
||||||
_RowData('TPS current', _s300Tps(f[9]).toStringAsFixed(2)),
|
_RowData('Battery', s.bat.toStringAsFixed(2)),
|
||||||
_RowData('Gear raw @27 (decimal)', f[27].toString()),
|
_RowData('O2', s.o2.toStringAsFixed(2)),
|
||||||
_RowData('INJ raw @10 LE (offset unverified)', _u16le(f, 10).toString()),
|
_RowData('AFR', s.afr.toStringAsFixed(2)),
|
||||||
_RowData('IGN raw @12 (offset unverified)', f[12].toString()),
|
_RowData('MIL (unverified)', s.mil.toString()),
|
||||||
_RowData('O2 current raw (offset unverified)', f[16].toString()),
|
_RowData('Fuel Pump', s.fuelPump.toString()),
|
||||||
_RowData('ECT raw @33', f[33].toString()),
|
_RowData('Fan Out (unverified)', s.fanOut.toString()),
|
||||||
_RowData('IAT raw @34', f[34].toString()),
|
_RowData('VTEC (unverified)', s.vtec.toString()),
|
||||||
_RowData('BAT raw @36', f[36].toString()),
|
_RowData('Knock', s.knock.toString()),
|
||||||
_RowData('PA raw @44 (offset unverified)', f[0x2C].toString()),
|
_RowData('Rev Limit (unverified)', s.revLimit.toString()),
|
||||||
_RowData('PA candidate raw @50 (unconfirmed, ~3kPa off)', f[50].toString()),
|
_RowData('Launch (unverified)', s.launch.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)}'),
|
List<_RowData> _s300CandidateRows(Uint8List f) {
|
||||||
_RowData('SW2 @18 (bit7..0)', '${_b(f, 0x12)} ${_bin(f, 0x12)}'),
|
final rpmLe = _u16le(f, 3);
|
||||||
_RowData('SW3 @19 (bit7..0)', '${_b(f, 0x13)} ${_bin(f, 0x13)}'),
|
final vssLe = _u16le(f, 5);
|
||||||
_RowData('SW5 @67 (bit7..0)', '${_b(f, 0x43)} ${_bin(f, 0x43)}'),
|
final mapLe = _u16le(f, 7);
|
||||||
];
|
|
||||||
}
|
return [
|
||||||
|
_RowData('Header bytes', '${_b(f, 0)} ${_b(f, 1)} ${_b(f, 2)}'),
|
||||||
static int _u16le(Uint8List f, int o) => f[o] | (f[o + 1] << 8);
|
_RowData('RPM raw @3 LE', rpmLe.toString()),
|
||||||
static String _b(Uint8List f, int o) =>
|
_RowData('VSS raw @5 LE', vssLe.toString()),
|
||||||
f[o].toRadixString(16).padLeft(2, '0');
|
_RowData('VSS current km/h', _s300Vss(vssLe).toStringAsFixed(2)),
|
||||||
static String _bin(Uint8List f, int o) =>
|
_RowData('MAP raw @7 LE', mapLe.toString()),
|
||||||
f[o].toRadixString(2).padLeft(8, '0');
|
_RowData('MAP current kPa', _s300Map(mapLe).toStringAsFixed(2)),
|
||||||
|
_RowData('TPS raw @9', f[9].toString()),
|
||||||
static double _s300Vss(int raw) {
|
_RowData('TPS current', _s300Tps(f[9]).toStringAsFixed(2)),
|
||||||
return (raw < 893 || raw == 0xFFFF) ? 0.0 : 228480.0 / raw;
|
_RowData('Gear raw @27 (decimal)', f[27].toString()),
|
||||||
}
|
_RowData('INJ raw @10 LE', _u16le(f, 10).toString()),
|
||||||
|
_RowData('IGN raw @12', f[12].toString()),
|
||||||
static double _s300Map(int raw) {
|
_RowData('O2 raw @16', f[16].toString()),
|
||||||
return raw / 10.0;
|
_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()),
|
||||||
static double _s300Tps(int raw) {
|
_RowData('IAT raw @34', f[34].toString()),
|
||||||
return (raw * 100.0 / 255.0).clamp(0.0, 100.0);
|
_RowData('BAT raw @36', f[36].toString()),
|
||||||
}
|
_RowData('ERR00-03 @37-40',
|
||||||
|
'${_b(f, 37)} ${_b(f, 38)} ${_b(f, 39)} ${_b(f, 40)}'),
|
||||||
static String _hex(Uint8List frame) {
|
_RowData('Eth raw @41', f[41].toString()),
|
||||||
final parts = <String>[];
|
_RowData('AF raw @55', f[55].toString()),
|
||||||
for (var i = 0; i < frame.length; i++) {
|
_RowData('SW1 @17 (bit7..0)', '${_b(f, 17)} ${_bin(f, 17)}'),
|
||||||
if (i > 0 && i % 16 == 0) parts.add('\n');
|
_RowData('SW2 @18 (bit7..0)', '${_b(f, 18)} ${_bin(f, 18)}'),
|
||||||
parts.add(frame[i].toRadixString(16).padLeft(2, '0'));
|
_RowData('SW3 @19 (bit7..0)', '${_b(f, 19)} ${_bin(f, 19)}'),
|
||||||
}
|
_RowData('SW5 @49 (bit7..0)', '${_b(f, 49)} ${_bin(f, 49)}'),
|
||||||
return parts.join(' ');
|
_RowData('SW6 @50 (bit7..0)', '${_b(f, 50)} ${_bin(f, 50)}'),
|
||||||
}
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Step 1 of the data-layer review: checks whether this device/firmware
|
static int _u16le(Uint8List f, int o) => f[o] | (f[o + 1] << 8);
|
||||||
/// understands the official, self-describing Hondata Bluetooth protocol
|
static String _b(Uint8List f, int o) =>
|
||||||
/// (Bluetooth.pdf) rather than only the legacy fixed-offset frame format
|
f[o].toRadixString(16).padLeft(2, '0');
|
||||||
/// this app currently speaks. A real reply here (Protocol=0x00, ID=0x02,
|
static String _bin(Uint8List f, int o) =>
|
||||||
/// with a recognizable DeviceType byte) means migrating to that protocol is
|
f[o].toRadixString(2).padLeft(8, '0');
|
||||||
/// on the table; silence means this hardware is legacy-only.
|
|
||||||
class _ProtocolProbeCard extends StatelessWidget {
|
static double _s300Vss(int raw) {
|
||||||
final bool connected;
|
return (raw < 893 || raw == 0xFFFF) ? 0.0 : 228480.0 / raw;
|
||||||
final bool probing;
|
}
|
||||||
final Uint8List? result;
|
|
||||||
final VoidCallback onProbe;
|
static double _s300Map(int raw) {
|
||||||
|
return raw / 10.0;
|
||||||
const _ProtocolProbeCard({
|
}
|
||||||
required this.connected,
|
|
||||||
required this.probing,
|
static double _s300Tps(int raw) {
|
||||||
required this.result,
|
return (raw * 100.0 / 255.0).clamp(0.0, 100.0);
|
||||||
required this.onProbe,
|
}
|
||||||
});
|
|
||||||
|
static String _hex(Uint8List frame) {
|
||||||
@override
|
final parts = <String>[];
|
||||||
Widget build(BuildContext context) {
|
for (var i = 0; i < frame.length; i++) {
|
||||||
return Card(
|
if (i > 0 && i % 16 == 0) parts.add('\n');
|
||||||
child: Padding(
|
parts.add(frame[i].toRadixString(16).padLeft(2, '0'));
|
||||||
padding: const EdgeInsets.all(12),
|
}
|
||||||
child: Column(
|
return parts.join(' ');
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
}
|
||||||
children: [
|
}
|
||||||
const Text(
|
|
||||||
'Official Protocol Probe',
|
/// Step 1 of the data-layer review: checks whether this device/firmware
|
||||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
|
/// understands the official, self-describing Hondata Bluetooth protocol
|
||||||
),
|
/// (Bluetooth.pdf) rather than only the legacy fixed-offset frame format
|
||||||
const SizedBox(height: 4),
|
/// this app currently speaks. A real reply here (Protocol=0x00, ID=0x02,
|
||||||
const Text(
|
/// with a recognizable DeviceType byte) means migrating to that protocol is
|
||||||
'Sends DL_DeviceInfo (00 02 04 00). Pauses normal polling for '
|
/// on the table; silence means this hardware is legacy-only.
|
||||||
'the duration of the probe.',
|
class _ProtocolProbeCard extends StatelessWidget {
|
||||||
style: TextStyle(color: Colors.grey, fontSize: 11),
|
final bool connected;
|
||||||
),
|
final bool probing;
|
||||||
const SizedBox(height: 8),
|
final Uint8List? result;
|
||||||
SizedBox(
|
final VoidCallback onProbe;
|
||||||
width: double.infinity,
|
|
||||||
child: ElevatedButton(
|
const _ProtocolProbeCard({
|
||||||
onPressed: (connected && !probing) ? onProbe : null,
|
required this.connected,
|
||||||
child: Text(probing ? 'Probing…' : 'Send Probe'),
|
required this.probing,
|
||||||
),
|
required this.result,
|
||||||
),
|
required this.onProbe,
|
||||||
if (result != null) ...[
|
});
|
||||||
const SizedBox(height: 8),
|
|
||||||
for (final row in _interpret(result!)) _InfoRow(row),
|
@override
|
||||||
],
|
Widget build(BuildContext context) {
|
||||||
],
|
return Card(
|
||||||
),
|
child: Padding(
|
||||||
),
|
padding: const EdgeInsets.all(12),
|
||||||
);
|
child: Column(
|
||||||
}
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
List<_RowData> _interpret(Uint8List r) {
|
const Text(
|
||||||
if (r.isEmpty) {
|
'Official Protocol Probe',
|
||||||
return const [
|
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
|
||||||
_RowData('Result', 'No response — likely legacy protocol only'),
|
),
|
||||||
];
|
const SizedBox(height: 4),
|
||||||
}
|
const Text(
|
||||||
|
'Sends DL_DeviceInfo (00 02 04 00). Pauses normal polling for '
|
||||||
final hex = r.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ');
|
'the duration of the probe.',
|
||||||
final rows = [_RowData('Raw bytes', hex)];
|
style: TextStyle(color: Colors.grey, fontSize: 11),
|
||||||
if (r.length >= 4) {
|
),
|
||||||
final size = r[2] | (r[3] << 8); // little-endian per TDLHeader.Size
|
const SizedBox(height: 8),
|
||||||
rows.add(_RowData('Protocol / ID / Size', '${r[0]} / ${r[1]} / $size'));
|
SizedBox(
|
||||||
}
|
width: double.infinity,
|
||||||
if (r.length >= 6) {
|
child: ElevatedButton(
|
||||||
final deviceType = switch (r[4]) {
|
onPressed: (connected && !probing) ? onProbe : null,
|
||||||
0xC0 => 'S300',
|
child: Text(probing ? 'Probing…' : 'Send Probe'),
|
||||||
0xC1 => 'KPro',
|
),
|
||||||
0xC2 => 'FlashPro',
|
),
|
||||||
_ => 'unknown (0x${r[4].toRadixString(16)})',
|
if (result != null) ...[
|
||||||
};
|
const SizedBox(height: 8),
|
||||||
rows.add(_RowData('Device type', deviceType));
|
for (final row in _interpret(result!)) _InfoRow(row),
|
||||||
rows.add(_RowData('Ignition', r[5] != 0 ? 'On' : 'Off'));
|
],
|
||||||
}
|
],
|
||||||
return rows;
|
),
|
||||||
}
|
),
|
||||||
}
|
);
|
||||||
|
}
|
||||||
class _Section extends StatelessWidget {
|
|
||||||
final String title;
|
List<_RowData> _interpret(Uint8List r) {
|
||||||
final List<_RowData> rows;
|
if (r.isEmpty) {
|
||||||
|
return const [
|
||||||
const _Section({required this.title, required this.rows});
|
_RowData('Result', 'No response — likely legacy protocol only'),
|
||||||
|
];
|
||||||
@override
|
}
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Card(
|
final hex = r.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ');
|
||||||
child: Padding(
|
final rows = [_RowData('Raw bytes', hex)];
|
||||||
padding: const EdgeInsets.all(12),
|
if (r.length >= 4) {
|
||||||
child: Column(
|
final size = r[2] | (r[3] << 8); // little-endian per TDLHeader.Size
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
rows.add(_RowData('Protocol / ID / Size', '${r[0]} / ${r[1]} / $size'));
|
||||||
children: [
|
}
|
||||||
Text(
|
if (r.length >= 6) {
|
||||||
title,
|
final deviceType = switch (r[4]) {
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
|
0xC0 => 'S300',
|
||||||
),
|
0xC1 => 'KPro',
|
||||||
const SizedBox(height: 8),
|
0xC2 => 'FlashPro',
|
||||||
for (final row in rows) _InfoRow(row),
|
_ => 'unknown (0x${r[4].toRadixString(16)})',
|
||||||
],
|
};
|
||||||
),
|
rows.add(_RowData('Device type', deviceType));
|
||||||
),
|
rows.add(_RowData('Ignition', r[5] != 0 ? 'On' : 'Off'));
|
||||||
);
|
}
|
||||||
}
|
return rows;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
class _InfoRow extends StatelessWidget {
|
|
||||||
final _RowData row;
|
class _Section extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
const _InfoRow(this.row);
|
final List<_RowData> rows;
|
||||||
|
|
||||||
@override
|
const _Section({required this.title, required this.rows});
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Padding(
|
@override
|
||||||
padding: const EdgeInsets.symmetric(vertical: 3),
|
Widget build(BuildContext context) {
|
||||||
child: Row(
|
return Card(
|
||||||
children: [
|
child: Padding(
|
||||||
Expanded(
|
padding: const EdgeInsets.all(12),
|
||||||
child: Text(
|
child: Column(
|
||||||
row.label,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
children: [
|
||||||
),
|
Text(
|
||||||
),
|
title,
|
||||||
const SizedBox(width: 12),
|
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
|
||||||
Flexible(
|
),
|
||||||
child: Text(
|
const SizedBox(height: 8),
|
||||||
row.value,
|
for (final row in rows) _InfoRow(row),
|
||||||
textAlign: TextAlign.right,
|
],
|
||||||
style: const TextStyle(
|
),
|
||||||
fontSize: 12,
|
),
|
||||||
fontFamily: 'monospace',
|
);
|
||||||
fontWeight: FontWeight.w600,
|
}
|
||||||
),
|
}
|
||||||
),
|
|
||||||
),
|
class _InfoRow extends StatelessWidget {
|
||||||
],
|
final _RowData row;
|
||||||
),
|
|
||||||
);
|
const _InfoRow(this.row);
|
||||||
}
|
|
||||||
}
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
class _RawHexBlock extends StatelessWidget {
|
return Padding(
|
||||||
final String hex;
|
padding: const EdgeInsets.symmetric(vertical: 3),
|
||||||
|
child: Row(
|
||||||
const _RawHexBlock({required this.hex});
|
children: [
|
||||||
|
Expanded(
|
||||||
@override
|
child: Text(
|
||||||
Widget build(BuildContext context) {
|
row.label,
|
||||||
return Card(
|
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
||||||
child: Padding(
|
),
|
||||||
padding: const EdgeInsets.all(12),
|
),
|
||||||
child: SelectableText(
|
const SizedBox(width: 12),
|
||||||
hex,
|
Flexible(
|
||||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 11),
|
child: Text(
|
||||||
),
|
row.value,
|
||||||
),
|
textAlign: TextAlign.right,
|
||||||
);
|
style: const TextStyle(
|
||||||
}
|
fontSize: 12,
|
||||||
}
|
fontFamily: 'monospace',
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
class _RowData {
|
),
|
||||||
final String label;
|
),
|
||||||
final String value;
|
),
|
||||||
|
],
|
||||||
const _RowData(this.label, this.value);
|
),
|
||||||
}
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,232 +1,243 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../../core/protocol/dtc_map.dart';
|
import '../../../core/protocol/dtc_map.dart';
|
||||||
import '../../../core/providers/bt_provider.dart';
|
import '../../../core/providers/bt_provider.dart';
|
||||||
import '../../../core/providers/sensor_provider.dart';
|
import '../../../core/providers/sensor_provider.dart';
|
||||||
import '../../../core/providers/settings_provider.dart';
|
import '../../../core/providers/datalog_provider.dart';
|
||||||
import '../../../core/bluetooth/bt_poller.dart';
|
import '../../theme/app_colors.dart';
|
||||||
import 'widgets/dtc_row.dart';
|
import '../../../core/bluetooth/bt_poller.dart';
|
||||||
|
import 'widgets/dtc_row.dart';
|
||||||
class DtcScreen extends ConsumerStatefulWidget {
|
|
||||||
const DtcScreen({super.key});
|
class DtcScreen extends ConsumerStatefulWidget {
|
||||||
|
const DtcScreen({super.key});
|
||||||
@override
|
|
||||||
ConsumerState<DtcScreen> createState() => _DtcScreenState();
|
@override
|
||||||
}
|
ConsumerState<DtcScreen> createState() => _DtcScreenState();
|
||||||
|
}
|
||||||
class _DtcScreenState extends ConsumerState<DtcScreen> {
|
|
||||||
bool _clearing = false;
|
class _DtcScreenState extends ConsumerState<DtcScreen> {
|
||||||
Uint8List? _lastRaw;
|
bool _clearing = false;
|
||||||
|
Uint8List? _lastRaw;
|
||||||
String _hex(Uint8List bytes) {
|
|
||||||
final parts = <String>[];
|
String _hex(Uint8List bytes) {
|
||||||
for (var i = 0; i < bytes.length; i++) {
|
final parts = <String>[];
|
||||||
if (i > 0 && i % 16 == 0) parts.add('\n');
|
for (var i = 0; i < bytes.length; i++) {
|
||||||
parts.add(bytes[i].toRadixString(16).padLeft(2, '0'));
|
if (i > 0 && i % 16 == 0) parts.add('\n');
|
||||||
}
|
parts.add(bytes[i].toRadixString(16).padLeft(2, '0'));
|
||||||
return parts.join(' ');
|
}
|
||||||
}
|
return parts.join(' ');
|
||||||
|
}
|
||||||
void _showRawCapture() {
|
|
||||||
final raw = _lastRaw;
|
void _showRawCapture() {
|
||||||
if (raw == null) return;
|
final raw = _lastRaw;
|
||||||
final hex = _hex(raw);
|
if (raw == null) return;
|
||||||
showDialog<void>(
|
final hex = _hex(raw);
|
||||||
context: context,
|
showDialog<void>(
|
||||||
builder: (context) => AlertDialog(
|
context: context,
|
||||||
title: Text('Raw capture (${raw.length} bytes)'),
|
builder: (context) => AlertDialog(
|
||||||
content: SizedBox(
|
title: Text('Raw capture (${raw.length} bytes)'),
|
||||||
width: double.maxFinite,
|
content: SizedBox(
|
||||||
child: SingleChildScrollView(
|
width: double.maxFinite,
|
||||||
child: SelectableText(
|
child: SingleChildScrollView(
|
||||||
hex,
|
child: SelectableText(
|
||||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 11),
|
hex,
|
||||||
),
|
style: const TextStyle(fontFamily: 'monospace', fontSize: 11),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
actions: [
|
),
|
||||||
TextButton(
|
actions: [
|
||||||
onPressed: () async {
|
TextButton(
|
||||||
await Clipboard.setData(ClipboardData(text: hex));
|
onPressed: () async {
|
||||||
if (context.mounted) {
|
await Clipboard.setData(ClipboardData(text: hex));
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
if (context.mounted) {
|
||||||
const SnackBar(content: Text('Copied')),
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
);
|
const SnackBar(content: Text('Copied')),
|
||||||
}
|
);
|
||||||
},
|
}
|
||||||
child: const Text('Copy'),
|
},
|
||||||
),
|
child: const Text('Copy'),
|
||||||
TextButton(
|
),
|
||||||
onPressed: () => Navigator.pop(context),
|
TextButton(
|
||||||
child: const Text('Close'),
|
onPressed: () => Navigator.pop(context),
|
||||||
),
|
child: const Text('Close'),
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
);
|
),
|
||||||
}
|
);
|
||||||
|
}
|
||||||
Future<void> _confirmAndClear() async {
|
|
||||||
final confirmed = await showDialog<bool>(
|
Future<void> _confirmAndClear() async {
|
||||||
context: context,
|
final confirmed = await showDialog<bool>(
|
||||||
builder: (context) => AlertDialog(
|
context: context,
|
||||||
title: const Text('Clear Trouble Codes'),
|
builder: (context) => AlertDialog(
|
||||||
content: const Text(
|
title: const Text('Clear Trouble Codes'),
|
||||||
'This sends a reset command to the ECU and erases all stored '
|
content: const Text(
|
||||||
'fault codes. This cannot be undone.',
|
'This sends a reset command to the ECU and erases all stored '
|
||||||
),
|
'fault codes. This cannot be undone.',
|
||||||
actions: [
|
),
|
||||||
TextButton(
|
actions: [
|
||||||
onPressed: () => Navigator.pop(context, false),
|
TextButton(
|
||||||
child: const Text('Cancel'),
|
onPressed: () => Navigator.pop(context, false),
|
||||||
),
|
child: const Text('Cancel'),
|
||||||
TextButton(
|
),
|
||||||
onPressed: () => Navigator.pop(context, true),
|
TextButton(
|
||||||
child: const Text('Clear',
|
onPressed: () => Navigator.pop(context, true),
|
||||||
style: TextStyle(color: Colors.red)),
|
child: const Text('Clear',
|
||||||
),
|
style: TextStyle(color: Colors.red)),
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
);
|
),
|
||||||
if (confirmed != true) return;
|
);
|
||||||
|
if (confirmed != true) return;
|
||||||
setState(() => _clearing = true);
|
|
||||||
final result = await ref.read(btProvider.notifier).clearDtcs();
|
setState(() => _clearing = true);
|
||||||
if (!mounted) return;
|
final result = await ref.read(btProvider.notifier).clearDtcs();
|
||||||
setState(() {
|
if (!mounted) return;
|
||||||
_clearing = false;
|
setState(() {
|
||||||
_lastRaw = result.raw;
|
_clearing = false;
|
||||||
});
|
_lastRaw = result.raw;
|
||||||
|
});
|
||||||
final String message;
|
|
||||||
if (result.acknowledged) {
|
// Success is judged from the live data: the adapter does not send the
|
||||||
message = 'Trouble codes cleared';
|
// ack. Codes for faults that are still present return right away.
|
||||||
} else if (result.raw.isEmpty) {
|
final String message;
|
||||||
message = 'No response from ECU — codes may not have cleared';
|
if (result.codesBefore == 0) {
|
||||||
} else {
|
message = 'No stored trouble codes to clear';
|
||||||
// The scan runs alongside normal polling now, so "not acknowledged"
|
} else if (result.codesCleared > 0) {
|
||||||
// can mean several seconds' worth of regular datalog traffic with no
|
final cleared = result.codesCleared;
|
||||||
// ack byte pattern found in it — dumping all of that raw would be
|
final back = result.codesBefore - cleared;
|
||||||
// unusable in a snackbar, so just report how much was seen. "View"
|
message = back == 0
|
||||||
// opens the full hex to check by hand for anything our narrow 3-byte
|
? 'Trouble codes cleared'
|
||||||
// pattern match might have missed.
|
: '$cleared of ${result.codesBefore} codes cleared — $back came '
|
||||||
message =
|
'straight back (active fault, fix the cause first)';
|
||||||
'No ack seen — ${result.raw.length} bytes of other traffic during the window';
|
} else if (result.acknowledged) {
|
||||||
}
|
message = 'ECU confirmed the reset, but every code came straight back '
|
||||||
|
'(active faults)';
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
} else if (result.raw.isEmpty) {
|
||||||
SnackBar(
|
// Can't tell apart from "every fault re-set before the next frame".
|
||||||
content: Text(message),
|
message = 'No codes cleared — the ECU ignored the reset, or every '
|
||||||
action: (!result.acknowledged && result.raw.isNotEmpty)
|
'fault is still active';
|
||||||
? SnackBarAction(label: 'View', onPressed: _showRawCapture)
|
} else {
|
||||||
: null,
|
// "View" opens the raw reply hex to check by hand.
|
||||||
duration: const Duration(seconds: 6),
|
message = 'Unexpected reply (${result.raw.length} bytes) — codes did '
|
||||||
),
|
'not clear';
|
||||||
);
|
}
|
||||||
}
|
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@override
|
SnackBar(
|
||||||
Widget build(BuildContext context) {
|
content: Text(message),
|
||||||
final state = ref.watch(latestSensorProvider);
|
action: (!result.acknowledged && result.raw.isNotEmpty)
|
||||||
final bt = ref.watch(btProvider);
|
? SnackBarAction(label: 'View', onPressed: _showRawCapture)
|
||||||
final ecuType = ref.watch(settingsProvider).ecuType;
|
: null,
|
||||||
|
duration: const Duration(seconds: 6),
|
||||||
final dtcMap =
|
),
|
||||||
ecuType == EcuType.s300 ? s300DtcMap : kproDtcMap;
|
);
|
||||||
final active = decodeDtcs(state.errBytes, dtcMap);
|
}
|
||||||
|
|
||||||
return Column(
|
@override
|
||||||
children: [
|
Widget build(BuildContext context) {
|
||||||
// Header bar
|
final state = ref.watch(latestSensorProvider);
|
||||||
Container(
|
final bt = ref.watch(btProvider);
|
||||||
color: const Color(0xFF1A1A1A),
|
final ecuType = ref.watch(effectiveEcuTypeProvider);
|
||||||
padding:
|
final c = AppColors.of(context);
|
||||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
|
||||||
child: Row(
|
final dtcMap =
|
||||||
children: [
|
ecuType == EcuType.s300 ? s300DtcMap : kproDtcMap;
|
||||||
Icon(
|
final active = decodeDtcs(state.errBytes, dtcMap);
|
||||||
active.isEmpty
|
|
||||||
? Icons.check_circle_outline
|
return Column(
|
||||||
: Icons.warning_amber_rounded,
|
children: [
|
||||||
color: active.isEmpty ? Colors.green : Colors.amber,
|
// Header bar
|
||||||
size: 18,
|
Container(
|
||||||
),
|
color: c.surface,
|
||||||
const SizedBox(width: 8),
|
padding:
|
||||||
Text(
|
const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||||
active.isEmpty
|
child: Row(
|
||||||
? 'No active fault codes'
|
children: [
|
||||||
: '${active.length} active DTC${active.length > 1 ? "s" : ""}',
|
Icon(
|
||||||
style: TextStyle(
|
active.isEmpty
|
||||||
color: active.isEmpty ? Colors.green : Colors.amber,
|
? Icons.check_circle_outline
|
||||||
fontSize: 13,
|
: Icons.warning_amber_rounded,
|
||||||
fontWeight: FontWeight.w600,
|
color: active.isEmpty ? Colors.green : Colors.amber,
|
||||||
),
|
size: 18,
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
ecuType == EcuType.s300 ? 'S300' : 'KPro',
|
active.isEmpty
|
||||||
style:
|
? 'No active fault codes'
|
||||||
const TextStyle(color: Colors.grey, fontSize: 12),
|
: '${active.length} active DTC${active.length > 1 ? "s" : ""}',
|
||||||
),
|
style: TextStyle(
|
||||||
],
|
color: active.isEmpty ? Colors.green : Colors.amber,
|
||||||
),
|
fontSize: 13,
|
||||||
),
|
fontWeight: FontWeight.w600,
|
||||||
const Divider(height: 1, color: Colors.white10),
|
),
|
||||||
|
),
|
||||||
// DTC list
|
const Spacer(),
|
||||||
Expanded(
|
Text(
|
||||||
child: active.isEmpty
|
ecuType == EcuType.s300 ? 'S300' : 'KPro',
|
||||||
? const Center(
|
style:
|
||||||
child: Column(
|
TextStyle(color: c.onSurfaceMuted, fontSize: 12),
|
||||||
mainAxisSize: MainAxisSize.min,
|
),
|
||||||
children: [
|
],
|
||||||
Icon(Icons.check_circle_outline,
|
),
|
||||||
size: 56, color: Colors.green),
|
),
|
||||||
SizedBox(height: 12),
|
Divider(height: 1, color: c.divider),
|
||||||
Text('All clear — no fault codes',
|
|
||||||
style: TextStyle(
|
// DTC list
|
||||||
color: Colors.grey, fontSize: 15)),
|
Expanded(
|
||||||
],
|
child: active.isEmpty
|
||||||
),
|
? const Center(
|
||||||
)
|
child: Column(
|
||||||
: ListView.separated(
|
mainAxisSize: MainAxisSize.min,
|
||||||
itemCount: active.length,
|
children: [
|
||||||
separatorBuilder: (_, __) =>
|
Icon(Icons.check_circle_outline,
|
||||||
const Divider(height: 1, color: Colors.white10),
|
size: 56, color: Colors.green),
|
||||||
itemBuilder: (context, i) {
|
SizedBox(height: 12),
|
||||||
final full = active[i]; // "P0130 — O2 Sensor (front)"
|
Text('All clear — no fault codes',
|
||||||
final parts = full.split(' — ');
|
style: TextStyle(
|
||||||
final code = parts.first.trim();
|
color: Colors.grey, fontSize: 15)),
|
||||||
final desc =
|
],
|
||||||
parts.length > 1 ? parts.sublist(1).join(' — ') : '';
|
),
|
||||||
return DtcRow(code: code, description: desc);
|
)
|
||||||
},
|
: ListView.separated(
|
||||||
),
|
itemCount: active.length,
|
||||||
),
|
separatorBuilder: (_, __) =>
|
||||||
SafeArea(
|
Divider(height: 1, color: c.divider),
|
||||||
top: false,
|
itemBuilder: (context, i) {
|
||||||
child: Padding(
|
final full = active[i]; // "P0130 — O2 Sensor (front)"
|
||||||
padding: const EdgeInsets.all(16),
|
final parts = full.split(' — ');
|
||||||
child: SizedBox(
|
final code = parts.first.trim();
|
||||||
width: double.infinity,
|
final desc =
|
||||||
child: ElevatedButton(
|
parts.length > 1 ? parts.sublist(1).join(' — ') : '';
|
||||||
style: ElevatedButton.styleFrom(
|
return DtcRow(code: code, description: desc);
|
||||||
backgroundColor: Colors.red,
|
},
|
||||||
foregroundColor: Colors.white,
|
),
|
||||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
),
|
||||||
),
|
SafeArea(
|
||||||
onPressed: (bt.isConnected && !_clearing)
|
top: false,
|
||||||
? _confirmAndClear
|
child: Padding(
|
||||||
: null,
|
padding: const EdgeInsets.all(16),
|
||||||
child: Text(
|
child: SizedBox(
|
||||||
_clearing ? 'Clearing…' : 'CLEAR TROUBLE CODES',
|
width: double.infinity,
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
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,44 +1,46 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../../theme/app_colors.dart';
|
||||||
class DtcRow extends StatelessWidget {
|
|
||||||
final String code;
|
class DtcRow extends StatelessWidget {
|
||||||
final String description;
|
final String code;
|
||||||
|
final String description;
|
||||||
const DtcRow({super.key, required this.code, required this.description});
|
|
||||||
|
const DtcRow({super.key, required this.code, required this.description});
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
@override
|
||||||
return Padding(
|
Widget build(BuildContext context) {
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
return Padding(
|
||||||
child: Row(
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
child: Row(
|
||||||
children: [
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
Container(
|
children: [
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
Container(
|
||||||
decoration: BoxDecoration(
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||||
color: Colors.red.withAlpha(30),
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(6),
|
color: Colors.red.withAlpha(30),
|
||||||
border: Border.all(color: Colors.red.withAlpha(100)),
|
borderRadius: BorderRadius.circular(6),
|
||||||
),
|
border: Border.all(color: Colors.red.withAlpha(100)),
|
||||||
child: Text(
|
),
|
||||||
code,
|
child: Text(
|
||||||
style: const TextStyle(
|
code,
|
||||||
color: Colors.redAccent,
|
style: const TextStyle(
|
||||||
fontSize: 12,
|
color: Colors.redAccent,
|
||||||
fontWeight: FontWeight.bold,
|
fontSize: 12,
|
||||||
fontFamily: 'monospace',
|
fontWeight: FontWeight.bold,
|
||||||
),
|
fontFamily: 'monospace',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
),
|
||||||
Expanded(
|
const SizedBox(width: 12),
|
||||||
child: Text(
|
Expanded(
|
||||||
description,
|
child: Text(
|
||||||
style: const TextStyle(color: Colors.white70, fontSize: 13),
|
description,
|
||||||
),
|
style: TextStyle(
|
||||||
),
|
color: AppColors.of(context).onSurface, fontSize: 13),
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
);
|
],
|
||||||
}
|
),
|
||||||
}
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,136 +1,103 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../../core/models/sensor_defs.dart';
|
import '../../../core/models/units.dart';
|
||||||
import '../../../core/providers/sensor_history_provider.dart';
|
import '../../../core/providers/sensor_history_provider.dart';
|
||||||
import '../../../core/providers/theme_provider.dart';
|
import '../../../core/providers/settings_provider.dart';
|
||||||
import '../../../ui/theme/app_colors.dart';
|
import '../../../ui/theme/app_colors.dart';
|
||||||
import 'widgets/graph_time_selector.dart';
|
import '../dashboard/widgets/sensor_picker_sheet.dart';
|
||||||
import 'widgets/sensor_graph_panel.dart';
|
import 'widgets/graph_time_selector.dart';
|
||||||
|
import 'widgets/sensor_graph_panel.dart';
|
||||||
final _graphWindowProvider = StateProvider<int>((ref) => 60);
|
|
||||||
final _graphSensorsProvider = StateProvider<List<String>>(
|
final _graphWindowProvider = StateProvider<int>((ref) => 60);
|
||||||
(ref) => ['rpm', 'map', 'tps'],
|
final _graphSensorsProvider = StateProvider<List<String>>(
|
||||||
);
|
(ref) => ['rpm', 'map', 'tps', 'ect'],
|
||||||
|
);
|
||||||
class GraphScreen extends ConsumerWidget {
|
|
||||||
const GraphScreen({super.key});
|
class GraphScreen extends ConsumerWidget {
|
||||||
|
const GraphScreen({super.key});
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
@override
|
||||||
final history = ref.watch(sensorHistoryProvider);
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final colors = AppColors.forTheme(ref.watch(themeProvider));
|
final history = ref.watch(sensorHistoryProvider);
|
||||||
final window = ref.watch(_graphWindowProvider);
|
final colors = AppColors.of(context);
|
||||||
final sensors = ref.watch(_graphSensorsProvider);
|
final units = ref.watch(unitPrefsProvider);
|
||||||
|
final window = ref.watch(_graphWindowProvider);
|
||||||
return Column(
|
final sensors = ref.watch(_graphSensorsProvider);
|
||||||
children: [
|
|
||||||
// Time selector bar
|
return Column(
|
||||||
Container(
|
children: [
|
||||||
color: const Color(0xFF1A1A1A),
|
// Time selector bar
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
Container(
|
||||||
child: Row(
|
color: colors.surface,
|
||||||
children: [
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
const Text('Window:',
|
child: Row(
|
||||||
style: TextStyle(color: Colors.grey, fontSize: 12)),
|
children: [
|
||||||
const SizedBox(width: 8),
|
Text('Window:',
|
||||||
GraphTimeSelector(
|
style: TextStyle(color: colors.onSurfaceMuted, fontSize: 12)),
|
||||||
windowSeconds: window,
|
const SizedBox(width: 8),
|
||||||
onChanged: (s) =>
|
GraphTimeSelector(
|
||||||
ref.read(_graphWindowProvider.notifier).state = s,
|
windowSeconds: window,
|
||||||
),
|
onChanged: (s) =>
|
||||||
const Spacer(),
|
ref.read(_graphWindowProvider.notifier).state = s,
|
||||||
IconButton(
|
),
|
||||||
icon: Icon(Icons.add_chart, color: colors.accent, size: 20),
|
const Spacer(),
|
||||||
tooltip: 'Add sensor',
|
IconButton(
|
||||||
onPressed: () => _addSensor(context, ref, sensors),
|
icon: Icon(Icons.add_chart, color: colors.accent, size: 20),
|
||||||
padding: EdgeInsets.zero,
|
tooltip: 'Add sensor',
|
||||||
constraints: const BoxConstraints(),
|
onPressed: () => _addSensor(context, ref, sensors),
|
||||||
),
|
padding: EdgeInsets.zero,
|
||||||
],
|
constraints: const BoxConstraints(),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
// Graph panels
|
),
|
||||||
Expanded(
|
),
|
||||||
child: sensors.isEmpty
|
// Graph panels
|
||||||
? Center(
|
Expanded(
|
||||||
child: Column(
|
child: sensors.isEmpty
|
||||||
mainAxisSize: MainAxisSize.min,
|
? Center(
|
||||||
children: [
|
child: Column(
|
||||||
Icon(Icons.show_chart,
|
mainAxisSize: MainAxisSize.min,
|
||||||
size: 48, color: colors.accent.withAlpha(80)),
|
children: [
|
||||||
const SizedBox(height: 12),
|
Icon(Icons.show_chart,
|
||||||
const Text('No sensors selected',
|
size: 48, color: colors.accent.withAlpha(80)),
|
||||||
style: TextStyle(color: Colors.grey)),
|
const SizedBox(height: 12),
|
||||||
const SizedBox(height: 8),
|
Text('No sensors selected',
|
||||||
ElevatedButton.icon(
|
style: TextStyle(color: colors.onSurfaceMuted)),
|
||||||
onPressed: () => _addSensor(context, ref, sensors),
|
const SizedBox(height: 8),
|
||||||
icon: const Icon(Icons.add, size: 16),
|
ElevatedButton.icon(
|
||||||
label: const Text('Add sensor'),
|
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(
|
: ListView.builder(
|
||||||
sensorId: sensors[i],
|
itemCount: sensors.length,
|
||||||
history: history,
|
itemBuilder: (context, i) => SensorGraphPanel(
|
||||||
windowSeconds: window,
|
view: sensorView(sensors[i], units),
|
||||||
accentColor: colors.accent,
|
history: history,
|
||||||
onRemove: () {
|
windowSeconds: window,
|
||||||
final next = List<String>.from(sensors)..removeAt(i);
|
onRemove: () {
|
||||||
ref.read(_graphSensorsProvider.notifier).state = next;
|
final next = List<String>.from(sensors)..removeAt(i);
|
||||||
},
|
ref.read(_graphSensorsProvider.notifier).state = next;
|
||||||
),
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
);
|
],
|
||||||
}
|
);
|
||||||
|
}
|
||||||
void _addSensor(
|
|
||||||
BuildContext context, WidgetRef ref, List<String> current) {
|
Future<void> _addSensor(
|
||||||
showModalBottomSheet<void>(
|
BuildContext context, WidgetRef ref, List<String> current) async {
|
||||||
context: context,
|
final id = await pickSensor(
|
||||||
backgroundColor: const Color(0xFF1A1A1A),
|
context,
|
||||||
shape: const RoundedRectangleBorder(
|
title: 'ADD SENSOR TO GRAPH',
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
currentId: '',
|
||||||
),
|
units: ref.read(unitPrefsProvider),
|
||||||
builder: (_) => ListView(
|
);
|
||||||
children: [
|
if (id == null || current.contains(id)) return;
|
||||||
const Padding(
|
ref.read(_graphSensorsProvider.notifier).state = [...current, id];
|
||||||
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';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
class GraphTimeSelector extends StatelessWidget {
|
class GraphTimeSelector extends StatelessWidget {
|
||||||
final int windowSeconds;
|
final int windowSeconds;
|
||||||
final void Function(int seconds) onChanged;
|
final void Function(int seconds) onChanged;
|
||||||
|
|
||||||
static const _options = [30, 60, 120, 300];
|
static const _options = [30, 60, 120, 300];
|
||||||
|
|
||||||
const GraphTimeSelector({
|
const GraphTimeSelector({
|
||||||
super.key,
|
super.key,
|
||||||
required this.windowSeconds,
|
required this.windowSeconds,
|
||||||
required this.onChanged,
|
required this.onChanged,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final accent = Theme.of(context).colorScheme.primary;
|
final accent = Theme.of(context).colorScheme.primary;
|
||||||
return Row(
|
return Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: _options.map((s) {
|
children: _options.map((s) {
|
||||||
final selected = s == windowSeconds;
|
final selected = s == windowSeconds;
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () => onChanged(s),
|
onTap: () => onChanged(s),
|
||||||
child: AnimatedContainer(
|
child: AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 120),
|
duration: const Duration(milliseconds: 120),
|
||||||
margin: const EdgeInsets.only(left: 4),
|
margin: const EdgeInsets.only(left: 4),
|
||||||
padding:
|
padding:
|
||||||
const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: selected ? accent.withAlpha(30) : Colors.transparent,
|
color: selected ? accent.withAlpha(30) : Colors.transparent,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: selected ? accent : Colors.white24,
|
color: selected ? accent : Theme.of(context).dividerColor,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
s >= 60 ? '${s ~/ 60}m' : '${s}s',
|
s >= 60 ? '${s ~/ 60}m' : '${s}s',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: selected ? accent : Colors.white54,
|
color: selected ? accent : Theme.of(context).hintColor,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: selected ? FontWeight.bold : FontWeight.normal,
|
fontWeight: selected ? FontWeight.bold : FontWeight.normal,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,133 +1,203 @@
|
|||||||
import 'package:fl_chart/fl_chart.dart';
|
import 'dart:math' as math;
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import '../../../../core/models/sensor_defs.dart';
|
import 'package:fl_chart/fl_chart.dart';
|
||||||
import '../../../../core/providers/sensor_history_provider.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../../../core/models/units.dart';
|
||||||
class SensorGraphPanel extends StatelessWidget {
|
import '../../../../core/providers/sensor_history_provider.dart';
|
||||||
final String sensorId;
|
import '../../../theme/app_colors.dart';
|
||||||
final SensorHistory history;
|
|
||||||
final int windowSeconds;
|
/// One graph (requirements): line over the selected time window with the
|
||||||
final Color accentColor;
|
/// window's max/min top-left, current value and name bottom-left, value
|
||||||
final VoidCallback onRemove;
|
/// axis on the right, seconds-ago axis along the bottom, and a touch cursor
|
||||||
|
/// that reads out the value under the finger.
|
||||||
const SensorGraphPanel({
|
class SensorGraphPanel extends StatelessWidget {
|
||||||
super.key,
|
final SensorView view;
|
||||||
required this.sensorId,
|
final SensorHistory history;
|
||||||
required this.history,
|
final int windowSeconds;
|
||||||
required this.windowSeconds,
|
final VoidCallback onRemove;
|
||||||
required this.accentColor,
|
|
||||||
required this.onRemove,
|
const SensorGraphPanel({
|
||||||
});
|
super.key,
|
||||||
|
required this.view,
|
||||||
@override
|
required this.history,
|
||||||
Widget build(BuildContext context) {
|
required this.windowSeconds,
|
||||||
final def = sensorDefs.firstWhere(
|
required this.onRemove,
|
||||||
(d) => d.id == sensorId,
|
});
|
||||||
orElse: () => sensorDefs.first,
|
|
||||||
);
|
@override
|
||||||
final allSpots = history.get(sensorId);
|
Widget build(BuildContext context) {
|
||||||
|
final c = AppColors.of(context);
|
||||||
// Slice to window
|
final raw = history.get(view.def.id);
|
||||||
final spots = _window(allSpots, windowSeconds.toDouble());
|
final now = raw.isEmpty ? 0.0 : raw.last.x;
|
||||||
|
// x = seconds before the latest sample (0 at the right edge).
|
||||||
return Container(
|
final spots = [
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
for (final s in raw)
|
||||||
padding: const EdgeInsets.fromLTRB(8, 10, 12, 8),
|
if (now - s.x <= windowSeconds) FlSpot(s.x - now, view.convert(s.y)),
|
||||||
decoration: BoxDecoration(
|
];
|
||||||
color: const Color(0xFF1A1A1A),
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
double? lo, hi;
|
||||||
),
|
for (final s in spots) {
|
||||||
child: Column(
|
lo = lo == null ? s.y : math.min(lo, s.y);
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
hi = hi == null ? s.y : math.max(hi, s.y);
|
||||||
children: [
|
}
|
||||||
// Header
|
// Y axis follows the data in the window (like the reference), with a
|
||||||
Row(
|
// margin, and never collapses to zero height for a steady value.
|
||||||
children: [
|
var minY = view.min, maxY = view.max;
|
||||||
Text(
|
if (lo != null && hi != null) {
|
||||||
def.displayName.toUpperCase(),
|
final pad = math.max((hi - lo) * 0.15, (view.max - view.min) * 0.02);
|
||||||
style: TextStyle(
|
minY = lo - pad;
|
||||||
color: accentColor,
|
maxY = hi + pad;
|
||||||
fontSize: 11,
|
}
|
||||||
letterSpacing: 1.5,
|
final muted = TextStyle(color: c.onSurfaceMuted, fontSize: 11);
|
||||||
fontWeight: FontWeight.w600,
|
String f(double v) => v.toStringAsFixed(view.decimals);
|
||||||
),
|
|
||||||
),
|
return Container(
|
||||||
const SizedBox(width: 6),
|
height: 170,
|
||||||
Text(
|
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
def.unit,
|
padding: const EdgeInsets.fromLTRB(8, 6, 4, 4),
|
||||||
style: const TextStyle(color: Colors.grey, fontSize: 11),
|
decoration: BoxDecoration(
|
||||||
),
|
color: c.surface,
|
||||||
const Spacer(),
|
borderRadius: BorderRadius.circular(8),
|
||||||
if (spots.isNotEmpty)
|
border: Border.all(color: c.divider),
|
||||||
Text(
|
),
|
||||||
_fmt(spots.last.y),
|
child: Stack(
|
||||||
style: const TextStyle(
|
children: [
|
||||||
color: Colors.white,
|
Padding(
|
||||||
fontSize: 14,
|
padding: const EdgeInsets.only(bottom: 4),
|
||||||
fontWeight: FontWeight.bold,
|
child: spots.length < 2
|
||||||
),
|
? Center(child: Text('Waiting for data…', style: muted))
|
||||||
),
|
: LineChart(
|
||||||
const SizedBox(width: 8),
|
LineChartData(
|
||||||
GestureDetector(
|
minX: -windowSeconds.toDouble(),
|
||||||
onTap: onRemove,
|
maxX: 0,
|
||||||
child: const Icon(Icons.close,
|
minY: minY,
|
||||||
size: 16, color: Colors.white38),
|
maxY: maxY,
|
||||||
),
|
clipData: const FlClipData.all(),
|
||||||
],
|
gridData: FlGridData(
|
||||||
),
|
show: true,
|
||||||
const SizedBox(height: 8),
|
getDrawingHorizontalLine: (_) =>
|
||||||
// Chart
|
FlLine(color: c.divider, strokeWidth: 1),
|
||||||
SizedBox(
|
getDrawingVerticalLine: (_) =>
|
||||||
height: 90,
|
FlLine(color: c.divider, strokeWidth: 1),
|
||||||
child: spots.length < 2
|
),
|
||||||
? const Center(
|
borderData: FlBorderData(show: false),
|
||||||
child: Text('Waiting for data…',
|
titlesData: FlTitlesData(
|
||||||
style: TextStyle(color: Colors.white30,
|
topTitles: const AxisTitles(),
|
||||||
fontSize: 12)))
|
leftTitles: const AxisTitles(),
|
||||||
: LineChart(
|
rightTitles: AxisTitles(
|
||||||
LineChartData(
|
sideTitles: SideTitles(
|
||||||
gridData: FlGridData(
|
showTitles: true,
|
||||||
show: true,
|
reservedSize: 40,
|
||||||
drawVerticalLine: false,
|
getTitlesWidget: (v, meta) => Text(
|
||||||
getDrawingHorizontalLine: (_) => FlLine(
|
_compact(v),
|
||||||
color: Colors.white10,
|
style: muted.copyWith(fontSize: 9),
|
||||||
strokeWidth: 1,
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
borderData: FlBorderData(show: false),
|
bottomTitles: AxisTitles(
|
||||||
titlesData: const FlTitlesData(show: false),
|
sideTitles: SideTitles(
|
||||||
minY: def.min.toDouble(),
|
showTitles: true,
|
||||||
maxY: def.max.toDouble(),
|
reservedSize: 16,
|
||||||
lineBarsData: [
|
interval: windowSeconds / 6,
|
||||||
LineChartBarData(
|
getTitlesWidget: (v, meta) => Text(
|
||||||
spots: spots,
|
(-v).round().toString(),
|
||||||
isCurved: true,
|
style: muted.copyWith(fontSize: 9),
|
||||||
curveSmoothness: 0.2,
|
),
|
||||||
color: accentColor,
|
),
|
||||||
barWidth: 1.5,
|
),
|
||||||
dotData: const FlDotData(show: false),
|
),
|
||||||
belowBarData: BarAreaData(
|
lineTouchData: LineTouchData(
|
||||||
show: true,
|
getTouchedSpotIndicator: (bar, indexes) => [
|
||||||
color: accentColor.withAlpha(25),
|
for (final _ in indexes)
|
||||||
),
|
const TouchedSpotIndicatorData(
|
||||||
),
|
FlLine(color: Colors.red, strokeWidth: 1.5),
|
||||||
],
|
FlDotData(show: true),
|
||||||
),
|
),
|
||||||
duration: Duration.zero,
|
],
|
||||||
),
|
touchTooltipData: LineTouchTooltipData(
|
||||||
),
|
getTooltipColor: (_) => Colors.red,
|
||||||
],
|
tooltipPadding: const EdgeInsets.symmetric(
|
||||||
),
|
horizontal: 4, vertical: 2),
|
||||||
);
|
getTooltipItems: (touched) => [
|
||||||
}
|
for (final t in touched)
|
||||||
|
LineTooltipItem(
|
||||||
List<FlSpot> _window(List<FlSpot> spots, double windowSec) {
|
f(t.y),
|
||||||
if (spots.isEmpty) return spots;
|
const TextStyle(
|
||||||
final maxX = spots.last.x;
|
color: Colors.white, fontSize: 11),
|
||||||
final minX = maxX - windowSec;
|
),
|
||||||
return spots.where((s) => s.x >= minX).toList();
|
],
|
||||||
}
|
),
|
||||||
|
),
|
||||||
String _fmt(double v) =>
|
lineBarsData: [
|
||||||
v.abs() >= 100 ? v.toStringAsFixed(1) : v.toStringAsFixed(2);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,31 +1,39 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../../core/models/sensor_defs.dart';
|
import '../../../core/models/sensor_defs.dart';
|
||||||
import '../../../core/providers/sensor_provider.dart';
|
import '../../../core/models/units.dart';
|
||||||
import '../../../core/providers/theme_provider.dart';
|
import '../../../core/providers/sensor_provider.dart';
|
||||||
import '../../../ui/theme/app_colors.dart';
|
import '../../../core/providers/settings_provider.dart';
|
||||||
import 'widgets/sensor_row.dart';
|
import 'widgets/sensor_row.dart';
|
||||||
|
|
||||||
class SensorListScreen extends ConsumerWidget {
|
/// All sensors as a list (requirements): 1 column on a phone in portrait,
|
||||||
const SensorListScreen({super.key});
|
/// 2 columns on a tablet in portrait and in landscape.
|
||||||
|
class SensorListScreen extends ConsumerWidget {
|
||||||
@override
|
const SensorListScreen({super.key});
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final state = ref.watch(latestSensorProvider);
|
@override
|
||||||
final colors = AppColors.forTheme(ref.watch(themeProvider));
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final state = ref.watch(latestSensorProvider);
|
||||||
return ListView.separated(
|
final units = ref.watch(unitPrefsProvider);
|
||||||
itemCount: sensorDefs.length,
|
final media = MediaQuery.of(context);
|
||||||
separatorBuilder: (_, __) =>
|
final isTablet = media.size.shortestSide >= 600;
|
||||||
const Divider(height: 1, color: Colors.white10),
|
final isLandscape = media.orientation == Orientation.landscape;
|
||||||
itemBuilder: (context, i) {
|
final columns = (isTablet || isLandscape) ? 2 : 1;
|
||||||
final def = sensorDefs[i];
|
|
||||||
return SensorRow(
|
return GridView.builder(
|
||||||
def: def,
|
itemCount: sensorDefs.length,
|
||||||
value: sensorValue(state, def.id),
|
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
accentColor: colors.accent,
|
crossAxisCount: columns,
|
||||||
);
|
mainAxisExtent: 72,
|
||||||
},
|
crossAxisSpacing: 8,
|
||||||
);
|
),
|
||||||
}
|
itemBuilder: (context, i) {
|
||||||
}
|
final id = sensorDefs[i].id;
|
||||||
|
return SensorRow(
|
||||||
|
view: sensorView(id, units),
|
||||||
|
baseValue: sensorValue(state, id),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,130 +1,129 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../../../core/models/sensor_def.dart';
|
import '../../../../core/models/units.dart';
|
||||||
import '../../../../core/protocol/sensor_state.dart';
|
import '../../../theme/app_colors.dart';
|
||||||
|
|
||||||
class SensorRow extends StatelessWidget {
|
/// Sensor list entry (requirements): name and unit, min/max of the range,
|
||||||
final SensorDef def;
|
/// a segmented bar gauge and the numeric value.
|
||||||
final double value;
|
class SensorRow extends StatelessWidget {
|
||||||
final Color accentColor;
|
final SensorView view;
|
||||||
|
final double baseValue;
|
||||||
const SensorRow({
|
|
||||||
super.key,
|
const SensorRow({super.key, required this.view, required this.baseValue});
|
||||||
required this.def,
|
|
||||||
required this.value,
|
@override
|
||||||
required this.accentColor,
|
Widget build(BuildContext context) {
|
||||||
});
|
final c = AppColors.of(context);
|
||||||
|
final fraction = view.fraction(baseValue);
|
||||||
@override
|
final isHigh = fraction > 0.9;
|
||||||
Widget build(BuildContext context) {
|
final rangeStyle = TextStyle(color: c.onSurfaceMuted, fontSize: 10);
|
||||||
final normalized =
|
String fmt(double v) => v.toStringAsFixed(view.decimals > 1 ? 1 : 0);
|
||||||
((value - def.min) / (def.max - def.min)).clamp(0.0, 1.0);
|
|
||||||
final isHigh = normalized > 0.8;
|
return Container(
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 6, 12, 6),
|
||||||
return Padding(
|
decoration: BoxDecoration(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
|
border: Border(bottom: BorderSide(color: c.divider)),
|
||||||
child: Row(
|
),
|
||||||
children: [
|
child: Column(
|
||||||
// Label
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
SizedBox(
|
children: [
|
||||||
width: 52,
|
Row(
|
||||||
child: Text(
|
children: [
|
||||||
def.displayName,
|
Expanded(
|
||||||
style: const TextStyle(
|
child: Text(
|
||||||
color: Colors.grey,
|
view.name,
|
||||||
fontSize: 12,
|
maxLines: 1,
|
||||||
letterSpacing: 0.5,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
style: TextStyle(
|
||||||
),
|
color: c.accent,
|
||||||
),
|
fontSize: 14,
|
||||||
const SizedBox(width: 8),
|
fontWeight: FontWeight.w600,
|
||||||
// Bar gauge
|
),
|
||||||
Expanded(
|
),
|
||||||
child: ClipRRect(
|
),
|
||||||
borderRadius: BorderRadius.circular(3),
|
Text(view.unit,
|
||||||
child: Stack(
|
style: TextStyle(color: c.onSurfaceMuted, fontSize: 13)),
|
||||||
children: [
|
],
|
||||||
Container(height: 14, color: Colors.white10),
|
),
|
||||||
FractionallySizedBox(
|
const SizedBox(height: 2),
|
||||||
widthFactor: normalized,
|
Row(
|
||||||
child: Container(
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
height: 14,
|
children: [
|
||||||
decoration: BoxDecoration(
|
Expanded(
|
||||||
color: isHigh
|
child: Column(
|
||||||
? Colors.red.withAlpha(200)
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
: accentColor.withAlpha(180),
|
children: [
|
||||||
borderRadius: BorderRadius.circular(3),
|
Row(
|
||||||
),
|
children: [
|
||||||
),
|
Text(fmt(view.min), style: rangeStyle),
|
||||||
),
|
const Spacer(),
|
||||||
],
|
Text(fmt(view.max), style: rangeStyle),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 2),
|
||||||
const SizedBox(width: 10),
|
_SegmentBar(
|
||||||
// Value + unit
|
fraction: fraction,
|
||||||
SizedBox(
|
color: isHigh ? Colors.red : c.accent,
|
||||||
width: 80,
|
track: c.track,
|
||||||
child: RichText(
|
),
|
||||||
textAlign: TextAlign.right,
|
],
|
||||||
text: TextSpan(
|
),
|
||||||
children: [
|
),
|
||||||
TextSpan(
|
const SizedBox(width: 12),
|
||||||
text: _fmt(value),
|
SizedBox(
|
||||||
style: const TextStyle(
|
width: 90,
|
||||||
color: Colors.white,
|
child: FittedBox(
|
||||||
fontSize: 14,
|
fit: BoxFit.scaleDown,
|
||||||
fontWeight: FontWeight.w600,
|
alignment: Alignment.centerRight,
|
||||||
),
|
child: Text(
|
||||||
),
|
view.format(baseValue),
|
||||||
TextSpan(
|
style: TextStyle(
|
||||||
text: ' ${def.unit}',
|
color: isHigh ? Colors.red : c.onSurface,
|
||||||
style: TextStyle(
|
fontSize: 24,
|
||||||
color: accentColor,
|
fontWeight: FontWeight.bold,
|
||||||
fontSize: 11,
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
);
|
||||||
);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _fmt(double v) {
|
/// Bar made of thin segments, like the reference screenshot.
|
||||||
if (def.id == 'rpm' || def.id == 'gear') return v.toStringAsFixed(0);
|
class _SegmentBar extends StatelessWidget {
|
||||||
if (v.abs() >= 100) return v.toStringAsFixed(1);
|
final double fraction;
|
||||||
return v.toStringAsFixed(2);
|
final Color color;
|
||||||
}
|
final Color track;
|
||||||
}
|
|
||||||
|
const _SegmentBar({
|
||||||
double sensorValue(SensorState s, String id) {
|
required this.fraction,
|
||||||
switch (id) {
|
required this.color,
|
||||||
case 'rpm': return s.rpm;
|
required this.track,
|
||||||
case 'vss': return s.vss;
|
});
|
||||||
case 'map': return s.map;
|
|
||||||
case 'tps': return s.tps;
|
@override
|
||||||
case 'inj': return s.inj;
|
Widget build(BuildContext context) {
|
||||||
case 'ign': return s.ign;
|
return SizedBox(
|
||||||
case 'ect': return s.ect;
|
height: 14,
|
||||||
case 'iat': return s.iat;
|
child: LayoutBuilder(builder: (context, box) {
|
||||||
case 'bat': return s.bat;
|
const segWidth = 3.0;
|
||||||
case 'o2': return s.o2;
|
const gap = 2.0;
|
||||||
case 'gear': return s.gear.toDouble();
|
final count = (box.maxWidth / (segWidth + gap)).floor().clamp(1, 400);
|
||||||
case 'eth': return s.eth;
|
final lit = (fraction * count).round();
|
||||||
case 'pa': return s.pa;
|
return Row(
|
||||||
case 'afr': return s.afr;
|
children: [
|
||||||
case 'strim': return s.strim;
|
for (var i = 0; i < count; i++)
|
||||||
case 'ltrim': return s.ltrim;
|
Container(
|
||||||
case 'ain0': return s.ain0;
|
width: segWidth,
|
||||||
case 'ain1': return s.ain1;
|
margin: const EdgeInsets.only(right: gap),
|
||||||
case 'ain2': return s.ain2;
|
color: i < lit ? color : track,
|
||||||
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,151 +1,233 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../../core/bluetooth/bt_poller.dart';
|
import '../../../core/bluetooth/bt_poller.dart';
|
||||||
import '../../../core/providers/bt_provider.dart';
|
import '../../../core/models/units.dart';
|
||||||
import '../../../core/providers/settings_provider.dart';
|
import '../../../core/providers/bt_provider.dart';
|
||||||
import '../../../core/providers/theme_provider.dart';
|
import '../../../core/providers/settings_provider.dart';
|
||||||
import '../../theme/app_colors.dart';
|
import '../../../core/providers/theme_provider.dart';
|
||||||
import '../bt_picker/bt_picker_screen.dart';
|
import '../../theme/app_colors.dart';
|
||||||
|
import '../bt_picker/bt_picker_screen.dart';
|
||||||
class SettingsScreen extends ConsumerWidget {
|
import '../debug/raw_frame_debug_screen.dart';
|
||||||
const SettingsScreen({super.key});
|
|
||||||
|
class SettingsScreen extends ConsumerWidget {
|
||||||
@override
|
const SettingsScreen({super.key});
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final settings = ref.watch(settingsProvider);
|
@override
|
||||||
final themeVariant = ref.watch(themeProvider);
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final bt = ref.watch(btProvider);
|
final settings = ref.watch(settingsProvider);
|
||||||
final colors = AppColors.forTheme(themeVariant);
|
final themeVariant = ref.watch(themeProvider);
|
||||||
|
final bt = ref.watch(btProvider);
|
||||||
return Scaffold(
|
final colors = AppColors.of(context);
|
||||||
appBar: AppBar(title: const Text('Settings')),
|
final units = ref.watch(unitPrefsProvider);
|
||||||
body: ListView(
|
final unitsN = ref.read(unitPrefsProvider.notifier);
|
||||||
children: [
|
|
||||||
// ── Connection ───────────────────────────────────────────
|
return Scaffold(
|
||||||
_SectionHeader('CONNECTION'),
|
appBar: AppBar(title: const Text('Settings')),
|
||||||
ListTile(
|
body: ListView(
|
||||||
leading: Icon(Icons.bluetooth, color: colors.accent),
|
children: [
|
||||||
title: const Text('Bluetooth Device'),
|
// ── Connection ───────────────────────────────────────────
|
||||||
subtitle: Text(
|
_SectionHeader('CONNECTION'),
|
||||||
bt.isConnected ? 'Connected: ${bt.deviceName}' : 'Not connected',
|
ListTile(
|
||||||
style:
|
leading: Icon(Icons.bluetooth, color: colors.accent),
|
||||||
TextStyle(color: bt.isConnected ? Colors.green : Colors.grey),
|
title: const Text('Bluetooth Device'),
|
||||||
),
|
subtitle: Text(
|
||||||
trailing: bt.isConnected
|
bt.isConnected ? 'Connected: ${bt.deviceName}' : 'Not connected',
|
||||||
? TextButton(
|
style:
|
||||||
onPressed: () async {
|
TextStyle(color: bt.isConnected ? Colors.green : Colors.grey),
|
||||||
await ref.read(btProvider.notifier).disconnect();
|
),
|
||||||
},
|
trailing: bt.isConnected
|
||||||
child: const Text('Disconnect',
|
? TextButton(
|
||||||
style: TextStyle(color: Colors.red)),
|
onPressed: () async {
|
||||||
)
|
await ref.read(btProvider.notifier).disconnect();
|
||||||
: const Icon(Icons.arrow_forward_ios,
|
},
|
||||||
size: 14, color: Colors.grey),
|
child: const Text('Disconnect',
|
||||||
onTap: bt.isConnected
|
style: TextStyle(color: Colors.red)),
|
||||||
? null
|
)
|
||||||
: () => Navigator.push(
|
: const Icon(Icons.arrow_forward_ios,
|
||||||
context,
|
size: 14, color: Colors.grey),
|
||||||
MaterialPageRoute(builder: (_) => const BtPickerScreen()),
|
onTap: bt.isConnected
|
||||||
),
|
? null
|
||||||
),
|
: () => Navigator.push(
|
||||||
const Divider(height: 1, color: Colors.white10),
|
context,
|
||||||
|
MaterialPageRoute(builder: (_) => const BtPickerScreen()),
|
||||||
// ── ECU Protocol ─────────────────────────────────────────
|
),
|
||||||
_SectionHeader('ECU PROTOCOL'),
|
),
|
||||||
RadioListTile<EcuType>(
|
const Divider(height: 1),
|
||||||
title: const Text('S300'),
|
|
||||||
subtitle: const Text('Honda S300 ECU',
|
// ── ECU Protocol ─────────────────────────────────────────
|
||||||
style: TextStyle(color: Colors.grey, fontSize: 12)),
|
_SectionHeader('ECU PROTOCOL'),
|
||||||
value: EcuType.s300,
|
RadioListTile<EcuType>(
|
||||||
groupValue: settings.ecuType,
|
title: const Text('S300'),
|
||||||
activeColor: colors.accent,
|
subtitle: const Text('Honda S300 ECU',
|
||||||
onChanged: (v) {
|
style: TextStyle(color: Colors.grey, fontSize: 12)),
|
||||||
if (v != null) ref.read(settingsProvider.notifier).setEcuType(v);
|
value: EcuType.s300,
|
||||||
},
|
groupValue: settings.ecuType,
|
||||||
),
|
activeColor: colors.accent,
|
||||||
RadioListTile<EcuType>(
|
onChanged: (v) {
|
||||||
title: const Text('KPro'),
|
if (v != null) ref.read(settingsProvider.notifier).setEcuType(v);
|
||||||
subtitle: const Text('Hondata KPro ECU',
|
},
|
||||||
style: TextStyle(color: Colors.grey, fontSize: 12)),
|
),
|
||||||
value: EcuType.kpro,
|
RadioListTile<EcuType>(
|
||||||
groupValue: settings.ecuType,
|
title: const Text('KPro'),
|
||||||
activeColor: colors.accent,
|
subtitle: const Text('Hondata KPro ECU',
|
||||||
onChanged: (v) {
|
style: TextStyle(color: Colors.grey, fontSize: 12)),
|
||||||
if (v != null) ref.read(settingsProvider.notifier).setEcuType(v);
|
value: EcuType.kpro,
|
||||||
},
|
groupValue: settings.ecuType,
|
||||||
),
|
activeColor: colors.accent,
|
||||||
const Divider(height: 1, color: Colors.white10),
|
onChanged: (v) {
|
||||||
|
if (v != null) ref.read(settingsProvider.notifier).setEcuType(v);
|
||||||
// ── Polling Interval ─────────────────────────────────────
|
},
|
||||||
_SectionHeader('POLLING INTERVAL'),
|
),
|
||||||
...[20, 50, 100, 200].map((ms) => RadioListTile<int>(
|
const Divider(height: 1),
|
||||||
title: Text('${ms}ms (~${(1000 / ms).round()} Hz)'),
|
|
||||||
value: ms,
|
// ── Polling Interval ─────────────────────────────────────
|
||||||
groupValue: settings.pollingInterval.inMilliseconds,
|
_SectionHeader('POLLING INTERVAL'),
|
||||||
activeColor: colors.accent,
|
...pollingIntervalOptionsMs.map((ms) => RadioListTile<int>(
|
||||||
onChanged: (v) {
|
title: Text('${ms}ms (~${(1000 / ms).round()} Hz)'),
|
||||||
if (v != null) {
|
value: ms,
|
||||||
ref
|
groupValue: settings.pollingInterval.inMilliseconds,
|
||||||
.read(settingsProvider.notifier)
|
activeColor: colors.accent,
|
||||||
.setPollingInterval(Duration(milliseconds: v));
|
onChanged: (v) {
|
||||||
}
|
if (v != null) {
|
||||||
},
|
ref
|
||||||
)),
|
.read(settingsProvider.notifier)
|
||||||
const Divider(height: 1, color: Colors.white10),
|
.setPollingInterval(Duration(milliseconds: v));
|
||||||
|
}
|
||||||
// ── Theme ────────────────────────────────────────────────
|
},
|
||||||
_SectionHeader('THEME'),
|
)),
|
||||||
...AppThemeVariant.values.map((v) {
|
const Divider(height: 1),
|
||||||
final label = switch (v) {
|
|
||||||
AppThemeVariant.redDark => 'Red — Dark',
|
// ── Units ────────────────────────────────────────────────
|
||||||
AppThemeVariant.redLight => 'Red — Light',
|
_SectionHeader('UNITS'),
|
||||||
AppThemeVariant.greenDark => 'Green — Dark',
|
_UnitRow<SpeedUnit>(
|
||||||
AppThemeVariant.greenLight => 'Green — Light',
|
label: 'Speed',
|
||||||
};
|
value: units.speed,
|
||||||
return RadioListTile<AppThemeVariant>(
|
options: const {SpeedUnit.kmh: 'km/h', SpeedUnit.mph: 'mph'},
|
||||||
title: Text(label),
|
onChanged: unitsN.setSpeed,
|
||||||
value: v,
|
),
|
||||||
groupValue: themeVariant,
|
_UnitRow<TempUnit>(
|
||||||
activeColor: AppColors.forTheme(v).accent,
|
label: 'Temperature',
|
||||||
onChanged: (val) {
|
value: units.temp,
|
||||||
if (val != null) ref.read(themeProvider.notifier).set(val);
|
options: const {
|
||||||
},
|
TempUnit.celsius: '°C',
|
||||||
);
|
TempUnit.fahrenheit: '°F',
|
||||||
}),
|
},
|
||||||
const Divider(height: 1, color: Colors.white10),
|
onChanged: unitsN.setTemp,
|
||||||
|
),
|
||||||
// ── App info ─────────────────────────────────────────────
|
_UnitRow<PressureUnit>(
|
||||||
_SectionHeader('ABOUT'),
|
label: 'Pressure',
|
||||||
const ListTile(
|
value: units.pressure,
|
||||||
title: Text('HV BT Dashboard'),
|
options: const {
|
||||||
subtitle: Text('v1.4.0 — Phase 4',
|
PressureUnit.kpa: 'kPa',
|
||||||
style: TextStyle(color: Colors.grey, fontSize: 12)),
|
PressureUnit.bar: 'bar',
|
||||||
leading: Icon(Icons.info_outline, color: Colors.grey),
|
PressureUnit.psi: 'psi',
|
||||||
),
|
},
|
||||||
const SizedBox(height: 24),
|
onChanged: unitsN.setPressure,
|
||||||
],
|
),
|
||||||
),
|
const Divider(height: 1),
|
||||||
);
|
|
||||||
}
|
// ── Theme ────────────────────────────────────────────────
|
||||||
}
|
_SectionHeader('THEME'),
|
||||||
|
...AppThemeVariant.values.map((v) {
|
||||||
class _SectionHeader extends StatelessWidget {
|
final label = switch (v) {
|
||||||
final String title;
|
AppThemeVariant.redDark => 'Red — Dark',
|
||||||
const _SectionHeader(this.title);
|
AppThemeVariant.redLight => 'Red — Light',
|
||||||
|
AppThemeVariant.greenDark => 'Green — Dark',
|
||||||
@override
|
AppThemeVariant.greenLight => 'Green — Light',
|
||||||
Widget build(BuildContext context) {
|
};
|
||||||
return Padding(
|
return RadioListTile<AppThemeVariant>(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 20, 16, 6),
|
title: Text(label),
|
||||||
child: Text(
|
value: v,
|
||||||
title,
|
groupValue: themeVariant,
|
||||||
style: const TextStyle(
|
activeColor: AppColors.forTheme(v).accent,
|
||||||
color: Colors.grey,
|
onChanged: (val) {
|
||||||
fontSize: 11,
|
if (val != null) ref.read(themeProvider.notifier).set(val);
|
||||||
letterSpacing: 1.5,
|
},
|
||||||
fontWeight: FontWeight.w600,
|
);
|
||||||
),
|
}),
|
||||||
),
|
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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,65 +1,103 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../core/providers/theme_provider.dart';
|
import '../../core/providers/theme_provider.dart';
|
||||||
|
|
||||||
class AppColors {
|
/// App palette for one theme variant. Registered as a [ThemeExtension] so
|
||||||
final Color accent;
|
/// widgets read it with [AppColors.of] and follow light/dark automatically.
|
||||||
final Color background;
|
class AppColors extends ThemeExtension<AppColors> {
|
||||||
final Color surface;
|
final Color accent;
|
||||||
final Color surfaceVariant;
|
final Color background;
|
||||||
final Color onSurface;
|
final Color surface;
|
||||||
final Color onSurfaceMuted;
|
final Color surfaceVariant;
|
||||||
|
final Color onSurface;
|
||||||
const AppColors._({
|
final Color onSurfaceMuted;
|
||||||
required this.accent,
|
final Color divider;
|
||||||
required this.background,
|
final Color track; // empty part of bars and gauge arcs
|
||||||
required this.surface,
|
|
||||||
required this.surfaceVariant,
|
const AppColors._({
|
||||||
required this.onSurface,
|
required this.accent,
|
||||||
required this.onSurfaceMuted,
|
required this.background,
|
||||||
});
|
required this.surface,
|
||||||
|
required this.surfaceVariant,
|
||||||
static const redDark = AppColors._(
|
required this.onSurface,
|
||||||
accent: Color(0xFFFF4444),
|
required this.onSurfaceMuted,
|
||||||
background: Color(0xFF0A0A0A),
|
required this.divider,
|
||||||
surface: Color(0xFF1A1A1A),
|
required this.track,
|
||||||
surfaceVariant: Color(0xFF242424),
|
});
|
||||||
onSurface: Colors.white,
|
|
||||||
onSurfaceMuted: Colors.grey,
|
static const _dark = AppColors._(
|
||||||
);
|
accent: Color(0xFFFF4444),
|
||||||
|
background: Color(0xFF0A0A0A),
|
||||||
static const redLight = AppColors._(
|
surface: Color(0xFF1A1A1A),
|
||||||
accent: Color(0xFFCC0000),
|
surfaceVariant: Color(0xFF242424),
|
||||||
background: Color(0xFFF2F2F2),
|
onSurface: Colors.white,
|
||||||
surface: Color(0xFFFFFFFF),
|
onSurfaceMuted: Color(0xFF9E9E9E),
|
||||||
surfaceVariant: Color(0xFFEAEAEA),
|
divider: Color(0x1AFFFFFF),
|
||||||
onSurface: Color(0xFF111111),
|
track: Color(0xFF2A2A2A),
|
||||||
onSurfaceMuted: Color(0xFF777777),
|
);
|
||||||
);
|
|
||||||
|
static const _light = AppColors._(
|
||||||
static const greenDark = AppColors._(
|
accent: Color(0xFFCC0000),
|
||||||
accent: Color(0xFF00E676),
|
background: Color(0xFFF2F2F2),
|
||||||
background: Color(0xFF0A0A0A),
|
surface: Color(0xFFFFFFFF),
|
||||||
surface: Color(0xFF1A1A1A),
|
surfaceVariant: Color(0xFFEAEAEA),
|
||||||
surfaceVariant: Color(0xFF242424),
|
onSurface: Color(0xFF111111),
|
||||||
onSurface: Colors.white,
|
onSurfaceMuted: Color(0xFF6E6E6E),
|
||||||
onSurfaceMuted: Colors.grey,
|
divider: Color(0x1F000000),
|
||||||
);
|
track: Color(0xFFDDDDDD),
|
||||||
|
);
|
||||||
static const greenLight = AppColors._(
|
|
||||||
accent: Color(0xFF00875A),
|
static const redDark = _dark;
|
||||||
background: Color(0xFFF2F2F2),
|
static const redLight = _light;
|
||||||
surface: Color(0xFFFFFFFF),
|
static final greenDark = _dark.copyWith(accent: const Color(0xFF00E676));
|
||||||
surfaceVariant: Color(0xFFEAEAEA),
|
static final greenLight = _light.copyWith(accent: const Color(0xFF00875A));
|
||||||
onSurface: Color(0xFF111111),
|
|
||||||
onSurfaceMuted: Color(0xFF777777),
|
static AppColors forTheme(AppThemeVariant v) {
|
||||||
);
|
switch (v) {
|
||||||
|
case AppThemeVariant.redDark: return redDark;
|
||||||
static AppColors forTheme(AppThemeVariant v) {
|
case AppThemeVariant.redLight: return redLight;
|
||||||
switch (v) {
|
case AppThemeVariant.greenDark: return greenDark;
|
||||||
case AppThemeVariant.redDark: return redDark;
|
case AppThemeVariant.greenLight: return greenLight;
|
||||||
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)!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,55 +1,57 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../core/providers/theme_provider.dart';
|
import '../../core/providers/theme_provider.dart';
|
||||||
import 'app_colors.dart';
|
import 'app_colors.dart';
|
||||||
|
|
||||||
class AppTheme {
|
class AppTheme {
|
||||||
static ThemeData build(AppThemeVariant variant) {
|
static ThemeData build(AppThemeVariant variant) {
|
||||||
final colors = AppColors.forTheme(variant);
|
final colors = AppColors.forTheme(variant);
|
||||||
final isDark = variant.isDark;
|
final isDark = variant.isDark;
|
||||||
|
|
||||||
return ThemeData(
|
return ThemeData(
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
brightness: isDark ? Brightness.dark : Brightness.light,
|
brightness: isDark ? Brightness.dark : Brightness.light,
|
||||||
scaffoldBackgroundColor: colors.background,
|
scaffoldBackgroundColor: colors.background,
|
||||||
colorScheme: ColorScheme.fromSeed(
|
extensions: [colors],
|
||||||
seedColor: colors.accent,
|
dividerColor: colors.divider,
|
||||||
brightness: isDark ? Brightness.dark : Brightness.light,
|
colorScheme: ColorScheme.fromSeed(
|
||||||
).copyWith(
|
seedColor: colors.accent,
|
||||||
primary: colors.accent,
|
brightness: isDark ? Brightness.dark : Brightness.light,
|
||||||
surface: colors.surface,
|
).copyWith(
|
||||||
),
|
primary: colors.accent,
|
||||||
appBarTheme: AppBarTheme(
|
surface: colors.surface,
|
||||||
backgroundColor: colors.surface,
|
),
|
||||||
foregroundColor: colors.onSurface,
|
appBarTheme: AppBarTheme(
|
||||||
elevation: 0,
|
backgroundColor: colors.surface,
|
||||||
surfaceTintColor: Colors.transparent,
|
foregroundColor: colors.onSurface,
|
||||||
),
|
elevation: 0,
|
||||||
bottomNavigationBarTheme: BottomNavigationBarThemeData(
|
surfaceTintColor: Colors.transparent,
|
||||||
backgroundColor: colors.surface,
|
),
|
||||||
selectedItemColor: colors.accent,
|
bottomNavigationBarTheme: BottomNavigationBarThemeData(
|
||||||
unselectedItemColor: colors.onSurfaceMuted,
|
backgroundColor: colors.surface,
|
||||||
type: BottomNavigationBarType.fixed,
|
selectedItemColor: colors.accent,
|
||||||
elevation: 8,
|
unselectedItemColor: colors.onSurfaceMuted,
|
||||||
),
|
type: BottomNavigationBarType.fixed,
|
||||||
cardTheme: CardThemeData(
|
elevation: 8,
|
||||||
color: colors.surface,
|
),
|
||||||
elevation: 0,
|
cardTheme: CardThemeData(
|
||||||
shape: RoundedRectangleBorder(
|
color: colors.surface,
|
||||||
borderRadius: BorderRadius.circular(10),
|
elevation: 0,
|
||||||
),
|
shape: RoundedRectangleBorder(
|
||||||
),
|
borderRadius: BorderRadius.circular(10),
|
||||||
dividerTheme: DividerThemeData(
|
),
|
||||||
color: colors.surfaceVariant,
|
),
|
||||||
thickness: 1,
|
dividerTheme: DividerThemeData(
|
||||||
),
|
color: colors.divider,
|
||||||
floatingActionButtonTheme: FloatingActionButtonThemeData(
|
thickness: 1,
|
||||||
backgroundColor: colors.accent,
|
),
|
||||||
foregroundColor: isDark ? Colors.black : Colors.white,
|
floatingActionButtonTheme: FloatingActionButtonThemeData(
|
||||||
),
|
backgroundColor: colors.accent,
|
||||||
textTheme: TextTheme(
|
foregroundColor: isDark ? Colors.black : Colors.white,
|
||||||
bodyMedium: TextStyle(color: colors.onSurface),
|
),
|
||||||
bodySmall: TextStyle(color: colors.onSurfaceMuted),
|
textTheme: TextTheme(
|
||||||
),
|
bodyMedium: TextStyle(color: colors.onSurface),
|
||||||
);
|
bodySmall: TextStyle(color: colors.onSurfaceMuted),
|
||||||
}
|
),
|
||||||
}
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,38 +1,40 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../core/providers/bt_provider.dart';
|
import '../../core/providers/bt_provider.dart';
|
||||||
|
|
||||||
class BtStatusChip extends ConsumerWidget {
|
class BtStatusChip extends ConsumerWidget {
|
||||||
const BtStatusChip({super.key});
|
const BtStatusChip({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final bt = ref.watch(btProvider);
|
final status = ref.watch(btProvider.select((s) => s.status));
|
||||||
|
final name = ref.watch(btProvider.select((s) => s.deviceName));
|
||||||
final (color, icon, label) = switch (bt.status) {
|
|
||||||
BtStatus.connected => (Colors.green, Icons.bluetooth_connected, bt.deviceName ?? 'Connected'),
|
final (color, icon, label) = switch (status) {
|
||||||
BtStatus.connecting => (Colors.orange, Icons.bluetooth_searching, 'Connecting…'),
|
BtStatus.connected => (Colors.green, Icons.bluetooth_connected, name ?? 'Connected'),
|
||||||
BtStatus.error => (Colors.red, Icons.bluetooth_disabled, 'Error'),
|
BtStatus.connecting => (Colors.orange, Icons.bluetooth_searching, 'Connecting…'),
|
||||||
BtStatus.disconnected=> (Colors.grey, Icons.bluetooth, 'Disconnected'),
|
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(
|
return Padding(
|
||||||
mainAxisSize: MainAxisSize.min,
|
padding: const EdgeInsets.only(right: 8),
|
||||||
children: [
|
child: Row(
|
||||||
Icon(icon, size: 14, color: color),
|
mainAxisSize: MainAxisSize.min,
|
||||||
const SizedBox(width: 4),
|
children: [
|
||||||
Text(
|
Icon(icon, size: 14, color: color),
|
||||||
label,
|
const SizedBox(width: 4),
|
||||||
style: TextStyle(
|
Text(
|
||||||
color: color,
|
label,
|
||||||
fontSize: 11,
|
style: TextStyle(
|
||||||
fontWeight: FontWeight.w600,
|
color: color,
|
||||||
),
|
fontSize: 11,
|
||||||
),
|
fontWeight: FontWeight.w600,
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
);
|
],
|
||||||
}
|
),
|
||||||
}
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
58
lib/ui/widgets/data_status_banner.dart
Normal file
58
lib/ui/widgets/data_status_banner.dart
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
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,92 +1,101 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../screens/dashboard/dashboard_screen.dart';
|
import '../screens/dashboard/dashboard_screen.dart';
|
||||||
import '../screens/sensor_list/sensor_list_screen.dart';
|
import '../screens/sensor_list/sensor_list_screen.dart';
|
||||||
import '../screens/graph/graph_screen.dart';
|
import '../screens/graph/graph_screen.dart';
|
||||||
import '../screens/dtc/dtc_screen.dart';
|
import '../screens/dtc/dtc_screen.dart';
|
||||||
import '../screens/datalog/datalog_list_screen.dart';
|
import '../screens/datalog/datalog_list_screen.dart';
|
||||||
import '../screens/debug/raw_frame_debug_screen.dart';
|
import '../screens/datalog/datalog_playback_bar.dart';
|
||||||
import '../screens/settings/settings_screen.dart';
|
import '../../core/providers/datalog_provider.dart';
|
||||||
import 'bt_status_chip.dart';
|
import '../screens/settings/settings_screen.dart';
|
||||||
import 'record_fab.dart';
|
import 'bt_status_chip.dart';
|
||||||
|
import 'data_status_banner.dart';
|
||||||
final _navIndexProvider = StateProvider<int>((ref) => 0);
|
import 'record_fab.dart';
|
||||||
|
|
||||||
class MainNav extends ConsumerWidget {
|
final _navIndexProvider = StateProvider<int>((ref) => 0);
|
||||||
const MainNav({super.key});
|
|
||||||
|
class MainNav extends ConsumerWidget {
|
||||||
static const _screens = [
|
const MainNav({super.key});
|
||||||
DashboardScreen(),
|
|
||||||
SensorListScreen(),
|
static const _screens = [
|
||||||
GraphScreen(),
|
DashboardScreen(),
|
||||||
DtcScreen(),
|
SensorListScreen(),
|
||||||
];
|
GraphScreen(),
|
||||||
|
DtcScreen(),
|
||||||
static const _items = [
|
];
|
||||||
BottomNavigationBarItem(
|
|
||||||
icon: Icon(Icons.speed),
|
static const _items = [
|
||||||
label: 'Dashboard',
|
BottomNavigationBarItem(
|
||||||
),
|
icon: Icon(Icons.speed),
|
||||||
BottomNavigationBarItem(
|
label: 'Dashboard',
|
||||||
icon: Icon(Icons.list_alt),
|
),
|
||||||
label: 'Sensors',
|
BottomNavigationBarItem(
|
||||||
),
|
icon: Icon(Icons.list_alt),
|
||||||
BottomNavigationBarItem(
|
label: 'Sensors',
|
||||||
icon: Icon(Icons.show_chart),
|
),
|
||||||
label: 'Graph',
|
BottomNavigationBarItem(
|
||||||
),
|
icon: Icon(Icons.show_chart),
|
||||||
BottomNavigationBarItem(
|
label: 'Graph',
|
||||||
icon: Icon(Icons.warning_amber_outlined),
|
),
|
||||||
label: 'DTC',
|
BottomNavigationBarItem(
|
||||||
),
|
icon: Icon(Icons.warning_amber_outlined),
|
||||||
];
|
label: 'DTC',
|
||||||
|
),
|
||||||
@override
|
];
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final index = ref.watch(_navIndexProvider);
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
return Scaffold(
|
final index = ref.watch(_navIndexProvider);
|
||||||
appBar: AppBar(
|
final playback = ref.watch(playbackProvider);
|
||||||
title: const Text('HV BT Dashboard'),
|
|
||||||
actions: [
|
return Scaffold(
|
||||||
const BtStatusChip(),
|
appBar: AppBar(
|
||||||
IconButton(
|
title: const Text('HV BT Dashboard'),
|
||||||
icon: const Icon(Icons.folder_open_outlined),
|
actions: [
|
||||||
tooltip: 'Saved Sessions',
|
const BtStatusChip(),
|
||||||
onPressed: () => Navigator.push(
|
IconButton(
|
||||||
context,
|
icon: const Icon(Icons.folder_open_outlined),
|
||||||
MaterialPageRoute(builder: (_) => const DatalogListScreen()),
|
tooltip: 'Saved Sessions',
|
||||||
),
|
onPressed: () => Navigator.push(
|
||||||
),
|
context,
|
||||||
IconButton(
|
MaterialPageRoute(builder: (_) => const DatalogListScreen()),
|
||||||
icon: const Icon(Icons.bug_report_outlined),
|
),
|
||||||
tooltip: 'ECU Frame Debug',
|
),
|
||||||
onPressed: () => Navigator.push(
|
// Record sits in the app bar so it never covers dashboard values.
|
||||||
context,
|
const RecordButton(),
|
||||||
MaterialPageRoute(builder: (_) => const RawFrameDebugScreen()),
|
IconButton(
|
||||||
),
|
icon: const Icon(Icons.settings_outlined),
|
||||||
),
|
tooltip: 'Settings',
|
||||||
IconButton(
|
onPressed: () => Navigator.push(
|
||||||
icon: const Icon(Icons.settings_outlined),
|
context,
|
||||||
tooltip: 'Settings',
|
MaterialPageRoute(builder: (_) => const SettingsScreen()),
|
||||||
onPressed: () => Navigator.push(
|
),
|
||||||
context,
|
),
|
||||||
MaterialPageRoute(builder: (_) => const SettingsScreen()),
|
],
|
||||||
),
|
),
|
||||||
),
|
// SafeArea: in landscape the system navigation bar sits on a side.
|
||||||
],
|
body: SafeArea(
|
||||||
),
|
top: false,
|
||||||
body: IndexedStack(
|
bottom: false,
|
||||||
index: index,
|
child: Column(
|
||||||
children: _screens,
|
children: [
|
||||||
),
|
const DataStatusBanner(),
|
||||||
bottomNavigationBar: BottomNavigationBar(
|
if (playback.isActive) DatalogPlaybackBar(state: playback),
|
||||||
currentIndex: index,
|
Expanded(
|
||||||
items: _items,
|
child: IndexedStack(
|
||||||
onTap: (i) => ref.read(_navIndexProvider.notifier).state = i,
|
index: index,
|
||||||
),
|
children: _screens,
|
||||||
floatingActionButton: const RecordFab(),
|
),
|
||||||
);
|
),
|
||||||
}
|
],
|
||||||
}
|
),
|
||||||
|
),
|
||||||
|
bottomNavigationBar: BottomNavigationBar(
|
||||||
|
currentIndex: index,
|
||||||
|
items: _items,
|
||||||
|
onTap: (i) => ref.read(_navIndexProvider.notifier).state = i,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,48 +1,49 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../core/providers/bt_provider.dart';
|
import '../../core/providers/bt_provider.dart';
|
||||||
import '../../core/providers/datalog_provider.dart';
|
import '../../core/providers/datalog_provider.dart';
|
||||||
|
|
||||||
class RecordFab extends ConsumerWidget {
|
/// App-bar record/stop button for datalogging (shown while connected).
|
||||||
const RecordFab({super.key});
|
class RecordButton extends ConsumerWidget {
|
||||||
|
const RecordButton({super.key});
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
@override
|
||||||
final recording = ref.watch(recordingProvider);
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final bt = ref.watch(btProvider);
|
final recording = ref.watch(recordingProvider);
|
||||||
|
final bt = ref.watch(btProvider);
|
||||||
// Only show when BT is connected
|
|
||||||
if (!bt.isConnected) return const SizedBox.shrink();
|
// Only show when BT is connected
|
||||||
|
if (!bt.isConnected) return const SizedBox.shrink();
|
||||||
final isRecording = recording.isRecording;
|
|
||||||
|
final isRecording = recording.isRecording;
|
||||||
return FloatingActionButton(
|
|
||||||
heroTag: 'record_fab',
|
return IconButton(
|
||||||
backgroundColor: isRecording ? Colors.red : Theme.of(context).colorScheme.primary,
|
color: isRecording ? Colors.red : null,
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
if (isRecording) {
|
if (isRecording) {
|
||||||
await ref.read(recordingProvider.notifier).stopRecording();
|
await ref.read(recordingProvider.notifier).stopRecording();
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(
|
content: Text(
|
||||||
'Session saved — ${recording.frameCount} frames',
|
'Session saved — ${recording.frameCount} frames',
|
||||||
),
|
),
|
||||||
duration: const Duration(seconds: 2),
|
duration: const Duration(seconds: 2),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await ref.read(recordingProvider.notifier).startRecording();
|
await ref.read(recordingProvider.notifier).startRecording();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
tooltip: isRecording ? 'Stop recording' : 'Start recording',
|
tooltip: isRecording ? 'Stop recording' : 'Start recording',
|
||||||
child: AnimatedSwitcher(
|
icon: AnimatedSwitcher(
|
||||||
duration: const Duration(milliseconds: 200),
|
duration: const Duration(milliseconds: 200),
|
||||||
child: isRecording
|
child: isRecording
|
||||||
? const Icon(Icons.stop, key: ValueKey('stop'))
|
? const Icon(Icons.stop_circle, key: ValueKey('stop'))
|
||||||
: const Icon(Icons.fiber_manual_record, key: ValueKey('rec')),
|
: const Icon(Icons.fiber_manual_record_outlined,
|
||||||
),
|
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
|
name: hvbt_dash
|
||||||
description: HV BT Automotive ECU Dashboard
|
description: HV BT Automotive ECU Dashboard
|
||||||
|
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
|
|
||||||
version: 1.3.0+4
|
version: 1.3.0+4
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.0.0 <4.0.0'
|
sdk: '>=3.0.0 <4.0.0'
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_riverpod: ^2.5.0
|
flutter_riverpod: ^2.5.0
|
||||||
flutter_bluetooth_serial: ^0.4.0
|
flutter_bluetooth_serial: ^0.4.0
|
||||||
fl_chart: ^0.68.0
|
fl_chart: ^0.68.0
|
||||||
sqflite: ^2.3.0
|
sqflite: ^2.3.0
|
||||||
shared_preferences: ^2.2.0
|
shared_preferences: ^2.2.0
|
||||||
path_provider: ^2.1.0
|
path_provider: ^2.1.0
|
||||||
intl: ^0.19.0
|
intl: ^0.19.0
|
||||||
path: ^1.9.0
|
path: ^1.9.0
|
||||||
permission_handler: ^11.0.0
|
permission_handler: ^11.0.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_lints: ^3.0.0
|
flutter_lints: ^3.0.0
|
||||||
|
|
||||||
flutter:
|
flutter:
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
|
|||||||
@ -1,69 +1,85 @@
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:hvbt_dash/core/protocol/dtc_map.dart';
|
import 'package:hvbt_dash/core/protocol/dtc_map.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('S300 DTC map', () {
|
group('S300 DTC map', () {
|
||||||
test('contains expected number of entries', () {
|
test('contains expected number of entries', () {
|
||||||
expect(s300DtcMap.length, equals(32)); // 4 bytes × 8 bits
|
expect(s300DtcMap.length, equals(32)); // 4 bytes × 8 bits
|
||||||
});
|
});
|
||||||
|
|
||||||
test('P0130 maps to correct description for ERR00 bit0', () {
|
// Expected codes come from the S300 "DTC map" in hvbt_protocol.pdf.
|
||||||
expect(s300DtcMap['ERR00_bit0'], equals('P0130 — O2 Sensor (front)'));
|
test('ERR00 bit0 = P0130 (O2A)', () {
|
||||||
});
|
expect(s300DtcMap['ERR00_bit0'], startsWith('P0130 — '));
|
||||||
|
});
|
||||||
test('P0505 maps for ERR00 bit2', () {
|
|
||||||
expect(s300DtcMap['ERR00_bit2'], equals('P0505 — Idle Air Control Valve'));
|
test('ERR01 bit5 = P0505 (IAC)', () {
|
||||||
});
|
expect(s300DtcMap['ERR01_bit5'], startsWith('P0505 — '));
|
||||||
|
});
|
||||||
test('P1607 maps for ERR03 bit7', () {
|
|
||||||
expect(s300DtcMap['ERR03_bit7'], equals('P1607 — PCM Internal Circuit'));
|
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 — '));
|
||||||
group('KPro DTC map', () {
|
});
|
||||||
test('contains entries for 18 bytes (ERR00..ERR17)', () {
|
|
||||||
// Every ERR byte has 8 bits = 144 entries total
|
test('undocumented bits show the raw ERR position', () {
|
||||||
expect(kproDtcMap.length, equals(144));
|
expect(s300DtcMap['ERR03_bit7'], startsWith('ERR03.7 — '));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('P0130 maps for ERR00 bit0', () {
|
test('every entry has a code and a description', () {
|
||||||
expect(kproDtcMap['ERR00_bit0'], equals('P0130 — O2 Sensor (front)'));
|
for (final v in [...s300DtcMap.values, ...kproDtcMap.values]) {
|
||||||
});
|
expect(v.split(' — ').length, equals(2), reason: v);
|
||||||
|
}
|
||||||
test('ERR17 entries present', () {
|
});
|
||||||
expect(kproDtcMap.containsKey('ERR17_bit0'), isTrue);
|
});
|
||||||
expect(kproDtcMap.containsKey('ERR17_bit7'), isTrue);
|
|
||||||
});
|
group('KPro DTC map', () {
|
||||||
});
|
test('contains entries for 18 bytes (ERR00..ERR17)', () {
|
||||||
|
// Every ERR byte has 8 bits = 144 entries total
|
||||||
group('decodeDtcs helper', () {
|
expect(kproDtcMap.length, equals(144));
|
||||||
test('returns empty list when no errors', () {
|
});
|
||||||
final result = decodeDtcs([0, 0, 0, 0], s300DtcMap);
|
|
||||||
expect(result, isEmpty);
|
// 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 — '));
|
||||||
test('decodes ERR00 bit0 = P0130', () {
|
expect(kproDtcMap['ERR05_bit0'], startsWith('P1259 — '));
|
||||||
final result = decodeDtcs([0x01, 0, 0, 0], s300DtcMap);
|
expect(kproDtcMap['ERR11_bit6'], startsWith('P0304 — '));
|
||||||
expect(result, contains('P0130 — O2 Sensor (front)'));
|
});
|
||||||
});
|
|
||||||
|
test('ERR17 entries present', () {
|
||||||
test('decodes multiple bits in one byte', () {
|
expect(kproDtcMap.containsKey('ERR17_bit0'), isTrue);
|
||||||
// ERR00 bits 0 and 1
|
expect(kproDtcMap.containsKey('ERR17_bit7'), isTrue);
|
||||||
final result = decodeDtcs([0x03, 0, 0, 0], s300DtcMap);
|
});
|
||||||
expect(result.length, equals(2));
|
});
|
||||||
});
|
|
||||||
|
group('decodeDtcs helper', () {
|
||||||
test('decodes bits across multiple bytes', () {
|
test('returns empty list when no errors', () {
|
||||||
// ERR00 bit0 + ERR01 bit0
|
final result = decodeDtcs([0, 0, 0, 0], s300DtcMap);
|
||||||
final result = decodeDtcs([0x01, 0x01, 0, 0], s300DtcMap);
|
expect(result, isEmpty);
|
||||||
expect(result.length, equals(2));
|
});
|
||||||
});
|
|
||||||
|
test('decodes ERR00 bit0 = P0130', () {
|
||||||
test('skips bits not in map gracefully', () {
|
final result = decodeDtcs([0x01, 0, 0, 0], s300DtcMap);
|
||||||
// Use a small dtcMap with only one entry, but set all bits
|
expect(result, contains(s300DtcMap['ERR00_bit0']));
|
||||||
final result = decodeDtcs([0xFF, 0, 0, 0], {'ERR00_bit0': 'P0130 — test'});
|
});
|
||||||
// Only the one mapped bit should appear
|
|
||||||
expect(result.length, equals(1));
|
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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
22
test/ecu_type_from_name_test.dart
Normal file
22
test/ecu_type_from_name_test.dart
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
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,133 +1,167 @@
|
|||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:hvbt_dash/core/protocol/kpro_parser.dart';
|
import 'package:hvbt_dash/core/protocol/kpro_parser.dart';
|
||||||
import 'package:hvbt_dash/core/protocol/temp_table.dart';
|
import 'package:hvbt_dash/core/protocol/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({
|
/// Builds a zeroed 128-byte KPro frame and stamps the NEG8 checksum at byte 127.
|
||||||
int rpm = 0,
|
Uint8List _buildKProFrame({
|
||||||
int vss = 0,
|
int rpm = 0,
|
||||||
int tps = 0,
|
int vss = 0,
|
||||||
int map1 = 0,
|
int tps = 0,
|
||||||
int map2 = 0,
|
int map1 = 0,
|
||||||
int ign = 0,
|
int map2 = 0,
|
||||||
int ect = 0x80, // tempXlt[0x80]=73 → 33°C
|
int ign = 0,
|
||||||
int bat = 0,
|
int ect = 0x80, // tempXlt[0x80]=73 → 33°C
|
||||||
int sw1 = 0,
|
int bat = 0,
|
||||||
int sw2 = 0,
|
int sw1 = 0,
|
||||||
int krtrd = 0,
|
int sw2 = 0,
|
||||||
}) {
|
int krtrd = 0,
|
||||||
final frame = Uint8List(128);
|
}) {
|
||||||
// Header
|
final frame = Uint8List(128);
|
||||||
frame[0] = 0x1B;
|
// Header
|
||||||
frame[1] = 0x01;
|
frame[0] = 0x1B;
|
||||||
// RPM: bytes 2..3
|
frame[1] = 0x01;
|
||||||
frame[2] = (rpm >> 8) & 0xFF;
|
// RPM: bytes 2..3, little-endian
|
||||||
frame[3] = rpm & 0xFF;
|
frame[2] = rpm & 0xFF;
|
||||||
// VSS: byte 4
|
frame[3] = (rpm >> 8) & 0xFF;
|
||||||
frame[4] = vss & 0xFF;
|
// VSS: byte 4
|
||||||
// TPS: byte 5
|
frame[4] = vss & 0xFF;
|
||||||
frame[5] = tps & 0xFF;
|
// TPS: byte 5
|
||||||
// MAP1: byte 6
|
frame[5] = tps & 0xFF;
|
||||||
frame[6] = map1 & 0xFF;
|
// MAP1: byte 6
|
||||||
// MAP2: bytes 33..34
|
frame[6] = map1 & 0xFF;
|
||||||
frame[33] = (map2 >> 8) & 0xFF;
|
// MAP2: bytes 33 (L), 34 (H)
|
||||||
frame[34] = map2 & 0xFF;
|
frame[33] = map2 & 0xFF;
|
||||||
// IGN: byte 13
|
frame[34] = (map2 >> 8) & 0xFF;
|
||||||
frame[13] = ign;
|
// IGN: byte 13
|
||||||
// KRtrd: byte 21
|
frame[13] = ign;
|
||||||
frame[21] = krtrd;
|
// KRtrd: byte 21
|
||||||
// SW1: byte 31, SW2: byte 32
|
frame[21] = krtrd;
|
||||||
frame[31] = sw1;
|
// SW1: byte 31, SW2: byte 32
|
||||||
frame[32] = sw2;
|
frame[31] = sw1;
|
||||||
// ECT: byte 49
|
frame[32] = sw2;
|
||||||
frame[49] = ect;
|
// ECT: byte 49
|
||||||
// BAT: byte 52
|
frame[49] = ect;
|
||||||
frame[52] = bat;
|
// BAT: byte 52
|
||||||
|
frame[51] = bat; // KMGR: BAT is byte 51 (the PDF says 52)
|
||||||
// Stamp NEG8 checksum at byte 127
|
|
||||||
int neg8 = 0;
|
// Stamp NEG8 checksum at byte 127
|
||||||
for (int i = 0; i < 127; i++) {
|
int neg8 = 0;
|
||||||
neg8 = (neg8 - frame[i]) & 0xFF;
|
for (int i = 0; i < 127; i++) {
|
||||||
}
|
neg8 = (neg8 - frame[i]) & 0xFF;
|
||||||
frame[127] = neg8;
|
}
|
||||||
return frame;
|
frame[127] = neg8;
|
||||||
}
|
return frame;
|
||||||
|
}
|
||||||
void main() {
|
|
||||||
group('KPro parser', () {
|
void main() {
|
||||||
test('parses RPM = raw / 4', () {
|
group('KPro parser', () {
|
||||||
// rpm raw = 4000 → 1000.0 revs
|
test('parses RPM = raw / 4', () {
|
||||||
final frame = _buildKProFrame(rpm: 4000);
|
// rpm raw = 4000 → 1000.0 revs
|
||||||
final state = parseKPro(frame);
|
final frame = _buildKProFrame(rpm: 4000);
|
||||||
expect(state.rpm, equals(1000.0));
|
final state = parseKPro(frame);
|
||||||
});
|
expect(state.rpm, equals(1000.0));
|
||||||
|
});
|
||||||
test('parses VSS directly', () {
|
|
||||||
final frame = _buildKProFrame(vss: 120);
|
test('parses VSS directly', () {
|
||||||
final state = parseKPro(frame);
|
final frame = _buildKProFrame(vss: 120);
|
||||||
expect(state.vss, equals(120.0));
|
final state = parseKPro(frame);
|
||||||
});
|
expect(state.vss, equals(120.0));
|
||||||
|
});
|
||||||
test('parses TPS directly', () {
|
|
||||||
final frame = _buildKProFrame(tps: 75);
|
test('parses TPS = (raw - 25) / 2.04', () {
|
||||||
final state = parseKPro(frame);
|
final frame = _buildKProFrame(tps: 163); // KMGR: 68 %
|
||||||
expect(state.tps, equals(75.0));
|
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);
|
test('parses MAP from MAP1 when MAP2 = 0', () {
|
||||||
final state = parseKPro(frame);
|
final frame = _buildKProFrame(map1: 100, map2: 0);
|
||||||
expect(state.map, equals(100.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
|
test('parses MAP from MAP2 / 10 when MAP2 != 0', () {
|
||||||
final frame = _buildKProFrame(map1: 50, map2: 1000);
|
// map2 raw = 1000 → 100.0 kPa
|
||||||
final state = parseKPro(frame);
|
final frame = _buildKProFrame(map1: 50, map2: 1000);
|
||||||
expect(state.map, equals(100.0));
|
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
|
test('parses ECT using temp lookup table', () {
|
||||||
final frame = _buildKProFrame(ect: 0x80);
|
// ect raw = 0x80 = 128 → tempXlt[128]=73 → 33°C
|
||||||
final state = parseKPro(frame);
|
final frame = _buildKProFrame(ect: 0x80);
|
||||||
expect(state.ect, equals((tempXlt[0x80] - 40).toDouble()));
|
final state = parseKPro(frame);
|
||||||
});
|
expect(state.ect, equals((tempXlt[0x80] - 40).toDouble()));
|
||||||
|
});
|
||||||
test('parses BAT = raw / 10', () {
|
|
||||||
// bat raw = 130 → 13.0 V
|
test('parses BAT = raw / 10', () {
|
||||||
final frame = _buildKProFrame(bat: 130);
|
// bat raw = 130 → 13.0 V
|
||||||
final state = parseKPro(frame);
|
final frame = _buildKProFrame(bat: 130);
|
||||||
expect(state.bat, equals(13.0));
|
final state = parseKPro(frame);
|
||||||
});
|
expect(state.bat, equals(13.0));
|
||||||
|
});
|
||||||
test('MIL flag from SW2 bit2', () {
|
|
||||||
final frame = _buildKProFrame(sw2: 0x04); // bit2
|
test('MIL flag from SW2 bit3', () {
|
||||||
final state = parseKPro(frame);
|
final frame = _buildKProFrame(sw2: 0x08); // bit3
|
||||||
expect(state.mil, isTrue);
|
final state = parseKPro(frame);
|
||||||
});
|
expect(state.mil, isTrue);
|
||||||
|
});
|
||||||
test('knock flag when krtrd > 0', () {
|
|
||||||
final frame = _buildKProFrame(krtrd: 3);
|
test('real bench frame matches KPro Manager (7267 rpm capture)', () {
|
||||||
final state = parseKPro(frame);
|
// First 64 bytes logged at 00:40:25 on 2026-10-13, while KPro Manager
|
||||||
expect(state.knock, isTrue);
|
// 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 = [
|
||||||
test('errBytes has 18 entries', () {
|
0x1b, 0x01, 0x8d, 0x71, 0x00, 0xa3, 0x75, 0xff, //
|
||||||
final frame = _buildKProFrame();
|
0x28, 0x00, 0x4d, 0x00, 0x00, 0x8a, 0x00, 0xd2,
|
||||||
final state = parseKPro(frame);
|
0xd9, 0xaf, 0xf6, 0x97, 0x80, 0x00, 0x03, 0x8b,
|
||||||
expect(state.errBytes.length, equals(18));
|
0x00, 0x00, 0x12, 0x0c, 0x07, 0x09, 0x00, 0x50,
|
||||||
});
|
0x09, 0xa0, 0x04, 0x01, 0x00, 0x00, 0xff, 0x00,
|
||||||
|
0x00, 0xbf, 0xa3, 0xbf, 0xe8, 0x10, 0xf0, 0xff,
|
||||||
test('throws on wrong frame length', () {
|
0x8d, 0xd8, 0xd2, 0x72, 0xe8, 0x5f, 0xfe, 0x80,
|
||||||
expect(() => parseKPro(Uint8List(64)), throwsArgumentError);
|
0x04, 0x00, 0x00, 0x00, 0x65,
|
||||||
});
|
];
|
||||||
|
final frame = Uint8List(128)..setRange(0, head.length, head);
|
||||||
test('throws on bad checksum', () {
|
frame[127] = calculateNeg8(frame.sublist(0, 127));
|
||||||
final frame = _buildKProFrame(rpm: 4000);
|
final s = parseKPro(frame);
|
||||||
frame[127] = 0x00; // corrupt
|
expect(s.rpm, closeTo(7267, 1));
|
||||||
expect(() => parseKPro(frame), throwsArgumentError);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@ -1,38 +1,38 @@
|
|||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:hvbt_dash/core/protocol/neg8.dart';
|
import 'package:hvbt_dash/core/protocol/neg8.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('NEG8 checksum', () {
|
group('NEG8 checksum', () {
|
||||||
test('S300 request header [0x1B, 0x00] produces 0xE5', () {
|
test('S300 request header [0x1B, 0x00] produces 0xE5', () {
|
||||||
// NEG8 of [0x1B, 0x00] = (0 - 0x1B - 0x00) & 0xFF = 0xE5
|
// NEG8 of [0x1B, 0x00] = (0 - 0x1B - 0x00) & 0xFF = 0xE5
|
||||||
final buf = Uint8List.fromList([0x1B, 0x00]);
|
final buf = Uint8List.fromList([0x1B, 0x00]);
|
||||||
expect(calculateNeg8(buf), equals(0xE5));
|
expect(calculateNeg8(buf), equals(0xE5));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('KPro request header [0x1B, 0x01] produces 0xE4', () {
|
test('KPro request header [0x1B, 0x01] produces 0xE4', () {
|
||||||
final buf = Uint8List.fromList([0x1B, 0x01]);
|
final buf = Uint8List.fromList([0x1B, 0x01]);
|
||||||
expect(calculateNeg8(buf), equals(0xE4));
|
expect(calculateNeg8(buf), equals(0xE4));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('validateFrame passes for correctly checksummed frame', () {
|
test('validateFrame passes for correctly checksummed frame', () {
|
||||||
// Build a 4-byte frame where last byte = NEG8 of first 3
|
// Build a 4-byte frame where last byte = NEG8 of first 3
|
||||||
final data = Uint8List.fromList([0x1B, 0x00, 0x00, 0xE5]);
|
final data = Uint8List.fromList([0x1B, 0x00, 0x00, 0xE5]);
|
||||||
expect(validateFrame(data), isTrue);
|
expect(validateFrame(data), isTrue);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('validateFrame fails for corrupted frame', () {
|
test('validateFrame fails for corrupted frame', () {
|
||||||
final data = Uint8List.fromList([0x1B, 0x00, 0x00, 0xFF]);
|
final data = Uint8List.fromList([0x1B, 0x00, 0x00, 0xFF]);
|
||||||
expect(validateFrame(data), isFalse);
|
expect(validateFrame(data), isFalse);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('validateFrame returns false for empty frame', () {
|
test('validateFrame returns false for empty frame', () {
|
||||||
expect(validateFrame(Uint8List(0)), isFalse);
|
expect(validateFrame(Uint8List(0)), isFalse);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('single byte 0x00 validates as [0x00, 0x00]', () {
|
test('single byte 0x00 validates as [0x00, 0x00]', () {
|
||||||
// NEG8([0x00]) = 0x00, so frame [0x00, 0x00] should pass
|
// NEG8([0x00]) = 0x00, so frame [0x00, 0x00] should pass
|
||||||
expect(validateFrame(Uint8List.fromList([0x00, 0x00])), isTrue);
|
expect(validateFrame(Uint8List.fromList([0x00, 0x00])), isTrue);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
100
test/rpm_glitch_filter_test.dart
Normal file
100
test/rpm_glitch_filter_test.dart
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
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,264 +1,348 @@
|
|||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:hvbt_dash/core/protocol/neg8.dart';
|
import 'package:hvbt_dash/core/protocol/neg8.dart';
|
||||||
import 'package:hvbt_dash/core/protocol/s300_parser.dart';
|
import 'package:hvbt_dash/core/protocol/s300_parser.dart';
|
||||||
import 'package:hvbt_dash/core/protocol/temp_table.dart';
|
import 'package:hvbt_dash/core/protocol/temp_table.dart';
|
||||||
|
|
||||||
/// Builds a zeroed 128-byte S300 frame and stamps the NEG8 checksum at byte 127.
|
/// Builds a zeroed 128-byte S300 frame and stamps the NEG8 checksum at byte 127.
|
||||||
Uint8List _buildS300Frame({
|
Uint8List _buildS300Frame({
|
||||||
int rpm = 0,
|
int rpm = 0,
|
||||||
int vss = 0,
|
int vss = 0,
|
||||||
int mapRaw = 0,
|
int mapRaw = 0,
|
||||||
int tps = 0,
|
int tps = 0,
|
||||||
int inj = 0,
|
int inj = 0,
|
||||||
int ign = 0,
|
int ign = 0,
|
||||||
int ect = 0x80, // tempXlt[0x80]=73 → 73-40=33°C
|
int ect = 0x80, // tempXlt[0x80]=73 → 73-40=33°C
|
||||||
int iat = 0x80,
|
int iat = 0x80,
|
||||||
int bat = 0,
|
int bat = 0,
|
||||||
int sw1 = 0,
|
int sw1 = 0,
|
||||||
int sw2 = 0,
|
int sw2 = 0,
|
||||||
int sw3 = 0,
|
int sw3 = 0,
|
||||||
int krtrd = 0,
|
int krtrd = 0,
|
||||||
int gear = 0,
|
int gear = 0,
|
||||||
}) {
|
Map<int, int> extra = const {},
|
||||||
final frame = Uint8List(128);
|
}) {
|
||||||
// Header
|
final frame = Uint8List(128);
|
||||||
frame[0] = 0x1B;
|
// Header
|
||||||
frame[1] = 0x00;
|
frame[0] = 0x1B;
|
||||||
// RPM: bytes +3..4, little-endian, raw
|
frame[1] = 0x00;
|
||||||
frame[3] = rpm & 0xFF;
|
// RPM: bytes +3..4, little-endian, raw
|
||||||
frame[4] = (rpm >> 8) & 0xFF;
|
frame[3] = rpm & 0xFF;
|
||||||
// VSS: bytes +5..6, little-endian
|
frame[4] = (rpm >> 8) & 0xFF;
|
||||||
frame[5] = vss & 0xFF;
|
// VSS: bytes +5..6, little-endian
|
||||||
frame[6] = (vss >> 8) & 0xFF;
|
frame[5] = vss & 0xFF;
|
||||||
// MAP: bytes +7..8, little-endian
|
frame[6] = (vss >> 8) & 0xFF;
|
||||||
frame[7] = mapRaw & 0xFF;
|
// MAP: bytes +7..8, little-endian
|
||||||
frame[8] = (mapRaw >> 8) & 0xFF;
|
frame[7] = mapRaw & 0xFF;
|
||||||
// TPS: byte +9
|
frame[8] = (mapRaw >> 8) & 0xFF;
|
||||||
frame[9] = tps;
|
// TPS: byte +9
|
||||||
// INJ: bytes +10..11, BE
|
frame[9] = tps;
|
||||||
frame[10] = (inj >> 8) & 0xFF;
|
// INJ: bytes +10..11, little-endian
|
||||||
frame[11] = inj & 0xFF;
|
frame[10] = inj & 0xFF;
|
||||||
// IGN: byte +12
|
frame[11] = (inj >> 8) & 0xFF;
|
||||||
frame[12] = ign;
|
// IGN: byte +12
|
||||||
// KRtrd: byte +13
|
frame[12] = ign;
|
||||||
frame[13] = krtrd;
|
// KRtrd: byte +13
|
||||||
// SW bitmaps
|
frame[13] = krtrd;
|
||||||
frame[0x11] = sw1;
|
// SW bitmaps
|
||||||
frame[0x12] = sw2;
|
frame[0x11] = sw1;
|
||||||
frame[0x13] = sw3;
|
frame[0x12] = sw2;
|
||||||
// Gear: decimal offset 27
|
frame[0x13] = sw3;
|
||||||
frame[27] = gear;
|
// Gear: decimal offset 27
|
||||||
// ECT: decimal offset 33
|
frame[27] = gear;
|
||||||
frame[33] = ect;
|
// ECT: decimal offset 33
|
||||||
// IAT: decimal offset 34
|
frame[33] = ect;
|
||||||
frame[34] = iat;
|
// IAT: decimal offset 34
|
||||||
// BAT: decimal offset 36
|
frame[34] = iat;
|
||||||
frame[36] = bat;
|
// BAT: decimal offset 36
|
||||||
|
frame[36] = bat;
|
||||||
// Stamp NEG8 checksum at byte 127
|
extra.forEach((offset, value) => frame[offset] = value);
|
||||||
int neg8 = 0;
|
|
||||||
for (int i = 0; i < 127; i++) {
|
// Stamp NEG8 checksum at byte 127
|
||||||
neg8 = (neg8 - frame[i]) & 0xFF;
|
int neg8 = 0;
|
||||||
}
|
for (int i = 0; i < 127; i++) {
|
||||||
frame[127] = neg8;
|
neg8 = (neg8 - frame[i]) & 0xFF;
|
||||||
return frame;
|
}
|
||||||
}
|
frame[127] = neg8;
|
||||||
|
return frame;
|
||||||
void main() {
|
}
|
||||||
group('S300 parser', () {
|
|
||||||
test('parses RPM correctly', () {
|
void main() {
|
||||||
// RPM raw = 3000
|
group('S300 parser', () {
|
||||||
final frame = _buildS300Frame(rpm: 3000);
|
test('parses RPM correctly', () {
|
||||||
final state = parseS300(frame);
|
// RPM raw = 3000
|
||||||
expect(state.rpm, equals(3000.0));
|
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);
|
test('parses VSS = 0 when raw < 893', () {
|
||||||
expect(state.vss, equals(0.0));
|
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);
|
test('parses VSS correctly when raw >= 893', () {
|
||||||
final state = parseS300(frame);
|
// vss = 228480 / 1000 = 228.48 km/h
|
||||||
expect(state.vss, closeTo(228.48, 0.01));
|
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);
|
test('parses MAP as raw/10 kPa', () {
|
||||||
final state = parseS300(frame);
|
// mapRaw = 1148 -> 114.8 kPa
|
||||||
expect(state.map, closeTo(114.8, 0.01));
|
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);
|
test('parses TPS = 0 when raw = 0', () {
|
||||||
expect(state.tps, equals(0.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);
|
test('parses TPS = 100 when raw = 255', () {
|
||||||
expect(state.tps, equals(100.0));
|
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);
|
test('parses TPS correctly when raw >= 25', () {
|
||||||
final state = parseS300(frame);
|
// (raw - 25) / 2.04: raw 127 -> 50 %; USB tool showed 31 % at raw ≈ 88.
|
||||||
expect(state.tps, closeTo(49.8, 0.1));
|
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);
|
test('parses Gear from decimal offset 27', () {
|
||||||
final state = parseS300(frame);
|
final frame = _buildS300Frame(gear: 5);
|
||||||
expect(state.gear, equals(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
|
test('parses ECT using temp lookup table', () {
|
||||||
// tool's 28.4°F exactly at the same instant (RPM cross-checked).
|
// Confirmed via live capture: raw=0xD2(210) matched the reference
|
||||||
// ect raw = 0xD2 = 210 → tempXlt[210]=38 → 38-40=-2°C
|
// tool's 28.4°F exactly at the same instant (RPM cross-checked).
|
||||||
final frame = _buildS300Frame(ect: 0xD2);
|
// ect raw = 0xD2 = 210 → tempXlt[210]=38 → 38-40=-2°C
|
||||||
final state = parseS300(frame);
|
final frame = _buildS300Frame(ect: 0xD2);
|
||||||
expect(state.ect, equals((tempXlt[0xD2] - 40).toDouble()));
|
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
|
test('parses IAT using temp lookup table', () {
|
||||||
// ~1.3°F of the reference tool's 36.1°F reading.
|
// Confirmed via the same live capture: raw=0xCA(202) was within
|
||||||
// iat raw = 0xCA = 202 → tempXlt[202]=43 → 43-40=3°C
|
// ~1.3°F of the reference tool's 36.1°F reading.
|
||||||
final frame = _buildS300Frame(iat: 0xCA);
|
// iat raw = 0xCA = 202 → tempXlt[202]=43 → 43-40=3°C
|
||||||
final state = parseS300(frame);
|
final frame = _buildS300Frame(iat: 0xCA);
|
||||||
expect(state.iat, equals((tempXlt[0xCA] - 40).toDouble()));
|
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
|
test('parses BAT = raw * 26 / 270', () {
|
||||||
final frame = _buildS300Frame(bat: 135);
|
// bat raw = 135 → 135*26/270 = 13.0 V
|
||||||
final state = parseS300(frame);
|
final frame = _buildS300Frame(bat: 135);
|
||||||
expect(state.bat, closeTo(13.0, 0.01));
|
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
|
test('IGN decode: (raw + 120) / 2', () {
|
||||||
final frame = _buildS300Frame(ign: 164);
|
// ign raw = 164 -> (164 + 120) / 2 = 142.0
|
||||||
final state = parseS300(frame);
|
final frame = _buildS300Frame(ign: 164);
|
||||||
expect(state.ign, equals(142.0));
|
final state = parseS300(frame);
|
||||||
});
|
expect(state.ign, equals(142.0));
|
||||||
|
});
|
||||||
test('MIL flag set from SW2 bit5', () {
|
|
||||||
final frame = _buildS300Frame(sw2: 0x20); // bit5 set
|
test('MIL flag set from SW2 bit5', () {
|
||||||
final state = parseS300(frame);
|
final frame = _buildS300Frame(sw2: 0x20); // bit5 set
|
||||||
expect(state.mil, isTrue);
|
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
|
test('fuel pump flag is active-low: set when SW2 bit2 is CLEAR', () {
|
||||||
// list — confirmed live to be the fuel PUMP relay status, not fuel
|
// SW2 bit 2 is the "Fuel" line in the reference tool's own channel
|
||||||
// cut, but also confirmed live to be active-low: an extended session
|
// list — confirmed live to be the fuel PUMP relay status, not fuel
|
||||||
// held RPM above the ~300 threshold where the pump should be on, and
|
// cut, but also confirmed live to be active-low: an extended session
|
||||||
// this bit read 0 for the entire session. Separately confirmed the
|
// held RPM above the ~300 threshold where the pump should be on, and
|
||||||
// app showed "on" below ~200 rpm and "off" above it — exactly
|
// this bit read 0 for the entire session. Separately confirmed the
|
||||||
// inverted from a naive `!= 0` read, hence bit CLEAR = pump on.
|
// app showed "on" below ~200 rpm and "off" above it — exactly
|
||||||
final frame = _buildS300Frame(sw2: 0x38); // bit2 clear, other bits set
|
// inverted from a naive `!= 0` read, hence bit CLEAR = pump on.
|
||||||
final state = parseS300(frame);
|
final frame = _buildS300Frame(sw2: 0x38); // bit2 clear, other bits set
|
||||||
expect(state.fuelPump, isTrue);
|
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
|
test('fuel pump flag clear (pump off) when SW2 bit2 is set', () {
|
||||||
final state = parseS300(frame);
|
final frame = _buildS300Frame(sw2: 0x04); // bit2 set
|
||||||
expect(state.fuelPump, isFalse);
|
final state = parseS300(frame);
|
||||||
});
|
expect(state.fuelPump, isFalse);
|
||||||
|
});
|
||||||
test('knock flag set when krtrd > 0', () {
|
|
||||||
final frame = _buildS300Frame(krtrd: 5);
|
test('knock flag set when krtrd > 0', () {
|
||||||
final state = parseS300(frame);
|
final frame = _buildS300Frame(krtrd: 5);
|
||||||
expect(state.knock, isTrue);
|
final state = parseS300(frame);
|
||||||
});
|
expect(state.knock, isTrue);
|
||||||
|
});
|
||||||
test('knock flag clear when krtrd = 0', () {
|
|
||||||
final frame = _buildS300Frame(krtrd: 0);
|
test('knock flag clear when krtrd = 0', () {
|
||||||
final state = parseS300(frame);
|
final frame = _buildS300Frame(krtrd: 0);
|
||||||
expect(state.knock, isFalse);
|
final state = parseS300(frame);
|
||||||
});
|
expect(state.knock, isFalse);
|
||||||
|
});
|
||||||
test('throws on wrong frame length', () {
|
|
||||||
expect(
|
test('throws on wrong frame length', () {
|
||||||
() => parseS300(Uint8List(64)),
|
expect(
|
||||||
throwsArgumentError,
|
() => parseS300(Uint8List(64)),
|
||||||
);
|
throwsArgumentError,
|
||||||
});
|
);
|
||||||
|
});
|
||||||
test('throws on bad checksum', () {
|
|
||||||
final frame = _buildS300Frame(rpm: 1000);
|
// ── Offsets corrected 2026-10-12 (column-count mapping) ──────────────
|
||||||
frame[127] = 0x00; // corrupt checksum
|
test('fuel trims: bytes 29/31, 0x80 = 0 %', () {
|
||||||
expect(() => parseS300(frame), throwsArgumentError);
|
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('can parse bad checksum frame when validation is disabled', () {
|
});
|
||||||
final frame = _buildS300Frame(rpm: 1000);
|
|
||||||
frame[127] = 0x00; // corrupt checksum
|
test('DTC bytes are ERR00..ERR03 at 37..40', () {
|
||||||
final state = parseS300(frame, validateChecksum: false);
|
final s = parseS300(
|
||||||
expect(state.rpm, equals(1000.0));
|
_buildS300Frame(extra: {37: 0x01, 38: 0x02, 39: 0x04, 40: 0x08}));
|
||||||
});
|
expect(s.errBytes, equals([0x01, 0x02, 0x04, 0x08]));
|
||||||
|
});
|
||||||
test('decodes RPM=0 for a frame matching the fixed event-frame template', () {
|
|
||||||
// parseS300 itself doesn't special-case this template — it just reads
|
test('SW6 (byte 50) no longer produces phantom DTCs', () {
|
||||||
// the normal offsets, which land on the template's fixed bytes.
|
final s = parseS300(_buildS300Frame(extra: {50: 0x60, 49: 0x00}));
|
||||||
// Holding the last known-good value across this frame is the caller's
|
expect(s.errBytes, equals([0, 0, 0, 0]));
|
||||||
// job (sensor_provider.dart), driven by isS300TemplateFrame below.
|
});
|
||||||
final frame = _templateFrame();
|
|
||||||
final state = parseS300(frame);
|
test('fan output = SW5 (byte 49) bit0 FANC, not SW2 ALTC', () {
|
||||||
expect(state.rpm, equals(0.0));
|
expect(parseS300(_buildS300Frame(extra: {49: 0x01})).fanOut, isTrue);
|
||||||
});
|
expect(parseS300(_buildS300Frame(sw2: 0x80)).fanOut, isFalse);
|
||||||
|
});
|
||||||
test('isS300TemplateFrame detects the fixed event-frame byte pattern', () {
|
|
||||||
// Verified via live A/B capture: this exact bytes-0-16 pattern shows
|
test('ethanol from byte 41, AF raw from byte 55, PA from byte 32', () {
|
||||||
// up whenever SW2 (byte 18, outside this span) reports fuel cut
|
final s = parseS300(_buildS300Frame(extra: {41: 85, 55: 0x9A, 32: 0x93}));
|
||||||
// active, corroborated independently by the same frame's INJ pulse
|
expect(s.eth, equals(85.0));
|
||||||
// width reading 0 and TPS reading a closed-throttle value.
|
expect(s.afr, equals(154.0));
|
||||||
expect(isS300TemplateFrame(_templateFrame()), isTrue);
|
expect(s.pa, closeTo(100.25, 0.01));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('isS300TemplateFrame is false for a normal live-reading frame', () {
|
test('INJ read little-endian', () {
|
||||||
final frame = _buildS300Frame(rpm: 3000, tps: 127);
|
final s = parseS300(_buildS300Frame(inj: 3000));
|
||||||
expect(isS300TemplateFrame(frame), isFalse);
|
expect(s.inj, closeTo(3.0, 0.001));
|
||||||
});
|
});
|
||||||
});
|
|
||||||
}
|
test('AIN0 read little-endian at 82', () {
|
||||||
|
final s = parseS300(_buildS300Frame(extra: {82: 0x34, 83: 0x02}));
|
||||||
/// Builds a 128-byte frame stamped with the S300's fixed event-frame
|
expect(s.ain0, equals(0x0234.toDouble()));
|
||||||
/// template in bytes 0-16, with a valid NEG8 checksum.
|
});
|
||||||
Uint8List _templateFrame() {
|
|
||||||
final frame = Uint8List(128);
|
test('adapter default frame decodes to the PDF example (sanity)', () {
|
||||||
final prefix = [
|
final f = _fullDefaultFrame();
|
||||||
0x1B,
|
expect(isS300DefaultFrame(f), isTrue);
|
||||||
0x00,
|
final s = parseS300(f);
|
||||||
0x14,
|
expect(s.map, closeTo(101.0, 0.01)); // 0x03F2
|
||||||
0x00,
|
expect(s.gear, equals(1));
|
||||||
0x00,
|
expect(s.ect, equals(20.0)); // 0xA0
|
||||||
0xFF,
|
expect(s.iat, equals(20.0));
|
||||||
0xFF,
|
expect(s.bat, closeTo(12.04, 0.01)); // 0x7D
|
||||||
0xF2,
|
expect(s.strim, closeTo(0.0, 0.001));
|
||||||
0x03,
|
expect(s.errBytes, equals([0, 0, 0, 0]));
|
||||||
0x18,
|
});
|
||||||
0x00,
|
|
||||||
0x00,
|
test('throws on bad checksum', () {
|
||||||
0x10,
|
final frame = _buildS300Frame(rpm: 1000);
|
||||||
0x00,
|
frame[127] = 0x00; // corrupt checksum
|
||||||
0x00,
|
expect(() => parseS300(frame), throwsArgumentError);
|
||||||
0x00,
|
});
|
||||||
0xC4,
|
|
||||||
];
|
test('can parse bad checksum frame when validation is disabled', () {
|
||||||
for (var i = 0; i < prefix.length; i++) {
|
final frame = _buildS300Frame(rpm: 1000);
|
||||||
frame[i] = prefix[i];
|
frame[127] = 0x00; // corrupt checksum
|
||||||
}
|
final state = parseS300(frame, validateChecksum: false);
|
||||||
frame[127] = calculateNeg8(frame.sublist(0, 127));
|
expect(state.rpm, equals(1000.0));
|
||||||
return frame;
|
});
|
||||||
}
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|||||||
107
test/units_and_flags_test.dart
Normal file
107
test/units_and_flags_test.dart
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
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