Snapshot before poller/parser rework
This commit is contained in:
parent
77a47eb938
commit
fb87e79d41
56
.gitignore
vendored
56
.gitignore
vendored
@ -1,28 +1,28 @@
|
||||
# Flutter/Dart
|
||||
.dart_tool/
|
||||
.flutter-plugins
|
||||
.flutter-plugins-dependencies
|
||||
.packages
|
||||
.pub-cache/
|
||||
.pub/
|
||||
build/
|
||||
|
||||
# Android
|
||||
android/.gradle/
|
||||
android/.kotlin/
|
||||
android/captures/
|
||||
android/local.properties
|
||||
android/key.properties
|
||||
android/hs_err_pid*.log
|
||||
android/replay_pid*.log
|
||||
*.jks
|
||||
*.log
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.iml
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
# Flutter/Dart
|
||||
.dart_tool/
|
||||
.flutter-plugins
|
||||
.flutter-plugins-dependencies
|
||||
.packages
|
||||
.pub-cache/
|
||||
.pub/
|
||||
build/
|
||||
|
||||
# Android
|
||||
android/.gradle/
|
||||
android/.kotlin/
|
||||
android/captures/
|
||||
android/local.properties
|
||||
android/key.properties
|
||||
android/hs_err_pid*.log
|
||||
android/replay_pid*.log
|
||||
*.jks
|
||||
*.log
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.iml
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
60
.metadata
60
.metadata
@ -1,30 +1,30 @@
|
||||
# This file tracks properties of this Flutter project.
|
||||
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||
#
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: "db50e20168db8fee486b9abf32fc912de3bc5b6a"
|
||||
channel: "stable"
|
||||
|
||||
project_type: app
|
||||
|
||||
# Tracks metadata for the flutter migrate command
|
||||
migration:
|
||||
platforms:
|
||||
- platform: root
|
||||
create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
||||
base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
||||
- platform: android
|
||||
create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
||||
base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
||||
|
||||
# User provided section
|
||||
|
||||
# List of Local paths (relative to this file) that should be
|
||||
# ignored by the migrate tool.
|
||||
#
|
||||
# Files that are not part of the templates will be ignored by default.
|
||||
unmanaged_files:
|
||||
- 'lib/main.dart'
|
||||
- 'ios/Runner.xcodeproj/project.pbxproj'
|
||||
# This file tracks properties of this Flutter project.
|
||||
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||
#
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: "db50e20168db8fee486b9abf32fc912de3bc5b6a"
|
||||
channel: "stable"
|
||||
|
||||
project_type: app
|
||||
|
||||
# Tracks metadata for the flutter migrate command
|
||||
migration:
|
||||
platforms:
|
||||
- platform: root
|
||||
create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
||||
base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
||||
- platform: android
|
||||
create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
||||
base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
||||
|
||||
# User provided section
|
||||
|
||||
# List of Local paths (relative to this file) that should be
|
||||
# ignored by the migrate tool.
|
||||
#
|
||||
# Files that are not part of the templates will be ignored by default.
|
||||
unmanaged_files:
|
||||
- 'lib/main.dart'
|
||||
- 'ios/Runner.xcodeproj/project.pbxproj'
|
||||
|
||||
BIN
Bluetooth.pdf
Normal file
BIN
Bluetooth.pdf
Normal file
Binary file not shown.
32
README.md
32
README.md
@ -1,16 +1,16 @@
|
||||
# HV BT Dashboard
|
||||
|
||||
HV BT Dashboard is a Flutter Android app for live Honda ECU monitoring over Bluetooth. It connects to a paired Bluetooth ECU adapter, polls S300 or KPro data frames, validates and parses those frames, then displays live sensors, graphs, diagnostic trouble codes, and local datalog recordings.
|
||||
|
||||
Full layman-friendly documentation is here:
|
||||
|
||||
- [docs/FULL_PROJECT_DOCUMENTATION.md](docs/FULL_PROJECT_DOCUMENTATION.md)
|
||||
|
||||
Common commands:
|
||||
|
||||
```powershell
|
||||
flutter run
|
||||
flutter run --release
|
||||
flutter build apk --release
|
||||
flutter test
|
||||
```
|
||||
# HV BT Dashboard
|
||||
|
||||
HV BT Dashboard is a Flutter Android app for live Honda ECU monitoring over Bluetooth. It connects to a paired Bluetooth ECU adapter, polls S300 or KPro data frames, validates and parses those frames, then displays live sensors, graphs, diagnostic trouble codes, and local datalog recordings.
|
||||
|
||||
Full layman-friendly documentation is here:
|
||||
|
||||
- [docs/FULL_PROJECT_DOCUMENTATION.md](docs/FULL_PROJECT_DOCUMENTATION.md)
|
||||
|
||||
Common commands:
|
||||
|
||||
```powershell
|
||||
flutter run
|
||||
flutter run --release
|
||||
flutter build apk --release
|
||||
flutter test
|
||||
```
|
||||
|
||||
BIN
Requrements.pdf
Normal file
BIN
Requrements.pdf
Normal file
Binary file not shown.
@ -1,28 +1,28 @@
|
||||
# This file configures the analyzer, which statically analyzes Dart code to
|
||||
# check for errors, warnings, and lints.
|
||||
#
|
||||
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
|
||||
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
|
||||
# invoked from the command line by running `flutter analyze`.
|
||||
|
||||
# The following line activates a set of recommended lints for Flutter apps,
|
||||
# packages, and plugins designed to encourage good coding practices.
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
linter:
|
||||
# The lint rules applied to this project can be customized in the
|
||||
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
||||
# included above or to enable additional rules. A list of all available lints
|
||||
# and their documentation is published at https://dart.dev/lints.
|
||||
#
|
||||
# Instead of disabling a lint rule for the entire project in the
|
||||
# section below, it can also be suppressed for a single line of code
|
||||
# or a specific dart file by using the `// ignore: name_of_lint` and
|
||||
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
|
||||
# producing the lint.
|
||||
rules:
|
||||
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||
|
||||
# Additional information about this file can be found at
|
||||
# https://dart.dev/guides/language/analysis-options
|
||||
# This file configures the analyzer, which statically analyzes Dart code to
|
||||
# check for errors, warnings, and lints.
|
||||
#
|
||||
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
|
||||
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
|
||||
# invoked from the command line by running `flutter analyze`.
|
||||
|
||||
# The following line activates a set of recommended lints for Flutter apps,
|
||||
# packages, and plugins designed to encourage good coding practices.
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
linter:
|
||||
# The lint rules applied to this project can be customized in the
|
||||
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
||||
# included above or to enable additional rules. A list of all available lints
|
||||
# and their documentation is published at https://dart.dev/lints.
|
||||
#
|
||||
# Instead of disabling a lint rule for the entire project in the
|
||||
# section below, it can also be suppressed for a single line of code
|
||||
# or a specific dart file by using the `// ignore: name_of_lint` and
|
||||
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
|
||||
# producing the lint.
|
||||
rules:
|
||||
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||
|
||||
# Additional information about this file can be found at
|
||||
# https://dart.dev/guides/language/analysis-options
|
||||
|
||||
28
android/.gitignore
vendored
28
android/.gitignore
vendored
@ -1,14 +1,14 @@
|
||||
gradle-wrapper.jar
|
||||
/.gradle
|
||||
/captures/
|
||||
/gradlew
|
||||
/gradlew.bat
|
||||
/local.properties
|
||||
GeneratedPluginRegistrant.java
|
||||
.cxx/
|
||||
|
||||
# Remember to never publicly share your keystore.
|
||||
# See https://flutter.dev/to/reference-keystore
|
||||
key.properties
|
||||
**/*.keystore
|
||||
**/*.jks
|
||||
gradle-wrapper.jar
|
||||
/.gradle
|
||||
/captures/
|
||||
/gradlew
|
||||
/gradlew.bat
|
||||
/local.properties
|
||||
GeneratedPluginRegistrant.java
|
||||
.cxx/
|
||||
|
||||
# Remember to never publicly share your keystore.
|
||||
# See https://flutter.dev/to/reference-keystore
|
||||
key.properties
|
||||
**/*.keystore
|
||||
**/*.jks
|
||||
|
||||
@ -1,198 +1,198 @@
|
||||
kotlin version: 2.2.20
|
||||
error message: Daemon compilation failed: null
|
||||
java.lang.Exception
|
||||
at org.jetbrains.kotlin.daemon.common.CompileService$CallResult$Error.get(CompileService.kt:69)
|
||||
at org.jetbrains.kotlin.daemon.common.CompileService$CallResult$Error.get(CompileService.kt:65)
|
||||
at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.compileWithDaemon(GradleKotlinCompilerWork.kt:240)
|
||||
at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.compileWithDaemonOrFallbackImpl(GradleKotlinCompilerWork.kt:159)
|
||||
at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.run(GradleKotlinCompilerWork.kt:111)
|
||||
at org.jetbrains.kotlin.compilerRunner.GradleCompilerRunnerWithWorkers$GradleKotlinCompilerWorkAction.execute(GradleCompilerRunnerWithWorkers.kt:74)
|
||||
at org.gradle.workers.internal.DefaultWorkerServer.execute(DefaultWorkerServer.java:63)
|
||||
at org.gradle.workers.internal.NoIsolationWorkerFactory$1$1.create(NoIsolationWorkerFactory.java:66)
|
||||
at org.gradle.workers.internal.NoIsolationWorkerFactory$1$1.create(NoIsolationWorkerFactory.java:62)
|
||||
at org.gradle.internal.classloader.ClassLoaderUtils.executeInClassloader(ClassLoaderUtils.java:100)
|
||||
at org.gradle.workers.internal.NoIsolationWorkerFactory$1.lambda$execute$0(NoIsolationWorkerFactory.java:62)
|
||||
at org.gradle.workers.internal.AbstractWorker$1.call(AbstractWorker.java:44)
|
||||
at org.gradle.workers.internal.AbstractWorker$1.call(AbstractWorker.java:41)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:210)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:205)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:67)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:60)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:167)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:60)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner.call(DefaultBuildOperationRunner.java:54)
|
||||
at org.gradle.workers.internal.AbstractWorker.executeWrappedInBuildOperation(AbstractWorker.java:41)
|
||||
at org.gradle.workers.internal.NoIsolationWorkerFactory$1.execute(NoIsolationWorkerFactory.java:59)
|
||||
at org.gradle.workers.internal.DefaultWorkerExecutor.lambda$submitWork$0(DefaultWorkerExecutor.java:174)
|
||||
at java.base/java.util.concurrent.FutureTask.run(Unknown Source)
|
||||
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.runExecution(DefaultConditionalExecutionQueue.java:194)
|
||||
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.access$700(DefaultConditionalExecutionQueue.java:127)
|
||||
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner$1.run(DefaultConditionalExecutionQueue.java:169)
|
||||
at org.gradle.internal.Factories$1.create(Factories.java:31)
|
||||
at org.gradle.internal.work.DefaultWorkerLeaseService.withLocks(DefaultWorkerLeaseService.java:263)
|
||||
at org.gradle.internal.work.DefaultWorkerLeaseService.runAsWorkerThread(DefaultWorkerLeaseService.java:127)
|
||||
at org.gradle.internal.work.DefaultWorkerLeaseService.runAsWorkerThread(DefaultWorkerLeaseService.java:132)
|
||||
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.runBatch(DefaultConditionalExecutionQueue.java:164)
|
||||
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.run(DefaultConditionalExecutionQueue.java:133)
|
||||
at java.base/java.util.concurrent.Executors$RunnableAdapter.call(Unknown Source)
|
||||
at java.base/java.util.concurrent.FutureTask.run(Unknown Source)
|
||||
at org.gradle.internal.concurrent.ExecutorPolicy$CatchAndRecordFailures.onExecute(ExecutorPolicy.java:64)
|
||||
at org.gradle.internal.concurrent.AbstractManagedExecutor$1.run(AbstractManagedExecutor.java:48)
|
||||
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source)
|
||||
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source)
|
||||
at java.base/java.lang.Thread.run(Unknown Source)
|
||||
Caused by: java.lang.AssertionError: java.lang.Exception: Could not close incremental caches in D:\2026\BT-CAR-App\hvbt_dash\build\shared_preferences_android\kotlin\compileReleaseKotlin\cacheable\caches-jvm\jvm\kotlin: class-fq-name-to-source.tab, source-to-classes.tab, internal-name-to-source.tab
|
||||
at org.jetbrains.kotlin.com.google.common.io.Closer.close(Closer.java:218)
|
||||
at org.jetbrains.kotlin.incremental.IncrementalCachesManager.close(IncrementalCachesManager.kt:55)
|
||||
at kotlin.io.CloseableKt.closeFinally(Closeable.kt:46)
|
||||
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compileNonIncrementally(IncrementalCompilerRunner.kt:293)
|
||||
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compile(IncrementalCompilerRunner.kt:128)
|
||||
at org.jetbrains.kotlin.daemon.CompileServiceImplBase.execIncrementalCompiler(CompileServiceImpl.kt:684)
|
||||
at org.jetbrains.kotlin.daemon.CompileServiceImplBase.access$execIncrementalCompiler(CompileServiceImpl.kt:94)
|
||||
at org.jetbrains.kotlin.daemon.CompileServiceImpl.compile(CompileServiceImpl.kt:1810)
|
||||
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(Unknown Source)
|
||||
at java.base/java.lang.reflect.Method.invoke(Unknown Source)
|
||||
at java.rmi/sun.rmi.server.UnicastServerRef.dispatch(Unknown Source)
|
||||
at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source)
|
||||
at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source)
|
||||
at java.base/java.security.AccessController.doPrivileged(Unknown Source)
|
||||
at java.rmi/sun.rmi.transport.Transport.serviceCall(Unknown Source)
|
||||
at java.rmi/sun.rmi.transport.tcp.TCPTransport.handleMessages(Unknown Source)
|
||||
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run0(Unknown Source)
|
||||
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.lambda$run$0(Unknown Source)
|
||||
at java.base/java.security.AccessController.doPrivileged(Unknown Source)
|
||||
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run(Unknown Source)
|
||||
... 3 more
|
||||
Caused by: java.lang.Exception: Could not close incremental caches in D:\2026\BT-CAR-App\hvbt_dash\build\shared_preferences_android\kotlin\compileReleaseKotlin\cacheable\caches-jvm\jvm\kotlin: class-fq-name-to-source.tab, source-to-classes.tab, internal-name-to-source.tab
|
||||
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.forEachMapSafe(BasicMapsOwner.kt:95)
|
||||
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.close(BasicMapsOwner.kt:53)
|
||||
at org.jetbrains.kotlin.com.google.common.io.Closer.close(Closer.java:205)
|
||||
... 22 more
|
||||
Suppressed: java.lang.IllegalArgumentException: this and base files have different roots: C:\Users\mohan\AppData\Local\Pub\Cache\hosted\pub.dev\shared_preferences_android-2.4.23\android\src\main\kotlin\io\flutter\plugins\sharedpreferences\MessagesAsync.g.kt and D:\2026\BT-CAR-App\hvbt_dash\android.
|
||||
at kotlin.io.FilesKt__UtilsKt.toRelativeString(Utils.kt:117)
|
||||
at kotlin.io.FilesKt__UtilsKt.relativeTo(Utils.kt:128)
|
||||
at org.jetbrains.kotlin.incremental.storage.RelocatableFileToPathConverter.toPath(RelocatableFileToPathConverter.kt:24)
|
||||
at org.jetbrains.kotlin.incremental.storage.FileDescriptor.save(FileToPathConverter.kt:33)
|
||||
at org.jetbrains.kotlin.incremental.storage.FileDescriptor.save(FileToPathConverter.kt:30)
|
||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.doPut(PersistentMapImpl.java:447)
|
||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.put(PersistentMapImpl.java:426)
|
||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.put(PersistentHashMap.java:106)
|
||||
at org.jetbrains.kotlin.incremental.storage.LazyStorage.set(LazyStorage.kt:80)
|
||||
at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.applyChanges(InMemoryStorage.kt:108)
|
||||
at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.close(InMemoryStorage.kt:136)
|
||||
at org.jetbrains.kotlin.incremental.storage.PersistentStorageWrapper.close(PersistentStorage.kt:124)
|
||||
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53)
|
||||
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53)
|
||||
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.forEachMapSafe(BasicMapsOwner.kt:87)
|
||||
... 24 more
|
||||
Suppressed: java.lang.IllegalArgumentException: this and base files have different roots: C:\Users\mohan\AppData\Local\Pub\Cache\hosted\pub.dev\shared_preferences_android-2.4.23\android\src\main\kotlin\io\flutter\plugins\sharedpreferences\MessagesAsync.g.kt and D:\2026\BT-CAR-App\hvbt_dash\android.
|
||||
at kotlin.io.FilesKt__UtilsKt.toRelativeString(Utils.kt:117)
|
||||
at kotlin.io.FilesKt__UtilsKt.relativeTo(Utils.kt:128)
|
||||
at org.jetbrains.kotlin.incremental.storage.RelocatableFileToPathConverter.toPath(RelocatableFileToPathConverter.kt:24)
|
||||
at org.jetbrains.kotlin.incremental.storage.FileDescriptor.getHashCode(FileToPathConverter.kt:50)
|
||||
at org.jetbrains.kotlin.incremental.storage.FileDescriptor.getHashCode(FileToPathConverter.kt:30)
|
||||
at org.jetbrains.kotlin.com.intellij.util.containers.LinkedCustomHashMap.hashKey(LinkedCustomHashMap.java:109)
|
||||
at org.jetbrains.kotlin.com.intellij.util.containers.LinkedCustomHashMap.remove(LinkedCustomHashMap.java:153)
|
||||
at org.jetbrains.kotlin.com.intellij.util.containers.SLRUMap.remove(SLRUMap.java:89)
|
||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.flushAppendCache(PersistentMapImpl.java:1007)
|
||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.doPut(PersistentMapImpl.java:455)
|
||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.put(PersistentMapImpl.java:426)
|
||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.put(PersistentHashMap.java:106)
|
||||
at org.jetbrains.kotlin.incremental.storage.LazyStorage.set(LazyStorage.kt:80)
|
||||
at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.applyChanges(InMemoryStorage.kt:108)
|
||||
at org.jetbrains.kotlin.incremental.storage.AppendableInMemoryStorage.applyChanges(InMemoryStorage.kt:179)
|
||||
at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.close(InMemoryStorage.kt:136)
|
||||
at org.jetbrains.kotlin.incremental.storage.AppendableSetBasicMap.close(BasicMap.kt:157)
|
||||
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53)
|
||||
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53)
|
||||
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.forEachMapSafe(BasicMapsOwner.kt:87)
|
||||
... 24 more
|
||||
Suppressed: java.lang.IllegalArgumentException: this and base files have different roots: C:\Users\mohan\AppData\Local\Pub\Cache\hosted\pub.dev\shared_preferences_android-2.4.23\android\src\main\kotlin\io\flutter\plugins\sharedpreferences\MessagesAsync.g.kt and D:\2026\BT-CAR-App\hvbt_dash\android.
|
||||
at kotlin.io.FilesKt__UtilsKt.toRelativeString(Utils.kt:117)
|
||||
at kotlin.io.FilesKt__UtilsKt.relativeTo(Utils.kt:128)
|
||||
at org.jetbrains.kotlin.incremental.storage.RelocatableFileToPathConverter.toPath(RelocatableFileToPathConverter.kt:24)
|
||||
at org.jetbrains.kotlin.incremental.storage.FileDescriptor.save(FileToPathConverter.kt:33)
|
||||
at org.jetbrains.kotlin.incremental.storage.FileDescriptor.save(FileToPathConverter.kt:30)
|
||||
at org.jetbrains.kotlin.incremental.storage.AppendableCollectionExternalizer.save(LazyStorage.kt:151)
|
||||
at org.jetbrains.kotlin.incremental.storage.AppendableCollectionExternalizer.save(LazyStorage.kt:142)
|
||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.doPut(PersistentMapImpl.java:447)
|
||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.put(PersistentMapImpl.java:426)
|
||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.put(PersistentHashMap.java:106)
|
||||
at org.jetbrains.kotlin.incremental.storage.LazyStorage.set(LazyStorage.kt:80)
|
||||
at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.applyChanges(InMemoryStorage.kt:108)
|
||||
at org.jetbrains.kotlin.incremental.storage.AppendableInMemoryStorage.applyChanges(InMemoryStorage.kt:179)
|
||||
at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.close(InMemoryStorage.kt:136)
|
||||
at org.jetbrains.kotlin.incremental.storage.PersistentStorageWrapper.close(PersistentStorage.kt:124)
|
||||
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53)
|
||||
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53)
|
||||
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.forEachMapSafe(BasicMapsOwner.kt:87)
|
||||
... 24 more
|
||||
Suppressed: java.lang.Exception: Could not close incremental caches in D:\2026\BT-CAR-App\hvbt_dash\build\shared_preferences_android\kotlin\compileReleaseKotlin\cacheable\caches-jvm\lookups: id-to-file.tab, file-to-id.tab
|
||||
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.forEachMapSafe(BasicMapsOwner.kt:95)
|
||||
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.close(BasicMapsOwner.kt:53)
|
||||
at org.jetbrains.kotlin.incremental.LookupStorage.close(LookupStorage.kt:155)
|
||||
... 23 more
|
||||
Suppressed: java.lang.IllegalArgumentException: this and base files have different roots: C:\Users\mohan\AppData\Local\Pub\Cache\hosted\pub.dev\shared_preferences_android-2.4.23\android\src\main\kotlin\io\flutter\plugins\sharedpreferences\MessagesAsync.g.kt and D:\2026\BT-CAR-App\hvbt_dash\android.
|
||||
at kotlin.io.FilesKt__UtilsKt.toRelativeString(Utils.kt:117)
|
||||
at kotlin.io.FilesKt__UtilsKt.relativeTo(Utils.kt:128)
|
||||
at org.jetbrains.kotlin.incremental.storage.RelocatableFileToPathConverter.toPath(RelocatableFileToPathConverter.kt:24)
|
||||
at org.jetbrains.kotlin.incremental.storage.LegacyFileExternalizer.save(IdToFileMap.kt:51)
|
||||
at org.jetbrains.kotlin.incremental.storage.LegacyFileExternalizer.save(IdToFileMap.kt:48)
|
||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.doPut(PersistentMapImpl.java:447)
|
||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.put(PersistentMapImpl.java:426)
|
||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.put(PersistentHashMap.java:106)
|
||||
at org.jetbrains.kotlin.incremental.storage.LazyStorage.set(LazyStorage.kt:80)
|
||||
at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.applyChanges(InMemoryStorage.kt:108)
|
||||
at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.close(InMemoryStorage.kt:136)
|
||||
at org.jetbrains.kotlin.incremental.storage.PersistentStorageWrapper.close(PersistentStorage.kt:124)
|
||||
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53)
|
||||
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53)
|
||||
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.forEachMapSafe(BasicMapsOwner.kt:87)
|
||||
... 25 more
|
||||
Suppressed: java.lang.IllegalArgumentException: this and base files have different roots: C:\Users\mohan\AppData\Local\Pub\Cache\hosted\pub.dev\shared_preferences_android-2.4.23\android\src\main\kotlin\io\flutter\plugins\sharedpreferences\MessagesAsync.g.kt and D:\2026\BT-CAR-App\hvbt_dash\android.
|
||||
at kotlin.io.FilesKt__UtilsKt.toRelativeString(Utils.kt:117)
|
||||
at kotlin.io.FilesKt__UtilsKt.relativeTo(Utils.kt:128)
|
||||
at org.jetbrains.kotlin.incremental.storage.RelocatableFileToPathConverter.toPath(RelocatableFileToPathConverter.kt:24)
|
||||
at org.jetbrains.kotlin.incremental.storage.FileDescriptor.getHashCode(FileToPathConverter.kt:50)
|
||||
at org.jetbrains.kotlin.incremental.storage.FileDescriptor.getHashCode(FileToPathConverter.kt:30)
|
||||
at org.jetbrains.kotlin.com.intellij.util.containers.LinkedCustomHashMap.hashKey(LinkedCustomHashMap.java:109)
|
||||
at org.jetbrains.kotlin.com.intellij.util.containers.LinkedCustomHashMap.remove(LinkedCustomHashMap.java:153)
|
||||
at org.jetbrains.kotlin.com.intellij.util.containers.SLRUMap.remove(SLRUMap.java:89)
|
||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.flushAppendCache(PersistentMapImpl.java:1007)
|
||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.doPut(PersistentMapImpl.java:455)
|
||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.put(PersistentMapImpl.java:426)
|
||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.put(PersistentHashMap.java:106)
|
||||
at org.jetbrains.kotlin.incremental.storage.LazyStorage.set(LazyStorage.kt:80)
|
||||
at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.applyChanges(InMemoryStorage.kt:108)
|
||||
at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.close(InMemoryStorage.kt:136)
|
||||
at org.jetbrains.kotlin.incremental.storage.PersistentStorageWrapper.close(PersistentStorage.kt:124)
|
||||
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53)
|
||||
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53)
|
||||
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.forEachMapSafe(BasicMapsOwner.kt:87)
|
||||
... 25 more
|
||||
Suppressed: java.lang.Exception: Could not close incremental caches in D:\2026\BT-CAR-App\hvbt_dash\build\shared_preferences_android\kotlin\compileReleaseKotlin\cacheable\caches-jvm\inputs: source-to-output.tab
|
||||
... 25 more
|
||||
Suppressed: java.lang.IllegalArgumentException: this and base files have different roots: C:\Users\mohan\AppData\Local\Pub\Cache\hosted\pub.dev\shared_preferences_android-2.4.23\android\src\main\kotlin\io\flutter\plugins\sharedpreferences\MessagesAsync.g.kt and D:\2026\BT-CAR-App\hvbt_dash\android.
|
||||
at kotlin.io.FilesKt__UtilsKt.toRelativeString(Utils.kt:117)
|
||||
at kotlin.io.FilesKt__UtilsKt.relativeTo(Utils.kt:128)
|
||||
at org.jetbrains.kotlin.incremental.storage.RelocatableFileToPathConverter.toPath(RelocatableFileToPathConverter.kt:24)
|
||||
at org.jetbrains.kotlin.incremental.storage.FileDescriptor.getHashCode(FileToPathConverter.kt:50)
|
||||
at org.jetbrains.kotlin.incremental.storage.FileDescriptor.getHashCode(FileToPathConverter.kt:30)
|
||||
at org.jetbrains.kotlin.com.intellij.util.containers.LinkedCustomHashMap.hashKey(LinkedCustomHashMap.java:109)
|
||||
at org.jetbrains.kotlin.com.intellij.util.containers.LinkedCustomHashMap.remove(LinkedCustomHashMap.java:153)
|
||||
at org.jetbrains.kotlin.com.intellij.util.containers.SLRUMap.remove(SLRUMap.java:89)
|
||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.flushAppendCache(PersistentMapImpl.java:1007)
|
||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.doPut(PersistentMapImpl.java:455)
|
||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.put(PersistentMapImpl.java:426)
|
||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.put(PersistentHashMap.java:106)
|
||||
at org.jetbrains.kotlin.incremental.storage.LazyStorage.set(LazyStorage.kt:80)
|
||||
at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.applyChanges(InMemoryStorage.kt:108)
|
||||
at org.jetbrains.kotlin.incremental.storage.AppendableInMemoryStorage.applyChanges(InMemoryStorage.kt:179)
|
||||
at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.close(InMemoryStorage.kt:136)
|
||||
at org.jetbrains.kotlin.incremental.storage.AppendableSetBasicMap.close(BasicMap.kt:157)
|
||||
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53)
|
||||
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53)
|
||||
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.forEachMapSafe(BasicMapsOwner.kt:87)
|
||||
... 24 more
|
||||
|
||||
|
||||
kotlin version: 2.2.20
|
||||
error message: Daemon compilation failed: null
|
||||
java.lang.Exception
|
||||
at org.jetbrains.kotlin.daemon.common.CompileService$CallResult$Error.get(CompileService.kt:69)
|
||||
at org.jetbrains.kotlin.daemon.common.CompileService$CallResult$Error.get(CompileService.kt:65)
|
||||
at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.compileWithDaemon(GradleKotlinCompilerWork.kt:240)
|
||||
at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.compileWithDaemonOrFallbackImpl(GradleKotlinCompilerWork.kt:159)
|
||||
at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.run(GradleKotlinCompilerWork.kt:111)
|
||||
at org.jetbrains.kotlin.compilerRunner.GradleCompilerRunnerWithWorkers$GradleKotlinCompilerWorkAction.execute(GradleCompilerRunnerWithWorkers.kt:74)
|
||||
at org.gradle.workers.internal.DefaultWorkerServer.execute(DefaultWorkerServer.java:63)
|
||||
at org.gradle.workers.internal.NoIsolationWorkerFactory$1$1.create(NoIsolationWorkerFactory.java:66)
|
||||
at org.gradle.workers.internal.NoIsolationWorkerFactory$1$1.create(NoIsolationWorkerFactory.java:62)
|
||||
at org.gradle.internal.classloader.ClassLoaderUtils.executeInClassloader(ClassLoaderUtils.java:100)
|
||||
at org.gradle.workers.internal.NoIsolationWorkerFactory$1.lambda$execute$0(NoIsolationWorkerFactory.java:62)
|
||||
at org.gradle.workers.internal.AbstractWorker$1.call(AbstractWorker.java:44)
|
||||
at org.gradle.workers.internal.AbstractWorker$1.call(AbstractWorker.java:41)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:210)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:205)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:67)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:60)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:167)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:60)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner.call(DefaultBuildOperationRunner.java:54)
|
||||
at org.gradle.workers.internal.AbstractWorker.executeWrappedInBuildOperation(AbstractWorker.java:41)
|
||||
at org.gradle.workers.internal.NoIsolationWorkerFactory$1.execute(NoIsolationWorkerFactory.java:59)
|
||||
at org.gradle.workers.internal.DefaultWorkerExecutor.lambda$submitWork$0(DefaultWorkerExecutor.java:174)
|
||||
at java.base/java.util.concurrent.FutureTask.run(Unknown Source)
|
||||
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.runExecution(DefaultConditionalExecutionQueue.java:194)
|
||||
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.access$700(DefaultConditionalExecutionQueue.java:127)
|
||||
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner$1.run(DefaultConditionalExecutionQueue.java:169)
|
||||
at org.gradle.internal.Factories$1.create(Factories.java:31)
|
||||
at org.gradle.internal.work.DefaultWorkerLeaseService.withLocks(DefaultWorkerLeaseService.java:263)
|
||||
at org.gradle.internal.work.DefaultWorkerLeaseService.runAsWorkerThread(DefaultWorkerLeaseService.java:127)
|
||||
at org.gradle.internal.work.DefaultWorkerLeaseService.runAsWorkerThread(DefaultWorkerLeaseService.java:132)
|
||||
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.runBatch(DefaultConditionalExecutionQueue.java:164)
|
||||
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.run(DefaultConditionalExecutionQueue.java:133)
|
||||
at java.base/java.util.concurrent.Executors$RunnableAdapter.call(Unknown Source)
|
||||
at java.base/java.util.concurrent.FutureTask.run(Unknown Source)
|
||||
at org.gradle.internal.concurrent.ExecutorPolicy$CatchAndRecordFailures.onExecute(ExecutorPolicy.java:64)
|
||||
at org.gradle.internal.concurrent.AbstractManagedExecutor$1.run(AbstractManagedExecutor.java:48)
|
||||
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source)
|
||||
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source)
|
||||
at java.base/java.lang.Thread.run(Unknown Source)
|
||||
Caused by: java.lang.AssertionError: java.lang.Exception: Could not close incremental caches in D:\2026\BT-CAR-App\hvbt_dash\build\shared_preferences_android\kotlin\compileReleaseKotlin\cacheable\caches-jvm\jvm\kotlin: class-fq-name-to-source.tab, source-to-classes.tab, internal-name-to-source.tab
|
||||
at org.jetbrains.kotlin.com.google.common.io.Closer.close(Closer.java:218)
|
||||
at org.jetbrains.kotlin.incremental.IncrementalCachesManager.close(IncrementalCachesManager.kt:55)
|
||||
at kotlin.io.CloseableKt.closeFinally(Closeable.kt:46)
|
||||
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compileNonIncrementally(IncrementalCompilerRunner.kt:293)
|
||||
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compile(IncrementalCompilerRunner.kt:128)
|
||||
at org.jetbrains.kotlin.daemon.CompileServiceImplBase.execIncrementalCompiler(CompileServiceImpl.kt:684)
|
||||
at org.jetbrains.kotlin.daemon.CompileServiceImplBase.access$execIncrementalCompiler(CompileServiceImpl.kt:94)
|
||||
at org.jetbrains.kotlin.daemon.CompileServiceImpl.compile(CompileServiceImpl.kt:1810)
|
||||
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(Unknown Source)
|
||||
at java.base/java.lang.reflect.Method.invoke(Unknown Source)
|
||||
at java.rmi/sun.rmi.server.UnicastServerRef.dispatch(Unknown Source)
|
||||
at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source)
|
||||
at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source)
|
||||
at java.base/java.security.AccessController.doPrivileged(Unknown Source)
|
||||
at java.rmi/sun.rmi.transport.Transport.serviceCall(Unknown Source)
|
||||
at java.rmi/sun.rmi.transport.tcp.TCPTransport.handleMessages(Unknown Source)
|
||||
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run0(Unknown Source)
|
||||
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.lambda$run$0(Unknown Source)
|
||||
at java.base/java.security.AccessController.doPrivileged(Unknown Source)
|
||||
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run(Unknown Source)
|
||||
... 3 more
|
||||
Caused by: java.lang.Exception: Could not close incremental caches in D:\2026\BT-CAR-App\hvbt_dash\build\shared_preferences_android\kotlin\compileReleaseKotlin\cacheable\caches-jvm\jvm\kotlin: class-fq-name-to-source.tab, source-to-classes.tab, internal-name-to-source.tab
|
||||
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.forEachMapSafe(BasicMapsOwner.kt:95)
|
||||
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.close(BasicMapsOwner.kt:53)
|
||||
at org.jetbrains.kotlin.com.google.common.io.Closer.close(Closer.java:205)
|
||||
... 22 more
|
||||
Suppressed: java.lang.IllegalArgumentException: this and base files have different roots: C:\Users\mohan\AppData\Local\Pub\Cache\hosted\pub.dev\shared_preferences_android-2.4.23\android\src\main\kotlin\io\flutter\plugins\sharedpreferences\MessagesAsync.g.kt and D:\2026\BT-CAR-App\hvbt_dash\android.
|
||||
at kotlin.io.FilesKt__UtilsKt.toRelativeString(Utils.kt:117)
|
||||
at kotlin.io.FilesKt__UtilsKt.relativeTo(Utils.kt:128)
|
||||
at org.jetbrains.kotlin.incremental.storage.RelocatableFileToPathConverter.toPath(RelocatableFileToPathConverter.kt:24)
|
||||
at org.jetbrains.kotlin.incremental.storage.FileDescriptor.save(FileToPathConverter.kt:33)
|
||||
at org.jetbrains.kotlin.incremental.storage.FileDescriptor.save(FileToPathConverter.kt:30)
|
||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.doPut(PersistentMapImpl.java:447)
|
||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.put(PersistentMapImpl.java:426)
|
||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.put(PersistentHashMap.java:106)
|
||||
at org.jetbrains.kotlin.incremental.storage.LazyStorage.set(LazyStorage.kt:80)
|
||||
at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.applyChanges(InMemoryStorage.kt:108)
|
||||
at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.close(InMemoryStorage.kt:136)
|
||||
at org.jetbrains.kotlin.incremental.storage.PersistentStorageWrapper.close(PersistentStorage.kt:124)
|
||||
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53)
|
||||
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53)
|
||||
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.forEachMapSafe(BasicMapsOwner.kt:87)
|
||||
... 24 more
|
||||
Suppressed: java.lang.IllegalArgumentException: this and base files have different roots: C:\Users\mohan\AppData\Local\Pub\Cache\hosted\pub.dev\shared_preferences_android-2.4.23\android\src\main\kotlin\io\flutter\plugins\sharedpreferences\MessagesAsync.g.kt and D:\2026\BT-CAR-App\hvbt_dash\android.
|
||||
at kotlin.io.FilesKt__UtilsKt.toRelativeString(Utils.kt:117)
|
||||
at kotlin.io.FilesKt__UtilsKt.relativeTo(Utils.kt:128)
|
||||
at org.jetbrains.kotlin.incremental.storage.RelocatableFileToPathConverter.toPath(RelocatableFileToPathConverter.kt:24)
|
||||
at org.jetbrains.kotlin.incremental.storage.FileDescriptor.getHashCode(FileToPathConverter.kt:50)
|
||||
at org.jetbrains.kotlin.incremental.storage.FileDescriptor.getHashCode(FileToPathConverter.kt:30)
|
||||
at org.jetbrains.kotlin.com.intellij.util.containers.LinkedCustomHashMap.hashKey(LinkedCustomHashMap.java:109)
|
||||
at org.jetbrains.kotlin.com.intellij.util.containers.LinkedCustomHashMap.remove(LinkedCustomHashMap.java:153)
|
||||
at org.jetbrains.kotlin.com.intellij.util.containers.SLRUMap.remove(SLRUMap.java:89)
|
||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.flushAppendCache(PersistentMapImpl.java:1007)
|
||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.doPut(PersistentMapImpl.java:455)
|
||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.put(PersistentMapImpl.java:426)
|
||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.put(PersistentHashMap.java:106)
|
||||
at org.jetbrains.kotlin.incremental.storage.LazyStorage.set(LazyStorage.kt:80)
|
||||
at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.applyChanges(InMemoryStorage.kt:108)
|
||||
at org.jetbrains.kotlin.incremental.storage.AppendableInMemoryStorage.applyChanges(InMemoryStorage.kt:179)
|
||||
at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.close(InMemoryStorage.kt:136)
|
||||
at org.jetbrains.kotlin.incremental.storage.AppendableSetBasicMap.close(BasicMap.kt:157)
|
||||
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53)
|
||||
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53)
|
||||
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.forEachMapSafe(BasicMapsOwner.kt:87)
|
||||
... 24 more
|
||||
Suppressed: java.lang.IllegalArgumentException: this and base files have different roots: C:\Users\mohan\AppData\Local\Pub\Cache\hosted\pub.dev\shared_preferences_android-2.4.23\android\src\main\kotlin\io\flutter\plugins\sharedpreferences\MessagesAsync.g.kt and D:\2026\BT-CAR-App\hvbt_dash\android.
|
||||
at kotlin.io.FilesKt__UtilsKt.toRelativeString(Utils.kt:117)
|
||||
at kotlin.io.FilesKt__UtilsKt.relativeTo(Utils.kt:128)
|
||||
at org.jetbrains.kotlin.incremental.storage.RelocatableFileToPathConverter.toPath(RelocatableFileToPathConverter.kt:24)
|
||||
at org.jetbrains.kotlin.incremental.storage.FileDescriptor.save(FileToPathConverter.kt:33)
|
||||
at org.jetbrains.kotlin.incremental.storage.FileDescriptor.save(FileToPathConverter.kt:30)
|
||||
at org.jetbrains.kotlin.incremental.storage.AppendableCollectionExternalizer.save(LazyStorage.kt:151)
|
||||
at org.jetbrains.kotlin.incremental.storage.AppendableCollectionExternalizer.save(LazyStorage.kt:142)
|
||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.doPut(PersistentMapImpl.java:447)
|
||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.put(PersistentMapImpl.java:426)
|
||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.put(PersistentHashMap.java:106)
|
||||
at org.jetbrains.kotlin.incremental.storage.LazyStorage.set(LazyStorage.kt:80)
|
||||
at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.applyChanges(InMemoryStorage.kt:108)
|
||||
at org.jetbrains.kotlin.incremental.storage.AppendableInMemoryStorage.applyChanges(InMemoryStorage.kt:179)
|
||||
at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.close(InMemoryStorage.kt:136)
|
||||
at org.jetbrains.kotlin.incremental.storage.PersistentStorageWrapper.close(PersistentStorage.kt:124)
|
||||
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53)
|
||||
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53)
|
||||
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.forEachMapSafe(BasicMapsOwner.kt:87)
|
||||
... 24 more
|
||||
Suppressed: java.lang.Exception: Could not close incremental caches in D:\2026\BT-CAR-App\hvbt_dash\build\shared_preferences_android\kotlin\compileReleaseKotlin\cacheable\caches-jvm\lookups: id-to-file.tab, file-to-id.tab
|
||||
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.forEachMapSafe(BasicMapsOwner.kt:95)
|
||||
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.close(BasicMapsOwner.kt:53)
|
||||
at org.jetbrains.kotlin.incremental.LookupStorage.close(LookupStorage.kt:155)
|
||||
... 23 more
|
||||
Suppressed: java.lang.IllegalArgumentException: this and base files have different roots: C:\Users\mohan\AppData\Local\Pub\Cache\hosted\pub.dev\shared_preferences_android-2.4.23\android\src\main\kotlin\io\flutter\plugins\sharedpreferences\MessagesAsync.g.kt and D:\2026\BT-CAR-App\hvbt_dash\android.
|
||||
at kotlin.io.FilesKt__UtilsKt.toRelativeString(Utils.kt:117)
|
||||
at kotlin.io.FilesKt__UtilsKt.relativeTo(Utils.kt:128)
|
||||
at org.jetbrains.kotlin.incremental.storage.RelocatableFileToPathConverter.toPath(RelocatableFileToPathConverter.kt:24)
|
||||
at org.jetbrains.kotlin.incremental.storage.LegacyFileExternalizer.save(IdToFileMap.kt:51)
|
||||
at org.jetbrains.kotlin.incremental.storage.LegacyFileExternalizer.save(IdToFileMap.kt:48)
|
||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.doPut(PersistentMapImpl.java:447)
|
||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.put(PersistentMapImpl.java:426)
|
||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.put(PersistentHashMap.java:106)
|
||||
at org.jetbrains.kotlin.incremental.storage.LazyStorage.set(LazyStorage.kt:80)
|
||||
at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.applyChanges(InMemoryStorage.kt:108)
|
||||
at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.close(InMemoryStorage.kt:136)
|
||||
at org.jetbrains.kotlin.incremental.storage.PersistentStorageWrapper.close(PersistentStorage.kt:124)
|
||||
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53)
|
||||
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53)
|
||||
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.forEachMapSafe(BasicMapsOwner.kt:87)
|
||||
... 25 more
|
||||
Suppressed: java.lang.IllegalArgumentException: this and base files have different roots: C:\Users\mohan\AppData\Local\Pub\Cache\hosted\pub.dev\shared_preferences_android-2.4.23\android\src\main\kotlin\io\flutter\plugins\sharedpreferences\MessagesAsync.g.kt and D:\2026\BT-CAR-App\hvbt_dash\android.
|
||||
at kotlin.io.FilesKt__UtilsKt.toRelativeString(Utils.kt:117)
|
||||
at kotlin.io.FilesKt__UtilsKt.relativeTo(Utils.kt:128)
|
||||
at org.jetbrains.kotlin.incremental.storage.RelocatableFileToPathConverter.toPath(RelocatableFileToPathConverter.kt:24)
|
||||
at org.jetbrains.kotlin.incremental.storage.FileDescriptor.getHashCode(FileToPathConverter.kt:50)
|
||||
at org.jetbrains.kotlin.incremental.storage.FileDescriptor.getHashCode(FileToPathConverter.kt:30)
|
||||
at org.jetbrains.kotlin.com.intellij.util.containers.LinkedCustomHashMap.hashKey(LinkedCustomHashMap.java:109)
|
||||
at org.jetbrains.kotlin.com.intellij.util.containers.LinkedCustomHashMap.remove(LinkedCustomHashMap.java:153)
|
||||
at org.jetbrains.kotlin.com.intellij.util.containers.SLRUMap.remove(SLRUMap.java:89)
|
||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.flushAppendCache(PersistentMapImpl.java:1007)
|
||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.doPut(PersistentMapImpl.java:455)
|
||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.put(PersistentMapImpl.java:426)
|
||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.put(PersistentHashMap.java:106)
|
||||
at org.jetbrains.kotlin.incremental.storage.LazyStorage.set(LazyStorage.kt:80)
|
||||
at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.applyChanges(InMemoryStorage.kt:108)
|
||||
at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.close(InMemoryStorage.kt:136)
|
||||
at org.jetbrains.kotlin.incremental.storage.PersistentStorageWrapper.close(PersistentStorage.kt:124)
|
||||
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53)
|
||||
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53)
|
||||
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.forEachMapSafe(BasicMapsOwner.kt:87)
|
||||
... 25 more
|
||||
Suppressed: java.lang.Exception: Could not close incremental caches in D:\2026\BT-CAR-App\hvbt_dash\build\shared_preferences_android\kotlin\compileReleaseKotlin\cacheable\caches-jvm\inputs: source-to-output.tab
|
||||
... 25 more
|
||||
Suppressed: java.lang.IllegalArgumentException: this and base files have different roots: C:\Users\mohan\AppData\Local\Pub\Cache\hosted\pub.dev\shared_preferences_android-2.4.23\android\src\main\kotlin\io\flutter\plugins\sharedpreferences\MessagesAsync.g.kt and D:\2026\BT-CAR-App\hvbt_dash\android.
|
||||
at kotlin.io.FilesKt__UtilsKt.toRelativeString(Utils.kt:117)
|
||||
at kotlin.io.FilesKt__UtilsKt.relativeTo(Utils.kt:128)
|
||||
at org.jetbrains.kotlin.incremental.storage.RelocatableFileToPathConverter.toPath(RelocatableFileToPathConverter.kt:24)
|
||||
at org.jetbrains.kotlin.incremental.storage.FileDescriptor.getHashCode(FileToPathConverter.kt:50)
|
||||
at org.jetbrains.kotlin.incremental.storage.FileDescriptor.getHashCode(FileToPathConverter.kt:30)
|
||||
at org.jetbrains.kotlin.com.intellij.util.containers.LinkedCustomHashMap.hashKey(LinkedCustomHashMap.java:109)
|
||||
at org.jetbrains.kotlin.com.intellij.util.containers.LinkedCustomHashMap.remove(LinkedCustomHashMap.java:153)
|
||||
at org.jetbrains.kotlin.com.intellij.util.containers.SLRUMap.remove(SLRUMap.java:89)
|
||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.flushAppendCache(PersistentMapImpl.java:1007)
|
||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.doPut(PersistentMapImpl.java:455)
|
||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.put(PersistentMapImpl.java:426)
|
||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.put(PersistentHashMap.java:106)
|
||||
at org.jetbrains.kotlin.incremental.storage.LazyStorage.set(LazyStorage.kt:80)
|
||||
at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.applyChanges(InMemoryStorage.kt:108)
|
||||
at org.jetbrains.kotlin.incremental.storage.AppendableInMemoryStorage.applyChanges(InMemoryStorage.kt:179)
|
||||
at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.close(InMemoryStorage.kt:136)
|
||||
at org.jetbrains.kotlin.incremental.storage.AppendableSetBasicMap.close(BasicMap.kt:157)
|
||||
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53)
|
||||
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53)
|
||||
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.forEachMapSafe(BasicMapsOwner.kt:87)
|
||||
... 24 more
|
||||
|
||||
|
||||
|
||||
@ -1,44 +1,44 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("kotlin-android")
|
||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.metatroncube.hvbt_dash"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_17.toString()
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId = "com.metatroncube.hvbt_dash"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
minSdk = 26
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("kotlin-android")
|
||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.metatroncube.hvbt_dash"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_17.toString()
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId = "com.metatroncube.hvbt_dash"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
minSdk = 26
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
|
||||
@ -1,51 +1,51 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.BLUETOOTH"/>
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"/>
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
|
||||
<application
|
||||
android:label="hvbt_dash"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:taskAffinity=""
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||
the Android process has started. This theme is visible to the user
|
||||
while the Flutter UI initializes. After that, this theme continues
|
||||
to determine the Window background behind the Flutter UI. -->
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme"
|
||||
/>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
</application>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
|
||||
|
||||
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
</intent>
|
||||
</queries>
|
||||
</manifest>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.BLUETOOTH"/>
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"/>
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
|
||||
<application
|
||||
android:label="hvbt_dash"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:taskAffinity=""
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||
the Android process has started. This theme is visible to the user
|
||||
while the Flutter UI initializes. After that, this theme continues
|
||||
to determine the Window background behind the Flutter UI. -->
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme"
|
||||
/>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
</application>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
|
||||
|
||||
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
</intent>
|
||||
</queries>
|
||||
</manifest>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
package com.metatroncube.hvbt_dash
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
package com.metatroncube.hvbt_dash
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="?android:colorBackground" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="?android:colorBackground" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@android:color/white" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@android:color/white" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
|
||||
@ -1,18 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
||||
@ -1,18 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
|
||||
@ -1,24 +1,24 @@
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
val newBuildDir: Directory =
|
||||
rootProject.layout.buildDirectory
|
||||
.dir("../../build")
|
||||
.get()
|
||||
rootProject.layout.buildDirectory.value(newBuildDir)
|
||||
|
||||
subprojects {
|
||||
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
|
||||
project.layout.buildDirectory.value(newSubprojectBuildDir)
|
||||
}
|
||||
subprojects {
|
||||
project.evaluationDependsOn(":app")
|
||||
}
|
||||
|
||||
tasks.register<Delete>("clean") {
|
||||
delete(rootProject.layout.buildDirectory)
|
||||
}
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
val newBuildDir: Directory =
|
||||
rootProject.layout.buildDirectory
|
||||
.dir("../../build")
|
||||
.get()
|
||||
rootProject.layout.buildDirectory.value(newBuildDir)
|
||||
|
||||
subprojects {
|
||||
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
|
||||
project.layout.buildDirectory.value(newSubprojectBuildDir)
|
||||
}
|
||||
subprojects {
|
||||
project.evaluationDependsOn(":app")
|
||||
}
|
||||
|
||||
tasks.register<Delete>("clean") {
|
||||
delete(rootProject.layout.buildDirectory)
|
||||
}
|
||||
|
||||
10
android/gradle/wrapper/gradle-wrapper.properties
vendored
10
android/gradle/wrapper/gradle-wrapper.properties
vendored
@ -1,5 +1,5 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
|
||||
|
||||
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 {
|
||||
val flutterSdkPath =
|
||||
run {
|
||||
val properties = java.util.Properties()
|
||||
file("local.properties").inputStream().use { properties.load(it) }
|
||||
val flutterSdkPath = properties.getProperty("flutter.sdk")
|
||||
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
|
||||
flutterSdkPath
|
||||
}
|
||||
|
||||
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "8.11.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
||||
}
|
||||
|
||||
include(":app")
|
||||
pluginManagement {
|
||||
val flutterSdkPath =
|
||||
run {
|
||||
val properties = java.util.Properties()
|
||||
file("local.properties").inputStream().use { properties.load(it) }
|
||||
val flutterSdkPath = properties.getProperty("flutter.sdk")
|
||||
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
|
||||
flutterSdkPath
|
||||
}
|
||||
|
||||
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "8.11.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
||||
}
|
||||
|
||||
include(":app")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
62
docs/HANDOFF.md
Normal file
62
docs/HANDOFF.md
Normal file
@ -0,0 +1,62 @@
|
||||
# HV BT Dashboard — Data Layer Handoff
|
||||
|
||||
Context for continuing work on the S300/KPro Bluetooth data layer. Paste this into a new chat to resume.
|
||||
|
||||
## To be continued tomorrow — debugging in progress, session ended mid-investigation
|
||||
Was actively live-debugging a connection lockup with the phone connected and a `flutter run` session attached when the user had to leave. **Nothing is currently running** (flutter session and Monitor were both stopped cleanly), but the investigation is mid-thread — see "Today's session" below for exactly where it left off, and pick up from "Next steps for tomorrow" at the end.
|
||||
|
||||
The **temporary debug print is still active** in `sensor_provider.dart` (search for `TEMP: live-debugging session, remove after` — two spots, a `dbgFrameCount` counter and a `print(...)` call). It logs `template`/`rpm`/`gear`/`ect`/`fuelPump`/`mil`/`vtec`/`fan`/`sw2` for S300 frames, throttled to ~2/sec. Don't re-add it tomorrow — it's already there. Remove it once this investigation is actually closed out.
|
||||
|
||||
## Reference documents (in project root)
|
||||
- `hvbt_protocol.pdf` — legacy fixed-offset frame protocol (what the app currently uses). Ground truth for byte offsets/formulas, though its own offset labels are internally inconsistent — don't trust them without a live capture.
|
||||
- `Bluetooth.pdf` — Hondata's official, newer, self-describing channel-based protocol (v1.6, 2020). Confirmed via live probe that this specific hardware does NOT support it — legacy protocol is the only option.
|
||||
- `Requrements.pdf` — original customer UI/UX requirements, references a "TunerView" app as the visual benchmark.
|
||||
|
||||
## Today's session: fuel pump held-during-template fix, then a fresh reproduction of the lockup
|
||||
1. **Fixed**: `fuelPump`, `mil`, `vtec`, `fanOut` are now also held from last-known-good during S300 template frames (`sensor_provider.dart`), not just RPM/VSS/MAP/etc and gear/ECT/IAT/BAT from the previous round. This was prompted by the user seeing fuel pump flip to a wrong value after leaving the app idle without changing anything on the bench — consistent with the connection sitting in a run of template frames (where SW2 was still being decoded fresh and showing whatever the frozen pattern happened to contain) during that idle stretch.
|
||||
2. **Reproduced a worse case live**: reconnected and watched a fresh session start with **100% template frames — zero live frames — for the first 103+ consecutive polls**. Since `lastGood` had never been populated yet (no live frame had ever arrived), the hold logic had nothing to fall back on and just displayed raw template garbage (RPM=0, gear=1) directly. A manual disconnect/reconnect fixed it — RPM immediately started tracking real changes again (535 of the next 1159 frames were live).
|
||||
3. **User raised a valid concern**: will this lockup recur after sitting idle again? Proposed (but did not build, pending a decision) an auto-reconnect feature: detect N consecutive template frames with zero live data and trigger an automatic disconnect+reconnect, rather than requiring the user to notice and do it manually.
|
||||
4. **It recurred within the same debugging session**, unprompted, while just sitting idle — caught a long run of `template=true, rpm=0` again in the log. It eventually broke free on its own (one live frame came through), but then the live debug session itself died: `flutter run` reported **"Lost connection to device"** and exited (the background bash task completed with exit code 0). This is the phone's USB/ADB link to the laptop dropping, not necessarily the phone's Bluetooth link to the ECU — those are two different connections and losing one doesn't necessarily mean the other dropped too.
|
||||
5. **Unresolved when the session ended**: whether the app itself (on the phone, independent of the USB debug tether) is still running/connected to the ECU via Bluetooth, or whether it also crashed/disconnected. Debug builds tied to an active `flutter run` session sometimes get killed when that session ends — this needs to be checked first thing tomorrow. `adb devices` showed the phone reachable again by the end of the session, so the USB/ADB link itself recovered at least.
|
||||
|
||||
## Next steps for tomorrow, in order
|
||||
1. **Check whether the app survived** the "Lost connection to device" event — is it still open and showing data on the phone, or did it close? If it closed, that's itself worth understanding (does losing the USB/ADB tether kill the debug-mode app process, or was this a coincidental separate crash?).
|
||||
2. **Decide on the auto-reconnect feature** (see point 3 above) — now that the lockup has reproduced twice in one session, including a from-cold-start 100%-template case that the hold-last-good-value fix can't help with at all, this is probably worth building. Tradeoff to weigh: an automatic reconnect means a brief, visible disconnect/reconnect blip whenever it triggers, and if tuned too aggressively could interrupt a legitimately long-but-real stretch of template frames.
|
||||
3. **Relaunch live debugging** (see Environment notes) to keep characterizing the lockup — specifically it would help to know: does it always eventually self-recover (like it did once today), or does it sometimes stay stuck indefinitely without a manual reconnect? How long does a "stuck" stretch typically last? Does it correlate with anything (idle time specifically, or could be triggered by anything)?
|
||||
4. Once the lockup itself is on firmer ground, return to the still-open data fields below.
|
||||
|
||||
## Confirmed & fixed (shipped, working — from before today, still holding)
|
||||
- **RPM, VSS, MAP, IGN formulas**: match the official doc / live captures. *(IGN's formula itself may be worth a second look — one live check showed a big mismatch — not investigated further, low priority.)*
|
||||
- **TPS** (offset 9, `raw*100/255`), **Gear** (offset 27): confirmed correct against live USB captures.
|
||||
- **ECT** (offset 33), **IAT** (offset 34), **BAT** (offset 36): all moved from earlier wrong offsets and confirmed via live capture matching USB closely.
|
||||
- **Fuel Pump** (SW2 bit 2, active-low: `(sw2 & 0x04) == 0`): confirmed correct when frame is live (non-template).
|
||||
- **S300 "template frame" detection and hold logic**: `isS300TemplateFrame()` in `s300_parser.dart` detects the frozen byte pattern (bytes 0-16, plus bytes 32-36 covering gear/ECT/IAT/BAT). `sensor_provider.dart` now holds RPM/VSS/MAP/TPS/INJ/IGN/knock/O2/gear/ECT/IAT/BAT/fuelPump/mil/vtec/fanOut from last-known-good during these frames. **This only works once at least one live frame has been seen** — a connection stuck in 100% template from the very start has nothing to hold and shows raw garbage (see today's session, point 2).
|
||||
- **Root cause of the template-frame phenomenon itself is still unknown.** Most likely an adapter-side sampling/buffering limitation, possibly related to whatever's now also causing full lockups (100% template, no live frames at all) after idle periods — these might be the same underlying issue at different severities, not two separate problems.
|
||||
- **Bluetooth ECU-type persistence**: connecting no longer shows a "S300 or KPro?" popup every time — it connects directly using whatever's set in Settings (persisted via `SharedPreferences`, survives app restart). `bt_picker_screen.dart` / `settings_provider.dart`.
|
||||
- **Sanity clamp, dashboard scrolling, official protocol probe**: unchanged from before, still working.
|
||||
- **Clear DTC command**: still not getting acknowledged over Bluetooth despite the ECU accepting it via USB — unresolved, lower priority than the connection lockup.
|
||||
|
||||
## Still open — data fields needing live data to resolve (lower priority than the lockup investigation above)
|
||||
- **SW1 during template frames** — never actually confirmed either way for this specific frame type.
|
||||
- **AFR**: confirmed wrong (aliased to ERR03's byte). No correct offset found.
|
||||
- **PA**: candidate at offset 50 is close (~3 kPa off) but not confirmed.
|
||||
- **IGN formula**: flagged as possibly wrong, not investigated.
|
||||
|
||||
## Verification method that has worked every time
|
||||
1. Set a bench value to something specific and known.
|
||||
2. Screenshot the USB tool's reading (fuller flag panel = more cross-checks per shot).
|
||||
3. Attach a live debug session and read raw frame data straight from the console at the same moment — cross-check RPM between the screenshot and the log line to confirm they're actually simultaneous.
|
||||
4. Compute the byte offset/formula that reproduces the known value.
|
||||
5. Get a second data point or a stability check across many consecutive frames before changing code.
|
||||
|
||||
Do not guess offsets from the PDF's offset column labels alone. Always confirm against a live capture, and always check whether the frame in question is a template frame before trusting its bytes beyond 16.
|
||||
|
||||
## Not started yet (deferred until data layer + connection stability are solid)
|
||||
UI/UX gaps from `Requrements.pdf`: dashboard tile count (need 10, have 6), missing "SubSensor" next to main gauge, flag pills should be selectable (spec says 4, currently a fixed 7), datalog playback not wired to live display screens.
|
||||
|
||||
## Environment notes
|
||||
- Test device: Samsung SM_S948W, `adb -s R3GL40CPKMP` (transport ID changes across reconnects, device serial stays the same).
|
||||
- Build/install cycle: `flutter build apk --debug` then `adb -s R3GL40CPKMP install -r build\app\outputs\flutter-apk\app-debug.apk`.
|
||||
- Live debug session (preferred method, used heavily and successfully this whole project): `flutter run -d R3GL40CPKMP --debug` (run in background). Pair with a Monitor that dedupes or does stateful filtering (e.g. only alert on entering/leaving a "stuck" run of N consecutive template frames) rather than raw tail+grep, which gets rate-limited. **Known failure mode**: the session can die with "Lost connection to device" if the phone's USB/ADB link drops — check `adb devices` and relaunch if so.
|
||||
- All parser changes should get a unit test in `test/s300_parser_test.dart` (or `kpro_parser_test.dart`) built from actual captured bytes.
|
||||
- `git status` shows all data-layer, DTC/debug-screen, and BT-connection changes as uncommitted modifications, plus the three reference PDFs and this handoff doc as untracked. Nothing has been committed this project.
|
||||
BIN
hvbt_protocol.pdf
Normal file
BIN
hvbt_protocol.pdf
Normal file
Binary file not shown.
@ -1,160 +1,160 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import '../protocol/neg8.dart';
|
||||
import 'bt_service.dart';
|
||||
|
||||
enum EcuType { s300, kpro }
|
||||
|
||||
/// Sends the ECU request byte every [pollingInterval] ms,
|
||||
/// accumulates raw bytes into 128-byte frames, validates NEG8,
|
||||
/// and emits the newest usable frame on [frameStream].
|
||||
class BtPoller {
|
||||
final BtService _service;
|
||||
EcuType ecuType;
|
||||
final Duration pollingInterval;
|
||||
|
||||
// S300 request: [0x1B, 0x00, 0xE5]
|
||||
static final Uint8List _s300Request = Uint8List.fromList([0x1B, 0x00, 0xE5]);
|
||||
// KPro request: [0x1B, 0x01, 0xE4]
|
||||
static final Uint8List _kproRequest = Uint8List.fromList([0x1B, 0x01, 0xE4]);
|
||||
|
||||
final StreamController<Uint8List> _frameController =
|
||||
StreamController<Uint8List>.broadcast();
|
||||
|
||||
Stream<Uint8List> get frameStream => _frameController.stream;
|
||||
|
||||
final List<int> _rxBuf = [];
|
||||
Timer? _pollTimer;
|
||||
StreamSubscription<Uint8List>? _rxSub;
|
||||
|
||||
int droppedFrames = 0;
|
||||
int validFrames = 0;
|
||||
|
||||
// Tracks whether a request is still awaiting its response, so the poller
|
||||
// never pipelines more than one request ahead of the device. Without this,
|
||||
// a device that answers slower than [pollingInterval] falls permanently
|
||||
// behind — every displayed frame ends up answering a stale, already-queued
|
||||
// request instead of the most recent one, and the dashboard visibly lags
|
||||
// real engine changes by a growing amount.
|
||||
bool _awaitingResponse = false;
|
||||
DateTime? _lastRequestSentAt;
|
||||
|
||||
BtPoller(
|
||||
this._service, {
|
||||
this.ecuType = EcuType.s300,
|
||||
this.pollingInterval = const Duration(milliseconds: 50),
|
||||
});
|
||||
|
||||
void start() {
|
||||
_rxBuf.clear();
|
||||
_awaitingResponse = false;
|
||||
_lastRequestSentAt = null;
|
||||
|
||||
// Listen to raw bytes from BT and accumulate into 128-byte frames
|
||||
_rxSub = _service.rawStream.listen(
|
||||
(Uint8List chunk) {
|
||||
_rxBuf.addAll(chunk);
|
||||
_processBuffer();
|
||||
},
|
||||
onError: (Object e) {
|
||||
_frameController.addError(e);
|
||||
stop();
|
||||
},
|
||||
);
|
||||
|
||||
// Check every pollingInterval whether it's time to send the next
|
||||
// request — but only actually send once the prior one has been
|
||||
// answered (or has clearly been lost).
|
||||
_pollTimer = Timer.periodic(pollingInterval, (_) => _maybeSendRequest());
|
||||
_maybeSendRequest();
|
||||
}
|
||||
|
||||
void _maybeSendRequest() {
|
||||
if (!_service.isConnected) return;
|
||||
|
||||
final stalled = _lastRequestSentAt != null &&
|
||||
DateTime.now().difference(_lastRequestSentAt!) > pollingInterval * 4;
|
||||
if (_awaitingResponse && !stalled) return;
|
||||
|
||||
_awaitingResponse = true;
|
||||
_lastRequestSentAt = DateTime.now();
|
||||
_service
|
||||
.write(ecuType == EcuType.s300 ? _s300Request : _kproRequest)
|
||||
.catchError((Object _) {
|
||||
// A failed write must not leave the poller permanently waiting for a
|
||||
// response that was never actually requested — let the next tick retry.
|
||||
_awaitingResponse = false;
|
||||
});
|
||||
}
|
||||
|
||||
void _processBuffer() {
|
||||
// Extract as many 128-byte frames as possible.
|
||||
// Frame start sync: first byte should be 0x1B (header marker).
|
||||
// If we have a misaligned buffer, scan forward.
|
||||
Uint8List? latestUsableFrame;
|
||||
|
||||
while (_rxBuf.length >= 128) {
|
||||
// Scan for 0x1B frame header
|
||||
int startIdx = 0;
|
||||
while (startIdx < _rxBuf.length && _rxBuf[startIdx] != 0x1B) {
|
||||
startIdx++;
|
||||
}
|
||||
|
||||
// Not enough data after sync byte
|
||||
if (_rxBuf.length - startIdx < 128) {
|
||||
// Discard bytes before potential header
|
||||
if (startIdx > 0) {
|
||||
_rxBuf.removeRange(0, startIdx);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
final Uint8List frame =
|
||||
Uint8List.fromList(_rxBuf.sublist(startIdx, startIdx + 128));
|
||||
|
||||
final checksumOk = validateFrame(frame);
|
||||
|
||||
if (checksumOk) {
|
||||
validFrames++;
|
||||
latestUsableFrame = frame;
|
||||
_rxBuf.removeRange(0, startIdx + 128);
|
||||
} else {
|
||||
// Bad checksum — the window is misaligned (or the frame was
|
||||
// corrupted in transit). Drop one byte and rescan for the next
|
||||
// 0x1B so the stream resynchronizes instead of permanently
|
||||
// sliding a fixed-size window over garbage.
|
||||
droppedFrames++;
|
||||
_rxBuf.removeRange(0, startIdx + 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (latestUsableFrame != null) {
|
||||
_frameController.add(latestUsableFrame);
|
||||
// The device answered — clear the road for the next request. Wait out
|
||||
// the configured pollingInterval before sending it rather than firing
|
||||
// immediately: back-to-back requests with no gap can outrun the ECU's
|
||||
// own tach input-capture timing, which needs a moment to latch a fresh
|
||||
// RPM count between reads — hammering it faster than that makes it
|
||||
// fall back to reporting "no fresh count yet" on every subsequent
|
||||
// request instead of a real reading. pollingInterval is also what the
|
||||
// Settings screen exposes, so this makes that control actually pace
|
||||
// the link instead of just being a stalled-request fallback.
|
||||
_awaitingResponse = false;
|
||||
Timer(pollingInterval, _maybeSendRequest);
|
||||
}
|
||||
}
|
||||
|
||||
void stop() {
|
||||
_pollTimer?.cancel();
|
||||
_pollTimer = null;
|
||||
_rxSub?.cancel();
|
||||
_rxSub = null;
|
||||
_rxBuf.clear();
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
stop();
|
||||
_frameController.close();
|
||||
}
|
||||
}
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import '../protocol/neg8.dart';
|
||||
import 'bt_service.dart';
|
||||
|
||||
enum EcuType { s300, kpro }
|
||||
|
||||
/// Sends the ECU request byte every [pollingInterval] ms,
|
||||
/// accumulates raw bytes into 128-byte frames, validates NEG8,
|
||||
/// and emits the newest usable frame on [frameStream].
|
||||
class BtPoller {
|
||||
final BtService _service;
|
||||
EcuType ecuType;
|
||||
final Duration pollingInterval;
|
||||
|
||||
// S300 request: [0x1B, 0x00, 0xE5]
|
||||
static final Uint8List _s300Request = Uint8List.fromList([0x1B, 0x00, 0xE5]);
|
||||
// KPro request: [0x1B, 0x01, 0xE4]
|
||||
static final Uint8List _kproRequest = Uint8List.fromList([0x1B, 0x01, 0xE4]);
|
||||
|
||||
final StreamController<Uint8List> _frameController =
|
||||
StreamController<Uint8List>.broadcast();
|
||||
|
||||
Stream<Uint8List> get frameStream => _frameController.stream;
|
||||
|
||||
final List<int> _rxBuf = [];
|
||||
Timer? _pollTimer;
|
||||
StreamSubscription<Uint8List>? _rxSub;
|
||||
|
||||
int droppedFrames = 0;
|
||||
int validFrames = 0;
|
||||
|
||||
// Tracks whether a request is still awaiting its response, so the poller
|
||||
// never pipelines more than one request ahead of the device. Without this,
|
||||
// a device that answers slower than [pollingInterval] falls permanently
|
||||
// behind — every displayed frame ends up answering a stale, already-queued
|
||||
// request instead of the most recent one, and the dashboard visibly lags
|
||||
// real engine changes by a growing amount.
|
||||
bool _awaitingResponse = false;
|
||||
DateTime? _lastRequestSentAt;
|
||||
|
||||
BtPoller(
|
||||
this._service, {
|
||||
this.ecuType = EcuType.s300,
|
||||
this.pollingInterval = const Duration(milliseconds: 50),
|
||||
});
|
||||
|
||||
void start() {
|
||||
_rxBuf.clear();
|
||||
_awaitingResponse = false;
|
||||
_lastRequestSentAt = null;
|
||||
|
||||
// Listen to raw bytes from BT and accumulate into 128-byte frames
|
||||
_rxSub = _service.rawStream.listen(
|
||||
(Uint8List chunk) {
|
||||
_rxBuf.addAll(chunk);
|
||||
_processBuffer();
|
||||
},
|
||||
onError: (Object e) {
|
||||
_frameController.addError(e);
|
||||
stop();
|
||||
},
|
||||
);
|
||||
|
||||
// Check every pollingInterval whether it's time to send the next
|
||||
// request — but only actually send once the prior one has been
|
||||
// answered (or has clearly been lost).
|
||||
_pollTimer = Timer.periodic(pollingInterval, (_) => _maybeSendRequest());
|
||||
_maybeSendRequest();
|
||||
}
|
||||
|
||||
void _maybeSendRequest() {
|
||||
if (!_service.isConnected) return;
|
||||
|
||||
final stalled = _lastRequestSentAt != null &&
|
||||
DateTime.now().difference(_lastRequestSentAt!) > pollingInterval * 4;
|
||||
if (_awaitingResponse && !stalled) return;
|
||||
|
||||
_awaitingResponse = true;
|
||||
_lastRequestSentAt = DateTime.now();
|
||||
_service
|
||||
.write(ecuType == EcuType.s300 ? _s300Request : _kproRequest)
|
||||
.catchError((Object _) {
|
||||
// A failed write must not leave the poller permanently waiting for a
|
||||
// response that was never actually requested — let the next tick retry.
|
||||
_awaitingResponse = false;
|
||||
});
|
||||
}
|
||||
|
||||
void _processBuffer() {
|
||||
// Extract as many 128-byte frames as possible.
|
||||
// Frame start sync: first byte should be 0x1B (header marker).
|
||||
// If we have a misaligned buffer, scan forward.
|
||||
Uint8List? latestUsableFrame;
|
||||
|
||||
while (_rxBuf.length >= 128) {
|
||||
// Scan for 0x1B frame header
|
||||
int startIdx = 0;
|
||||
while (startIdx < _rxBuf.length && _rxBuf[startIdx] != 0x1B) {
|
||||
startIdx++;
|
||||
}
|
||||
|
||||
// Not enough data after sync byte
|
||||
if (_rxBuf.length - startIdx < 128) {
|
||||
// Discard bytes before potential header
|
||||
if (startIdx > 0) {
|
||||
_rxBuf.removeRange(0, startIdx);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
final Uint8List frame =
|
||||
Uint8List.fromList(_rxBuf.sublist(startIdx, startIdx + 128));
|
||||
|
||||
final checksumOk = validateFrame(frame);
|
||||
|
||||
if (checksumOk) {
|
||||
validFrames++;
|
||||
latestUsableFrame = frame;
|
||||
_rxBuf.removeRange(0, startIdx + 128);
|
||||
} else {
|
||||
// Bad checksum — the window is misaligned (or the frame was
|
||||
// corrupted in transit). Drop one byte and rescan for the next
|
||||
// 0x1B so the stream resynchronizes instead of permanently
|
||||
// sliding a fixed-size window over garbage.
|
||||
droppedFrames++;
|
||||
_rxBuf.removeRange(0, startIdx + 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (latestUsableFrame != null) {
|
||||
_frameController.add(latestUsableFrame);
|
||||
// The device answered — clear the road for the next request. Wait out
|
||||
// the configured pollingInterval before sending it rather than firing
|
||||
// immediately: back-to-back requests with no gap can outrun the ECU's
|
||||
// own tach input-capture timing, which needs a moment to latch a fresh
|
||||
// RPM count between reads — hammering it faster than that makes it
|
||||
// fall back to reporting "no fresh count yet" on every subsequent
|
||||
// request instead of a real reading. pollingInterval is also what the
|
||||
// Settings screen exposes, so this makes that control actually pace
|
||||
// the link instead of just being a stalled-request fallback.
|
||||
_awaitingResponse = false;
|
||||
Timer(pollingInterval, _maybeSendRequest);
|
||||
}
|
||||
}
|
||||
|
||||
void stop() {
|
||||
_pollTimer?.cancel();
|
||||
_pollTimer = null;
|
||||
_rxSub?.cancel();
|
||||
_rxSub = null;
|
||||
_rxBuf.clear();
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
stop();
|
||||
_frameController.close();
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,56 +1,56 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart';
|
||||
|
||||
/// Wraps flutter_bluetooth_serial for SPP/RFCOMM Classic BT.
|
||||
class BtService {
|
||||
BluetoothConnection? _connection;
|
||||
final StreamController<Uint8List> _rawFrameController =
|
||||
StreamController<Uint8List>.broadcast();
|
||||
|
||||
bool get isConnected => _connection?.isConnected ?? false;
|
||||
|
||||
Stream<Uint8List> get rawStream => _rawFrameController.stream;
|
||||
|
||||
Future<List<BluetoothDevice>> getPairedDevices() async {
|
||||
return FlutterBluetoothSerial.instance.getBondedDevices();
|
||||
}
|
||||
|
||||
Future<void> connect(BluetoothDevice device) async {
|
||||
if (_connection != null) {
|
||||
await disconnect();
|
||||
}
|
||||
_connection = await BluetoothConnection.toAddress(device.address);
|
||||
_connection!.input!.listen(
|
||||
(Uint8List data) {
|
||||
if (!_rawFrameController.isClosed) {
|
||||
_rawFrameController.add(data);
|
||||
}
|
||||
},
|
||||
onDone: () {
|
||||
_rawFrameController.addError('BT connection closed');
|
||||
},
|
||||
onError: (Object e) {
|
||||
_rawFrameController.addError(e);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> write(Uint8List data) async {
|
||||
if (_connection?.isConnected ?? false) {
|
||||
_connection!.output.add(data);
|
||||
await _connection!.output.allSent;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
await _connection?.close();
|
||||
_connection = null;
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
disconnect();
|
||||
_rawFrameController.close();
|
||||
}
|
||||
}
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart';
|
||||
|
||||
/// Wraps flutter_bluetooth_serial for SPP/RFCOMM Classic BT.
|
||||
class BtService {
|
||||
BluetoothConnection? _connection;
|
||||
final StreamController<Uint8List> _rawFrameController =
|
||||
StreamController<Uint8List>.broadcast();
|
||||
|
||||
bool get isConnected => _connection?.isConnected ?? false;
|
||||
|
||||
Stream<Uint8List> get rawStream => _rawFrameController.stream;
|
||||
|
||||
Future<List<BluetoothDevice>> getPairedDevices() async {
|
||||
return FlutterBluetoothSerial.instance.getBondedDevices();
|
||||
}
|
||||
|
||||
Future<void> connect(BluetoothDevice device) async {
|
||||
if (_connection != null) {
|
||||
await disconnect();
|
||||
}
|
||||
_connection = await BluetoothConnection.toAddress(device.address);
|
||||
_connection!.input!.listen(
|
||||
(Uint8List data) {
|
||||
if (!_rawFrameController.isClosed) {
|
||||
_rawFrameController.add(data);
|
||||
}
|
||||
},
|
||||
onDone: () {
|
||||
_rawFrameController.addError('BT connection closed');
|
||||
},
|
||||
onError: (Object e) {
|
||||
_rawFrameController.addError(e);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> write(Uint8List data) async {
|
||||
if (_connection?.isConnected ?? false) {
|
||||
_connection!.output.add(data);
|
||||
await _connection!.output.allSent;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
await _connection?.close();
|
||||
_connection = null;
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
disconnect();
|
||||
_rawFrameController.close();
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,140 +1,140 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
|
||||
import '../models/datalog_session.dart';
|
||||
|
||||
/// SQLite database wrapper for sessions and raw ECU frames.
|
||||
class DatalogDb {
|
||||
static const _dbName = 'hvbt_datalog.db';
|
||||
static const _version = 1;
|
||||
|
||||
Database? _db;
|
||||
|
||||
Future<Database> get _database async {
|
||||
_db ??= await _open();
|
||||
return _db!;
|
||||
}
|
||||
|
||||
Future<Database> _open() async {
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
final path = p.join(dir.path, _dbName);
|
||||
return openDatabase(
|
||||
path,
|
||||
version: _version,
|
||||
onCreate: (db, _) async {
|
||||
await db.execute('''
|
||||
CREATE TABLE sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ecu_type TEXT NOT NULL,
|
||||
start_time INTEGER NOT NULL,
|
||||
end_time INTEGER,
|
||||
frame_count INTEGER NOT NULL DEFAULT 0
|
||||
)
|
||||
''');
|
||||
await db.execute('''
|
||||
CREATE TABLE frames (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id INTEGER NOT NULL,
|
||||
timestamp INTEGER NOT NULL,
|
||||
data BLOB NOT NULL,
|
||||
FOREIGN KEY(session_id) REFERENCES sessions(id) ON DELETE CASCADE
|
||||
)
|
||||
''');
|
||||
await db.execute(
|
||||
'CREATE INDEX idx_frames_session ON frames(session_id)');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ── Sessions ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Inserts a new session row. Returns the assigned id.
|
||||
Future<int> insertSession(DatalogSession session) async {
|
||||
final db = await _database;
|
||||
return db.insert('sessions', session.toMap()..remove('id'));
|
||||
}
|
||||
|
||||
/// Updates end_time and frame_count for an existing session.
|
||||
Future<void> closeSession(int sessionId, DateTime endTime, int frameCount) async {
|
||||
final db = await _database;
|
||||
await db.update(
|
||||
'sessions',
|
||||
{
|
||||
'end_time': endTime.millisecondsSinceEpoch,
|
||||
'frame_count': frameCount,
|
||||
},
|
||||
where: 'id = ?',
|
||||
whereArgs: [sessionId],
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns all sessions ordered newest first.
|
||||
Future<List<DatalogSession>> getSessions() async {
|
||||
final db = await _database;
|
||||
final rows = await db.query('sessions', orderBy: 'start_time DESC');
|
||||
return rows.map(DatalogSession.fromMap).toList();
|
||||
}
|
||||
|
||||
/// Deletes a session and all its frames (CASCADE).
|
||||
Future<void> deleteSession(int sessionId) async {
|
||||
final db = await _database;
|
||||
await db.delete('sessions', where: 'id = ?', whereArgs: [sessionId]);
|
||||
}
|
||||
|
||||
// ── Frames ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Batch-inserts multiple frames in a single transaction.
|
||||
Future<void> insertFrames(
|
||||
int sessionId, List<PendingFrame> frames) async {
|
||||
if (frames.isEmpty) return;
|
||||
final db = await _database;
|
||||
await db.transaction((txn) async {
|
||||
final batch = txn.batch();
|
||||
for (final f in frames) {
|
||||
batch.insert('frames', {
|
||||
'session_id': sessionId,
|
||||
'timestamp': f.timestamp.millisecondsSinceEpoch,
|
||||
'data': f.data,
|
||||
});
|
||||
}
|
||||
await batch.commit(noResult: true);
|
||||
});
|
||||
}
|
||||
|
||||
/// Returns all raw frames for a session, ordered by timestamp.
|
||||
Future<List<StoredFrame>> getFrames(int sessionId) async {
|
||||
final db = await _database;
|
||||
final rows = await db.query(
|
||||
'frames',
|
||||
where: 'session_id = ?',
|
||||
whereArgs: [sessionId],
|
||||
orderBy: 'timestamp ASC',
|
||||
);
|
||||
return rows
|
||||
.map((r) => StoredFrame(
|
||||
timestamp: DateTime.fromMillisecondsSinceEpoch(
|
||||
r['timestamp'] as int),
|
||||
data: r['data'] as Uint8List,
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<void> close() async => _db?.close();
|
||||
}
|
||||
|
||||
// ─── DTOs ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
class PendingFrame {
|
||||
final DateTime timestamp;
|
||||
final Uint8List data;
|
||||
const PendingFrame(this.timestamp, this.data);
|
||||
}
|
||||
|
||||
class StoredFrame {
|
||||
final DateTime timestamp;
|
||||
final Uint8List data;
|
||||
const StoredFrame({required this.timestamp, required this.data});
|
||||
}
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
|
||||
import '../models/datalog_session.dart';
|
||||
|
||||
/// SQLite database wrapper for sessions and raw ECU frames.
|
||||
class DatalogDb {
|
||||
static const _dbName = 'hvbt_datalog.db';
|
||||
static const _version = 1;
|
||||
|
||||
Database? _db;
|
||||
|
||||
Future<Database> get _database async {
|
||||
_db ??= await _open();
|
||||
return _db!;
|
||||
}
|
||||
|
||||
Future<Database> _open() async {
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
final path = p.join(dir.path, _dbName);
|
||||
return openDatabase(
|
||||
path,
|
||||
version: _version,
|
||||
onCreate: (db, _) async {
|
||||
await db.execute('''
|
||||
CREATE TABLE sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ecu_type TEXT NOT NULL,
|
||||
start_time INTEGER NOT NULL,
|
||||
end_time INTEGER,
|
||||
frame_count INTEGER NOT NULL DEFAULT 0
|
||||
)
|
||||
''');
|
||||
await db.execute('''
|
||||
CREATE TABLE frames (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id INTEGER NOT NULL,
|
||||
timestamp INTEGER NOT NULL,
|
||||
data BLOB NOT NULL,
|
||||
FOREIGN KEY(session_id) REFERENCES sessions(id) ON DELETE CASCADE
|
||||
)
|
||||
''');
|
||||
await db.execute(
|
||||
'CREATE INDEX idx_frames_session ON frames(session_id)');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ── Sessions ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Inserts a new session row. Returns the assigned id.
|
||||
Future<int> insertSession(DatalogSession session) async {
|
||||
final db = await _database;
|
||||
return db.insert('sessions', session.toMap()..remove('id'));
|
||||
}
|
||||
|
||||
/// Updates end_time and frame_count for an existing session.
|
||||
Future<void> closeSession(int sessionId, DateTime endTime, int frameCount) async {
|
||||
final db = await _database;
|
||||
await db.update(
|
||||
'sessions',
|
||||
{
|
||||
'end_time': endTime.millisecondsSinceEpoch,
|
||||
'frame_count': frameCount,
|
||||
},
|
||||
where: 'id = ?',
|
||||
whereArgs: [sessionId],
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns all sessions ordered newest first.
|
||||
Future<List<DatalogSession>> getSessions() async {
|
||||
final db = await _database;
|
||||
final rows = await db.query('sessions', orderBy: 'start_time DESC');
|
||||
return rows.map(DatalogSession.fromMap).toList();
|
||||
}
|
||||
|
||||
/// Deletes a session and all its frames (CASCADE).
|
||||
Future<void> deleteSession(int sessionId) async {
|
||||
final db = await _database;
|
||||
await db.delete('sessions', where: 'id = ?', whereArgs: [sessionId]);
|
||||
}
|
||||
|
||||
// ── Frames ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Batch-inserts multiple frames in a single transaction.
|
||||
Future<void> insertFrames(
|
||||
int sessionId, List<PendingFrame> frames) async {
|
||||
if (frames.isEmpty) return;
|
||||
final db = await _database;
|
||||
await db.transaction((txn) async {
|
||||
final batch = txn.batch();
|
||||
for (final f in frames) {
|
||||
batch.insert('frames', {
|
||||
'session_id': sessionId,
|
||||
'timestamp': f.timestamp.millisecondsSinceEpoch,
|
||||
'data': f.data,
|
||||
});
|
||||
}
|
||||
await batch.commit(noResult: true);
|
||||
});
|
||||
}
|
||||
|
||||
/// Returns all raw frames for a session, ordered by timestamp.
|
||||
Future<List<StoredFrame>> getFrames(int sessionId) async {
|
||||
final db = await _database;
|
||||
final rows = await db.query(
|
||||
'frames',
|
||||
where: 'session_id = ?',
|
||||
whereArgs: [sessionId],
|
||||
orderBy: 'timestamp ASC',
|
||||
);
|
||||
return rows
|
||||
.map((r) => StoredFrame(
|
||||
timestamp: DateTime.fromMillisecondsSinceEpoch(
|
||||
r['timestamp'] as int),
|
||||
data: r['data'] as Uint8List,
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<void> close() async => _db?.close();
|
||||
}
|
||||
|
||||
// ─── DTOs ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
class PendingFrame {
|
||||
final DateTime timestamp;
|
||||
final Uint8List data;
|
||||
const PendingFrame(this.timestamp, this.data);
|
||||
}
|
||||
|
||||
class StoredFrame {
|
||||
final DateTime timestamp;
|
||||
final Uint8List data;
|
||||
const StoredFrame({required this.timestamp, required this.data});
|
||||
}
|
||||
|
||||
@ -1,119 +1,119 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'datalog_db.dart';
|
||||
|
||||
enum PlaybackStatus { idle, playing, paused }
|
||||
|
||||
/// Loads stored frames from SQLite and replays them through a stream
|
||||
/// at configurable speed. Consumers watch [frameStream] exactly like
|
||||
/// the live BT stream — the parser layer is unaware of the difference.
|
||||
class DatalogPlayer {
|
||||
final DatalogDb _db;
|
||||
|
||||
List<StoredFrame> _frames = [];
|
||||
int _position = 0; // index into _frames
|
||||
double _speed = 1.0; // 1.0 = real-time, 2.0 = 2× faster
|
||||
|
||||
PlaybackStatus _status = PlaybackStatus.idle;
|
||||
PlaybackStatus get status => _status;
|
||||
int get position => _position;
|
||||
int get totalFrames => _frames.length;
|
||||
double get speed => _speed;
|
||||
set speed(double v) => _speed = v.clamp(0.25, 8.0);
|
||||
|
||||
final StreamController<Uint8List> _controller =
|
||||
StreamController<Uint8List>.broadcast();
|
||||
|
||||
Stream<Uint8List> get frameStream => _controller.stream;
|
||||
|
||||
/// Callback fired on each tick — lets UI update a progress indicator.
|
||||
void Function(int position, int total)? onProgress;
|
||||
|
||||
Timer? _timer;
|
||||
|
||||
DatalogPlayer(this._db);
|
||||
|
||||
/// Load all frames for [sessionId] into memory.
|
||||
Future<void> load(int sessionId) async {
|
||||
await stop();
|
||||
_frames = await _db.getFrames(sessionId);
|
||||
_position = 0;
|
||||
_status = PlaybackStatus.idle;
|
||||
}
|
||||
|
||||
/// Start or resume playback.
|
||||
void play() {
|
||||
if (_status == PlaybackStatus.playing) return;
|
||||
if (_frames.isEmpty) return;
|
||||
_status = PlaybackStatus.playing;
|
||||
_scheduleNext();
|
||||
}
|
||||
|
||||
void pause() {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
if (_status == PlaybackStatus.playing) {
|
||||
_status = PlaybackStatus.paused;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
_position = 0;
|
||||
_status = PlaybackStatus.idle;
|
||||
}
|
||||
|
||||
/// Jump to a specific frame index.
|
||||
void seek(int index) {
|
||||
_position = index.clamp(0, _frames.length - 1);
|
||||
onProgress?.call(_position, _frames.length);
|
||||
}
|
||||
|
||||
void _scheduleNext() {
|
||||
if (_position >= _frames.length) {
|
||||
_status = PlaybackStatus.idle;
|
||||
onProgress?.call(_frames.length, _frames.length);
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate delay to next frame using original timestamps
|
||||
Duration delay = const Duration(milliseconds: 100); // default 10 Hz
|
||||
if (_position + 1 < _frames.length) {
|
||||
final gap = _frames[_position + 1].timestamp
|
||||
.difference(_frames[_position].timestamp);
|
||||
delay = Duration(
|
||||
microseconds: (gap.inMicroseconds / _speed).round(),
|
||||
);
|
||||
// Clamp to 10ms–2s to handle gaps from pauses/reconnects
|
||||
delay = delay.clamp(
|
||||
const Duration(milliseconds: 10),
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
}
|
||||
|
||||
_timer = Timer(delay, () {
|
||||
if (_status != PlaybackStatus.playing) return;
|
||||
if (_position < _frames.length) {
|
||||
_controller.add(_frames[_position].data);
|
||||
onProgress?.call(_position, _frames.length);
|
||||
_position++;
|
||||
}
|
||||
_scheduleNext();
|
||||
});
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
_controller.close();
|
||||
}
|
||||
}
|
||||
|
||||
extension on Duration {
|
||||
Duration clamp(Duration min, Duration max) {
|
||||
if (this < min) return min;
|
||||
if (this > max) return max;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'datalog_db.dart';
|
||||
|
||||
enum PlaybackStatus { idle, playing, paused }
|
||||
|
||||
/// Loads stored frames from SQLite and replays them through a stream
|
||||
/// at configurable speed. Consumers watch [frameStream] exactly like
|
||||
/// the live BT stream — the parser layer is unaware of the difference.
|
||||
class DatalogPlayer {
|
||||
final DatalogDb _db;
|
||||
|
||||
List<StoredFrame> _frames = [];
|
||||
int _position = 0; // index into _frames
|
||||
double _speed = 1.0; // 1.0 = real-time, 2.0 = 2× faster
|
||||
|
||||
PlaybackStatus _status = PlaybackStatus.idle;
|
||||
PlaybackStatus get status => _status;
|
||||
int get position => _position;
|
||||
int get totalFrames => _frames.length;
|
||||
double get speed => _speed;
|
||||
set speed(double v) => _speed = v.clamp(0.25, 8.0);
|
||||
|
||||
final StreamController<Uint8List> _controller =
|
||||
StreamController<Uint8List>.broadcast();
|
||||
|
||||
Stream<Uint8List> get frameStream => _controller.stream;
|
||||
|
||||
/// Callback fired on each tick — lets UI update a progress indicator.
|
||||
void Function(int position, int total)? onProgress;
|
||||
|
||||
Timer? _timer;
|
||||
|
||||
DatalogPlayer(this._db);
|
||||
|
||||
/// Load all frames for [sessionId] into memory.
|
||||
Future<void> load(int sessionId) async {
|
||||
await stop();
|
||||
_frames = await _db.getFrames(sessionId);
|
||||
_position = 0;
|
||||
_status = PlaybackStatus.idle;
|
||||
}
|
||||
|
||||
/// Start or resume playback.
|
||||
void play() {
|
||||
if (_status == PlaybackStatus.playing) return;
|
||||
if (_frames.isEmpty) return;
|
||||
_status = PlaybackStatus.playing;
|
||||
_scheduleNext();
|
||||
}
|
||||
|
||||
void pause() {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
if (_status == PlaybackStatus.playing) {
|
||||
_status = PlaybackStatus.paused;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
_position = 0;
|
||||
_status = PlaybackStatus.idle;
|
||||
}
|
||||
|
||||
/// Jump to a specific frame index.
|
||||
void seek(int index) {
|
||||
_position = index.clamp(0, _frames.length - 1);
|
||||
onProgress?.call(_position, _frames.length);
|
||||
}
|
||||
|
||||
void _scheduleNext() {
|
||||
if (_position >= _frames.length) {
|
||||
_status = PlaybackStatus.idle;
|
||||
onProgress?.call(_frames.length, _frames.length);
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate delay to next frame using original timestamps
|
||||
Duration delay = const Duration(milliseconds: 100); // default 10 Hz
|
||||
if (_position + 1 < _frames.length) {
|
||||
final gap = _frames[_position + 1].timestamp
|
||||
.difference(_frames[_position].timestamp);
|
||||
delay = Duration(
|
||||
microseconds: (gap.inMicroseconds / _speed).round(),
|
||||
);
|
||||
// Clamp to 10ms–2s to handle gaps from pauses/reconnects
|
||||
delay = delay.clamp(
|
||||
const Duration(milliseconds: 10),
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
}
|
||||
|
||||
_timer = Timer(delay, () {
|
||||
if (_status != PlaybackStatus.playing) return;
|
||||
if (_position < _frames.length) {
|
||||
_controller.add(_frames[_position].data);
|
||||
onProgress?.call(_position, _frames.length);
|
||||
_position++;
|
||||
}
|
||||
_scheduleNext();
|
||||
});
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
_controller.close();
|
||||
}
|
||||
}
|
||||
|
||||
extension on Duration {
|
||||
Duration clamp(Duration min, Duration max) {
|
||||
if (this < min) return min;
|
||||
if (this > max) return max;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,94 +1,94 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'datalog_db.dart';
|
||||
import '../models/datalog_session.dart';
|
||||
|
||||
/// Listens to a raw frame stream and batches writes to SQLite every 500ms.
|
||||
class DatalogRecorder {
|
||||
final DatalogDb _db;
|
||||
|
||||
int? _sessionId;
|
||||
String? _ecuType;
|
||||
DateTime? _startTime;
|
||||
int _frameCount = 0;
|
||||
|
||||
final List<PendingFrame> _batch = [];
|
||||
Timer? _flushTimer;
|
||||
StreamSubscription<Uint8List>? _frameSub;
|
||||
|
||||
bool get isRecording => _sessionId != null;
|
||||
int get frameCount => _frameCount;
|
||||
int? get sessionId => _sessionId;
|
||||
|
||||
DatalogRecorder(this._db);
|
||||
|
||||
/// Start a new recording session, listening to [frameStream].
|
||||
Future<void> start(Stream<Uint8List> frameStream, String ecuType) async {
|
||||
if (isRecording) await stop();
|
||||
|
||||
_ecuType = ecuType;
|
||||
_startTime = DateTime.now();
|
||||
_frameCount = 0;
|
||||
_batch.clear();
|
||||
|
||||
final session = DatalogSession(
|
||||
ecuType: ecuType,
|
||||
startTime: _startTime!,
|
||||
);
|
||||
_sessionId = await _db.insertSession(session);
|
||||
|
||||
// Collect frames into batch buffer
|
||||
_frameSub = frameStream.listen((Uint8List frame) {
|
||||
_batch.add(PendingFrame(DateTime.now(), frame));
|
||||
_frameCount++;
|
||||
});
|
||||
|
||||
// Flush to DB every 500ms
|
||||
_flushTimer = Timer.periodic(const Duration(milliseconds: 500), (_) {
|
||||
_flush();
|
||||
});
|
||||
}
|
||||
|
||||
/// Stop recording and finalise the session row.
|
||||
Future<DatalogSession?> stop() async {
|
||||
if (!isRecording) return null;
|
||||
|
||||
_flushTimer?.cancel();
|
||||
_flushTimer = null;
|
||||
await _frameSub?.cancel();
|
||||
_frameSub = null;
|
||||
|
||||
await _flush(); // write any remaining buffered frames
|
||||
|
||||
final endTime = DateTime.now();
|
||||
await _db.closeSession(_sessionId!, endTime, _frameCount);
|
||||
|
||||
final finished = DatalogSession(
|
||||
id: _sessionId,
|
||||
ecuType: _ecuType!,
|
||||
startTime: _startTime!,
|
||||
endTime: endTime,
|
||||
frameCount: _frameCount,
|
||||
);
|
||||
|
||||
_sessionId = null;
|
||||
_ecuType = null;
|
||||
_startTime = null;
|
||||
_frameCount = 0;
|
||||
|
||||
return finished;
|
||||
}
|
||||
|
||||
Future<void> _flush() async {
|
||||
if (_batch.isEmpty || _sessionId == null) return;
|
||||
final toWrite = List<PendingFrame>.from(_batch);
|
||||
_batch.clear();
|
||||
await _db.insertFrames(_sessionId!, toWrite);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_flushTimer?.cancel();
|
||||
_frameSub?.cancel();
|
||||
}
|
||||
}
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'datalog_db.dart';
|
||||
import '../models/datalog_session.dart';
|
||||
|
||||
/// Listens to a raw frame stream and batches writes to SQLite every 500ms.
|
||||
class DatalogRecorder {
|
||||
final DatalogDb _db;
|
||||
|
||||
int? _sessionId;
|
||||
String? _ecuType;
|
||||
DateTime? _startTime;
|
||||
int _frameCount = 0;
|
||||
|
||||
final List<PendingFrame> _batch = [];
|
||||
Timer? _flushTimer;
|
||||
StreamSubscription<Uint8List>? _frameSub;
|
||||
|
||||
bool get isRecording => _sessionId != null;
|
||||
int get frameCount => _frameCount;
|
||||
int? get sessionId => _sessionId;
|
||||
|
||||
DatalogRecorder(this._db);
|
||||
|
||||
/// Start a new recording session, listening to [frameStream].
|
||||
Future<void> start(Stream<Uint8List> frameStream, String ecuType) async {
|
||||
if (isRecording) await stop();
|
||||
|
||||
_ecuType = ecuType;
|
||||
_startTime = DateTime.now();
|
||||
_frameCount = 0;
|
||||
_batch.clear();
|
||||
|
||||
final session = DatalogSession(
|
||||
ecuType: ecuType,
|
||||
startTime: _startTime!,
|
||||
);
|
||||
_sessionId = await _db.insertSession(session);
|
||||
|
||||
// Collect frames into batch buffer
|
||||
_frameSub = frameStream.listen((Uint8List frame) {
|
||||
_batch.add(PendingFrame(DateTime.now(), frame));
|
||||
_frameCount++;
|
||||
});
|
||||
|
||||
// Flush to DB every 500ms
|
||||
_flushTimer = Timer.periodic(const Duration(milliseconds: 500), (_) {
|
||||
_flush();
|
||||
});
|
||||
}
|
||||
|
||||
/// Stop recording and finalise the session row.
|
||||
Future<DatalogSession?> stop() async {
|
||||
if (!isRecording) return null;
|
||||
|
||||
_flushTimer?.cancel();
|
||||
_flushTimer = null;
|
||||
await _frameSub?.cancel();
|
||||
_frameSub = null;
|
||||
|
||||
await _flush(); // write any remaining buffered frames
|
||||
|
||||
final endTime = DateTime.now();
|
||||
await _db.closeSession(_sessionId!, endTime, _frameCount);
|
||||
|
||||
final finished = DatalogSession(
|
||||
id: _sessionId,
|
||||
ecuType: _ecuType!,
|
||||
startTime: _startTime!,
|
||||
endTime: endTime,
|
||||
frameCount: _frameCount,
|
||||
);
|
||||
|
||||
_sessionId = null;
|
||||
_ecuType = null;
|
||||
_startTime = null;
|
||||
_frameCount = 0;
|
||||
|
||||
return finished;
|
||||
}
|
||||
|
||||
Future<void> _flush() async {
|
||||
if (_batch.isEmpty || _sessionId == null) return;
|
||||
final toWrite = List<PendingFrame>.from(_batch);
|
||||
_batch.clear();
|
||||
await _db.insertFrames(_sessionId!, toWrite);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_flushTimer?.cancel();
|
||||
_frameSub?.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,62 +1,62 @@
|
||||
/// Metadata for a single recorded ECU datalog session.
|
||||
class DatalogSession {
|
||||
final int? id;
|
||||
final String ecuType;
|
||||
final DateTime startTime;
|
||||
final DateTime? endTime;
|
||||
final int frameCount;
|
||||
|
||||
const DatalogSession({
|
||||
this.id,
|
||||
required this.ecuType,
|
||||
required this.startTime,
|
||||
this.endTime,
|
||||
this.frameCount = 0,
|
||||
});
|
||||
|
||||
Duration get duration {
|
||||
final end = endTime ?? DateTime.now();
|
||||
return end.difference(startTime);
|
||||
}
|
||||
|
||||
String get durationLabel {
|
||||
final d = duration;
|
||||
final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
|
||||
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
|
||||
return '$m:$s';
|
||||
}
|
||||
|
||||
DatalogSession copyWith({
|
||||
int? id,
|
||||
String? ecuType,
|
||||
DateTime? startTime,
|
||||
DateTime? endTime,
|
||||
int? frameCount,
|
||||
}) =>
|
||||
DatalogSession(
|
||||
id: id ?? this.id,
|
||||
ecuType: ecuType ?? this.ecuType,
|
||||
startTime: startTime ?? this.startTime,
|
||||
endTime: endTime ?? this.endTime,
|
||||
frameCount: frameCount ?? this.frameCount,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toMap() => {
|
||||
'id': id,
|
||||
'ecu_type': ecuType,
|
||||
'start_time': startTime.millisecondsSinceEpoch,
|
||||
'end_time': endTime?.millisecondsSinceEpoch,
|
||||
'frame_count': frameCount,
|
||||
};
|
||||
|
||||
factory DatalogSession.fromMap(Map<String, dynamic> m) => DatalogSession(
|
||||
id: m['id'] as int?,
|
||||
ecuType: m['ecu_type'] as String,
|
||||
startTime:
|
||||
DateTime.fromMillisecondsSinceEpoch(m['start_time'] as int),
|
||||
endTime: m['end_time'] == null
|
||||
? null
|
||||
: DateTime.fromMillisecondsSinceEpoch(m['end_time'] as int),
|
||||
frameCount: m['frame_count'] as int? ?? 0,
|
||||
);
|
||||
}
|
||||
/// Metadata for a single recorded ECU datalog session.
|
||||
class DatalogSession {
|
||||
final int? id;
|
||||
final String ecuType;
|
||||
final DateTime startTime;
|
||||
final DateTime? endTime;
|
||||
final int frameCount;
|
||||
|
||||
const DatalogSession({
|
||||
this.id,
|
||||
required this.ecuType,
|
||||
required this.startTime,
|
||||
this.endTime,
|
||||
this.frameCount = 0,
|
||||
});
|
||||
|
||||
Duration get duration {
|
||||
final end = endTime ?? DateTime.now();
|
||||
return end.difference(startTime);
|
||||
}
|
||||
|
||||
String get durationLabel {
|
||||
final d = duration;
|
||||
final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
|
||||
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
|
||||
return '$m:$s';
|
||||
}
|
||||
|
||||
DatalogSession copyWith({
|
||||
int? id,
|
||||
String? ecuType,
|
||||
DateTime? startTime,
|
||||
DateTime? endTime,
|
||||
int? frameCount,
|
||||
}) =>
|
||||
DatalogSession(
|
||||
id: id ?? this.id,
|
||||
ecuType: ecuType ?? this.ecuType,
|
||||
startTime: startTime ?? this.startTime,
|
||||
endTime: endTime ?? this.endTime,
|
||||
frameCount: frameCount ?? this.frameCount,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toMap() => {
|
||||
'id': id,
|
||||
'ecu_type': ecuType,
|
||||
'start_time': startTime.millisecondsSinceEpoch,
|
||||
'end_time': endTime?.millisecondsSinceEpoch,
|
||||
'frame_count': frameCount,
|
||||
};
|
||||
|
||||
factory DatalogSession.fromMap(Map<String, dynamic> m) => DatalogSession(
|
||||
id: m['id'] as int?,
|
||||
ecuType: m['ecu_type'] as String,
|
||||
startTime:
|
||||
DateTime.fromMillisecondsSinceEpoch(m['start_time'] as int),
|
||||
endTime: m['end_time'] == null
|
||||
? null
|
||||
: DateTime.fromMillisecondsSinceEpoch(m['end_time'] as int),
|
||||
frameCount: m['frame_count'] as int? ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
/// Definition of a boolean flag sensor shown as indicator pills.
|
||||
class FlagDef {
|
||||
final String id;
|
||||
final String displayName;
|
||||
|
||||
const FlagDef({required this.id, required this.displayName});
|
||||
}
|
||||
/// Definition of a boolean flag sensor shown as indicator pills.
|
||||
class FlagDef {
|
||||
final String id;
|
||||
final String displayName;
|
||||
|
||||
const FlagDef({required this.id, required this.displayName});
|
||||
}
|
||||
|
||||
@ -1,16 +1,16 @@
|
||||
/// Definition of a single sensor — used for bar gauges, pickers, and graph panels.
|
||||
class SensorDef {
|
||||
final String id;
|
||||
final String displayName;
|
||||
final String unit;
|
||||
final double min;
|
||||
final double max;
|
||||
|
||||
const SensorDef({
|
||||
required this.id,
|
||||
required this.displayName,
|
||||
required this.unit,
|
||||
required this.min,
|
||||
required this.max,
|
||||
});
|
||||
}
|
||||
/// Definition of a single sensor — used for bar gauges, pickers, and graph panels.
|
||||
class SensorDef {
|
||||
final String id;
|
||||
final String displayName;
|
||||
final String unit;
|
||||
final double min;
|
||||
final double max;
|
||||
|
||||
const SensorDef({
|
||||
required this.id,
|
||||
required this.displayName,
|
||||
required this.unit,
|
||||
required this.min,
|
||||
required this.max,
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,67 +1,67 @@
|
||||
import 'sensor_def.dart';
|
||||
import 'flag_def.dart';
|
||||
|
||||
/// Master list of all analog sensor definitions.
|
||||
const List<SensorDef> sensorDefs = [
|
||||
SensorDef(id: 'rpm', displayName: 'RPM', unit: 'revs', min: 0, max: 9000),
|
||||
SensorDef(id: 'vss', displayName: 'Speed', unit: 'km/h', min: 0, max: 280),
|
||||
SensorDef(
|
||||
id: 'map',
|
||||
displayName: 'Manifold pressure',
|
||||
unit: 'kPa',
|
||||
min: 0,
|
||||
max: 300),
|
||||
SensorDef(
|
||||
id: 'tps', displayName: 'Throttle pedal', unit: '%', min: 0, max: 100),
|
||||
SensorDef(
|
||||
id: 'inj', displayName: 'Injector duration', unit: 'ms', min: 0, max: 20),
|
||||
SensorDef(
|
||||
id: 'ign', displayName: 'Timing advance', unit: 'deg', min: -20, max: 60),
|
||||
SensorDef(
|
||||
id: 'ect', displayName: 'Coolant temp', unit: '°C', min: -40, max: 150),
|
||||
SensorDef(
|
||||
id: 'iat',
|
||||
displayName: 'Intake air temp',
|
||||
unit: '°C',
|
||||
min: -40,
|
||||
max: 150),
|
||||
SensorDef(
|
||||
id: 'bat', displayName: 'Battery voltage', unit: 'V', min: 0, max: 20),
|
||||
SensorDef(id: 'o2', displayName: 'O2 sensor', unit: 'V', min: 0, max: 1.5),
|
||||
SensorDef(id: 'gear', displayName: 'Gear', unit: '—', min: 1, max: 6),
|
||||
SensorDef(id: 'eth', displayName: 'Ethanol', unit: '%', min: 0, max: 100),
|
||||
SensorDef(
|
||||
id: 'pa', displayName: 'Baro pressure', unit: 'kPa', min: 0, max: 120),
|
||||
SensorDef(id: 'afr', displayName: 'AFR', unit: 'λ', min: 0.5, max: 2.0),
|
||||
SensorDef(
|
||||
id: 'strim', displayName: 'Short trim', unit: '%', min: -30, max: 30),
|
||||
SensorDef(
|
||||
id: 'ltrim', displayName: 'Long trim', unit: '%', min: -30, max: 30),
|
||||
SensorDef(
|
||||
id: 'ain0', displayName: 'Analog input 0', unit: 'V', min: 0, max: 5),
|
||||
SensorDef(
|
||||
id: 'ain1', displayName: 'Analog input 1', unit: 'V', min: 0, max: 5),
|
||||
SensorDef(
|
||||
id: 'ain2', displayName: 'Analog input 2', unit: 'V', min: 0, max: 5),
|
||||
SensorDef(
|
||||
id: 'ain3', displayName: 'Analog input 3', unit: 'V', min: 0, max: 5),
|
||||
SensorDef(
|
||||
id: 'ain4', displayName: 'Analog input 4', unit: 'V', min: 0, max: 5),
|
||||
SensorDef(
|
||||
id: 'ain5', displayName: 'Analog input 5', unit: 'V', min: 0, max: 5),
|
||||
SensorDef(
|
||||
id: 'ain6', displayName: 'Analog input 6', unit: 'V', min: 0, max: 5),
|
||||
SensorDef(
|
||||
id: 'ain7', displayName: 'Analog input 7', unit: 'V', min: 0, max: 5),
|
||||
];
|
||||
|
||||
/// Master list of boolean flag sensors.
|
||||
const List<FlagDef> flagDefs = [
|
||||
FlagDef(id: 'mil', displayName: 'MIL'),
|
||||
FlagDef(id: 'fuelcut', displayName: 'Fuel Cut'),
|
||||
FlagDef(id: 'fanout', displayName: 'FAN Out'),
|
||||
FlagDef(id: 'vtec', displayName: 'VTEC'),
|
||||
FlagDef(id: 'knock', displayName: 'Knock'),
|
||||
FlagDef(id: 'revlimit', displayName: 'Rev Limit'),
|
||||
FlagDef(id: 'launch', displayName: 'Launch'),
|
||||
];
|
||||
import 'sensor_def.dart';
|
||||
import 'flag_def.dart';
|
||||
|
||||
/// Master list of all analog sensor definitions.
|
||||
const List<SensorDef> sensorDefs = [
|
||||
SensorDef(id: 'rpm', displayName: 'RPM', unit: 'revs', min: 0, max: 9000),
|
||||
SensorDef(id: 'vss', displayName: 'Speed', unit: 'km/h', min: 0, max: 280),
|
||||
SensorDef(
|
||||
id: 'map',
|
||||
displayName: 'Manifold pressure',
|
||||
unit: 'kPa',
|
||||
min: 0,
|
||||
max: 300),
|
||||
SensorDef(
|
||||
id: 'tps', displayName: 'Throttle pedal', unit: '%', min: 0, max: 100),
|
||||
SensorDef(
|
||||
id: 'inj', displayName: 'Injector duration', unit: 'ms', min: 0, max: 20),
|
||||
SensorDef(
|
||||
id: 'ign', displayName: 'Timing advance', unit: 'deg', min: -20, max: 60),
|
||||
SensorDef(
|
||||
id: 'ect', displayName: 'Coolant temp', unit: '°C', min: -40, max: 150),
|
||||
SensorDef(
|
||||
id: 'iat',
|
||||
displayName: 'Intake air temp',
|
||||
unit: '°C',
|
||||
min: -40,
|
||||
max: 150),
|
||||
SensorDef(
|
||||
id: 'bat', displayName: 'Battery voltage', unit: 'V', min: 0, max: 20),
|
||||
SensorDef(id: 'o2', displayName: 'O2 sensor', unit: 'V', min: 0, max: 1.5),
|
||||
SensorDef(id: 'gear', displayName: 'Gear', unit: '—', min: 1, max: 6),
|
||||
SensorDef(id: 'eth', displayName: 'Ethanol', unit: '%', min: 0, max: 100),
|
||||
SensorDef(
|
||||
id: 'pa', displayName: 'Baro pressure', unit: 'kPa', min: 0, max: 120),
|
||||
SensorDef(id: 'afr', displayName: 'AFR', unit: 'λ', min: 0.5, max: 2.0),
|
||||
SensorDef(
|
||||
id: 'strim', displayName: 'Short trim', unit: '%', min: -30, max: 30),
|
||||
SensorDef(
|
||||
id: 'ltrim', displayName: 'Long trim', unit: '%', min: -30, max: 30),
|
||||
SensorDef(
|
||||
id: 'ain0', displayName: 'Analog input 0', unit: 'V', min: 0, max: 5),
|
||||
SensorDef(
|
||||
id: 'ain1', displayName: 'Analog input 1', unit: 'V', min: 0, max: 5),
|
||||
SensorDef(
|
||||
id: 'ain2', displayName: 'Analog input 2', unit: 'V', min: 0, max: 5),
|
||||
SensorDef(
|
||||
id: 'ain3', displayName: 'Analog input 3', unit: 'V', min: 0, max: 5),
|
||||
SensorDef(
|
||||
id: 'ain4', displayName: 'Analog input 4', unit: 'V', min: 0, max: 5),
|
||||
SensorDef(
|
||||
id: 'ain5', displayName: 'Analog input 5', unit: 'V', min: 0, max: 5),
|
||||
SensorDef(
|
||||
id: 'ain6', displayName: 'Analog input 6', unit: 'V', min: 0, max: 5),
|
||||
SensorDef(
|
||||
id: 'ain7', displayName: 'Analog input 7', unit: 'V', min: 0, max: 5),
|
||||
];
|
||||
|
||||
/// Master list of boolean flag sensors.
|
||||
const List<FlagDef> flagDefs = [
|
||||
FlagDef(id: 'mil', displayName: 'MIL'),
|
||||
FlagDef(id: 'fuelpump', displayName: 'Fuel Pump'),
|
||||
FlagDef(id: 'fanout', displayName: 'FAN Out'),
|
||||
FlagDef(id: 'vtec', displayName: 'VTEC'),
|
||||
FlagDef(id: 'knock', displayName: 'Knock'),
|
||||
FlagDef(id: 'revlimit', displayName: 'Rev Limit'),
|
||||
FlagDef(id: 'launch', displayName: 'Launch'),
|
||||
];
|
||||
|
||||
@ -1,249 +1,249 @@
|
||||
/// DTC maps for S300 and KPro ECU protocols.
|
||||
///
|
||||
/// Key format: 'ERRxx_bity' where xx = byte index (00-based), y = bit number (0=LSB).
|
||||
/// Value: 'Pxxxx — description'
|
||||
library;
|
||||
|
||||
/// S300 DTC map (ERR00–ERR03, 4 bytes × 8 bits = 32 possible codes)
|
||||
const Map<String, String> s300DtcMap = {
|
||||
// ERR00 (byte 0x31)
|
||||
'ERR00_bit0': 'P0130 — O2 Sensor (front)',
|
||||
'ERR00_bit1': 'P0135 — O2 Sensor Heater (front)',
|
||||
'ERR00_bit2': 'P0505 — Idle Air Control Valve',
|
||||
'ERR00_bit3': 'P0108 — MAP Sensor High',
|
||||
'ERR00_bit4': 'P0107 — MAP Sensor Low',
|
||||
'ERR00_bit5': 'P0113 — IAT Sensor High',
|
||||
'ERR00_bit6': 'P0112 — IAT Sensor Low',
|
||||
'ERR00_bit7': 'P0118 — ECT Sensor High',
|
||||
|
||||
// ERR01 (byte 0x32)
|
||||
'ERR01_bit0': 'P0117 — ECT Sensor Low',
|
||||
'ERR01_bit1': 'P0336 — CKP Sensor',
|
||||
'ERR01_bit2': 'P0341 — CMP Sensor',
|
||||
'ERR01_bit3': 'P1362 — TDC Sensor (No Signal)',
|
||||
'ERR01_bit4': 'P1363 — TDC Sensor (No Signal 2)',
|
||||
'ERR01_bit5': 'P0335 — CKP Sensor (No Signal)',
|
||||
'ERR01_bit6': 'P1361 — TDC Sensor',
|
||||
'ERR01_bit7': 'P0300 — Random Misfire',
|
||||
|
||||
// ERR02 (byte 0x33)
|
||||
'ERR02_bit0': 'P0301 — Misfire Cylinder 1',
|
||||
'ERR02_bit1': 'P0302 — Misfire Cylinder 2',
|
||||
'ERR02_bit2': 'P0303 — Misfire Cylinder 3',
|
||||
'ERR02_bit3': 'P0304 — Misfire Cylinder 4',
|
||||
'ERR02_bit4': 'P1259 — VTEC System Malfunction',
|
||||
'ERR02_bit5': 'P0420 — Catalyst System Low Efficiency',
|
||||
'ERR02_bit6': 'P0401 — EGR Insufficient Flow',
|
||||
'ERR02_bit7': 'P1456 — EVAP Leak (large) Fuel Tank Side',
|
||||
|
||||
// ERR03 (byte 0x34)
|
||||
'ERR03_bit0': 'P1457 — EVAP Leak (large) Canister Side',
|
||||
'ERR03_bit1': 'P0498 — EVAP Canister Vent Shut Valve',
|
||||
'ERR03_bit2': 'P0497 — EVAP Low Purge Flow',
|
||||
'ERR03_bit3': 'P0122 — TPS Sensor Low',
|
||||
'ERR03_bit4': 'P0123 — TPS Sensor High',
|
||||
'ERR03_bit5': 'P0562 — Battery Voltage Low',
|
||||
'ERR03_bit6': 'P0563 — Battery Voltage High',
|
||||
'ERR03_bit7': 'P1607 — PCM Internal Circuit',
|
||||
};
|
||||
|
||||
/// KPro DTC map (ERR00–ERR17, 18 bytes × 8 bits = 144 possible codes)
|
||||
const Map<String, String> kproDtcMap = {
|
||||
// ERR00
|
||||
'ERR00_bit0': 'P0130 — O2 Sensor (front)',
|
||||
'ERR00_bit1': 'P0135 — O2 Sensor Heater (front)',
|
||||
'ERR00_bit2': 'P0505 — Idle Air Control Valve',
|
||||
'ERR00_bit3': 'P0108 — MAP Sensor High',
|
||||
'ERR00_bit4': 'P0107 — MAP Sensor Low',
|
||||
'ERR00_bit5': 'P0113 — IAT Sensor High',
|
||||
'ERR00_bit6': 'P0112 — IAT Sensor Low',
|
||||
'ERR00_bit7': 'P0118 — ECT Sensor High',
|
||||
|
||||
// ERR01
|
||||
'ERR01_bit0': 'P0117 — ECT Sensor Low',
|
||||
'ERR01_bit1': 'P0336 — CKP Sensor',
|
||||
'ERR01_bit2': 'P0341 — CMP Sensor',
|
||||
'ERR01_bit3': 'P1362 — TDC Sensor (No Signal)',
|
||||
'ERR01_bit4': 'P1363 — TDC Sensor (No Signal 2)',
|
||||
'ERR01_bit5': 'P0335 — CKP Sensor (No Signal)',
|
||||
'ERR01_bit6': 'P1361 — TDC Sensor',
|
||||
'ERR01_bit7': 'P0300 — Random Misfire',
|
||||
|
||||
// ERR02
|
||||
'ERR02_bit0': 'P0301 — Misfire Cylinder 1',
|
||||
'ERR02_bit1': 'P0302 — Misfire Cylinder 2',
|
||||
'ERR02_bit2': 'P0303 — Misfire Cylinder 3',
|
||||
'ERR02_bit3': 'P0304 — Misfire Cylinder 4',
|
||||
'ERR02_bit4': 'P1259 — VTEC System Malfunction',
|
||||
'ERR02_bit5': 'P0420 — Catalyst System Low Efficiency',
|
||||
'ERR02_bit6': 'P0401 — EGR Insufficient Flow',
|
||||
'ERR02_bit7': 'P1456 — EVAP Leak (large) Fuel Tank Side',
|
||||
|
||||
// ERR03
|
||||
'ERR03_bit0': 'P1457 — EVAP Leak (large) Canister Side',
|
||||
'ERR03_bit1': 'P0498 — EVAP Canister Vent Shut Valve',
|
||||
'ERR03_bit2': 'P0497 — EVAP Low Purge Flow',
|
||||
'ERR03_bit3': 'P0122 — TPS Sensor Low',
|
||||
'ERR03_bit4': 'P0123 — TPS Sensor High',
|
||||
'ERR03_bit5': 'P0562 — Battery Voltage Low',
|
||||
'ERR03_bit6': 'P0563 — Battery Voltage High',
|
||||
'ERR03_bit7': 'P1607 — PCM Internal Circuit',
|
||||
|
||||
// ERR04
|
||||
'ERR04_bit0': 'P0136 — O2 Sensor (rear)',
|
||||
'ERR04_bit1': 'P0141 — O2 Sensor Heater (rear)',
|
||||
'ERR04_bit2': 'P1166 — Air Fuel Ratio (A/F) Sensor Heater',
|
||||
'ERR04_bit3': 'P1167 — Air Fuel Ratio (A/F) Sensor',
|
||||
'ERR04_bit4': 'P2195 — A/F Sensor Signal Stuck Lean',
|
||||
'ERR04_bit5': 'P2196 — A/F Sensor Signal Stuck Rich',
|
||||
'ERR04_bit6': 'P2237 — A/F Sensor Pump Cell Current Input Low',
|
||||
'ERR04_bit7': 'P2240 — A/F Sensor Pump Cell Current Input High',
|
||||
|
||||
// ERR05
|
||||
'ERR05_bit0': 'P2243 — A/F Sensor Reference Voltage Low',
|
||||
'ERR05_bit1': 'P2247 — A/F Sensor Reference Voltage High',
|
||||
'ERR05_bit2': 'P2251 — A/F Sensor VS Cell Voltage Low',
|
||||
'ERR05_bit3': 'P2254 — A/F Sensor VS Cell Voltage High',
|
||||
'ERR05_bit4': 'P0031 — O2 Sensor Heater Current Low (bank 1, sensor 1)',
|
||||
'ERR05_bit5': 'P0032 — O2 Sensor Heater Current High (bank 1, sensor 1)',
|
||||
'ERR05_bit6': 'P0037 — O2 Sensor Heater Current Low (bank 1, sensor 2)',
|
||||
'ERR05_bit7': 'P0038 — O2 Sensor Heater Current High (bank 1, sensor 2)',
|
||||
|
||||
// ERR06
|
||||
'ERR06_bit0': 'P0068 — MAP/MAF vs Throttle Position Correlation',
|
||||
'ERR06_bit1': 'P0101 — MAF Sensor Range/Performance',
|
||||
'ERR06_bit2': 'P0102 — MAF Sensor Low Input',
|
||||
'ERR06_bit3': 'P0103 — MAF Sensor High Input',
|
||||
'ERR06_bit4': 'P0171 — System Lean (bank 1)',
|
||||
'ERR06_bit5': 'P0172 — System Rich (bank 1)',
|
||||
'ERR06_bit6': 'P0174 — System Lean (bank 2)',
|
||||
'ERR06_bit7': 'P0175 — System Rich (bank 2)',
|
||||
|
||||
// ERR07
|
||||
'ERR07_bit0': 'P0325 — Knock Sensor',
|
||||
'ERR07_bit1': 'P0330 — Knock Sensor 2',
|
||||
'ERR07_bit2': 'P0339 — CKP Sensor Intermittent',
|
||||
'ERR07_bit3': 'P0344 — CMP Sensor Intermittent',
|
||||
'ERR07_bit4': 'P0365 — CMP Sensor B Circuit',
|
||||
'ERR07_bit5': 'P0390 — CMP Sensor B Circuit (bank 2)',
|
||||
'ERR07_bit6': 'P0498 — EVAP Canister Vent Shut Valve Low',
|
||||
'ERR07_bit7': 'P0499 — EVAP Canister Vent Shut Valve High',
|
||||
|
||||
// ERR08
|
||||
'ERR08_bit0': 'P0600 — Serial Communication Link',
|
||||
'ERR08_bit1': 'P0601 — Internal Control Module Memory',
|
||||
'ERR08_bit2': 'P0604 — Internal Control Module RAM',
|
||||
'ERR08_bit3': 'P0605 — Internal Control Module ROM',
|
||||
'ERR08_bit4': 'P0657 — Actuator Supply Voltage A Open',
|
||||
'ERR08_bit5': 'P0685 — ECM/PCM Power Relay Control Open',
|
||||
'ERR08_bit6': 'P0691 — Fan 1 Control Circuit Low',
|
||||
'ERR08_bit7': 'P0692 — Fan 1 Control Circuit High',
|
||||
|
||||
// ERR09
|
||||
'ERR09_bit0': 'P0693 — Fan 2 Control Circuit Low',
|
||||
'ERR09_bit1': 'P0694 — Fan 2 Control Circuit High',
|
||||
'ERR09_bit2': 'P1454 — EVAP Pressure Sensor Range/Performance',
|
||||
'ERR09_bit3': 'P1455 — EVAP Leak (small)',
|
||||
'ERR09_bit4': 'P1508 — IAC Valve Circuit Low',
|
||||
'ERR09_bit5': 'P1509 — IAC Valve Circuit High',
|
||||
'ERR09_bit6': 'P1519 — Idle Air Control Valve Stuck',
|
||||
'ERR09_bit7': 'P1706 — A/T Range Switch Out Of Range',
|
||||
|
||||
// ERR10
|
||||
'ERR10_bit0': 'P2101 — Throttle Actuator Control Motor Range',
|
||||
'ERR10_bit1': 'P2118 — Throttle Actuator Control Motor Current Range',
|
||||
'ERR10_bit2': 'P2119 — Throttle Actuator Control Throttle Body Range',
|
||||
'ERR10_bit3': 'P2122 — Throttle/Pedal Position Sensor D Low Input',
|
||||
'ERR10_bit4': 'P2123 — Throttle/Pedal Position Sensor D High Input',
|
||||
'ERR10_bit5': 'P2127 — Throttle/Pedal Position Sensor E Low Input',
|
||||
'ERR10_bit6': 'P2128 — Throttle/Pedal Position Sensor E High Input',
|
||||
'ERR10_bit7': 'P2135 — Throttle/Pedal Position Sensor A/B Voltage Correlation',
|
||||
|
||||
// ERR11
|
||||
'ERR11_bit0': 'P2138 — Throttle/Pedal Position Sensor D/E Voltage Correlation',
|
||||
'ERR11_bit1': 'P2176 — Throttle Actuator Control System Idle Position Not Learned',
|
||||
'ERR11_bit2': 'P2227 — Barometric Pressure Sensor A Circuit Range',
|
||||
'ERR11_bit3': 'P2228 — Barometric Pressure Sensor A Circuit Low',
|
||||
'ERR11_bit4': 'P2229 — Barometric Pressure Sensor A Circuit High',
|
||||
'ERR11_bit5': 'P2610 — ECM/PCM Internal Engine Off Timer',
|
||||
'ERR11_bit6': 'P2647 — VTEC System High (bank 1)',
|
||||
'ERR11_bit7': 'P2648 — VTEC System Low (bank 1)',
|
||||
|
||||
// ERR12
|
||||
'ERR12_bit0': 'P2649 — VTEC System High (bank 2)',
|
||||
'ERR12_bit1': 'P2650 — VTEC System Low (bank 2)',
|
||||
'ERR12_bit2': 'P3400 — VTEC System Stuck Off (bank 1)',
|
||||
'ERR12_bit3': 'P3497 — VTEC System Stuck Off (bank 2)',
|
||||
'ERR12_bit4': 'U0073 — Control Module Communication Bus Off',
|
||||
'ERR12_bit5': 'U0100 — Lost Communication With ECM/PCM A',
|
||||
'ERR12_bit6': 'U0155 — Lost Communication With Instrument Panel Cluster',
|
||||
'ERR12_bit7': 'U0164 — Lost Communication With HVAC Control Module',
|
||||
|
||||
// ERR13
|
||||
'ERR13_bit0': 'P0011 — Camshaft Position A — Timing Over-Advanced (bank 1)',
|
||||
'ERR13_bit1': 'P0012 — Camshaft Position A — Timing Over-Retarded (bank 1)',
|
||||
'ERR13_bit2': 'P0021 — Camshaft Position B — Timing Over-Advanced (bank 1)',
|
||||
'ERR13_bit3': 'P0022 — Camshaft Position B — Timing Over-Retarded (bank 1)',
|
||||
'ERR13_bit4': 'P0340 — CMP Sensor A Circuit (bank 1)',
|
||||
'ERR13_bit5': 'P0345 — CMP Sensor A Circuit (bank 2)',
|
||||
'ERR13_bit6': 'P0366 — CMP Sensor B Circuit Range/Performance (bank 1)',
|
||||
'ERR13_bit7': 'P0391 — CMP Sensor B Circuit Range/Performance (bank 2)',
|
||||
|
||||
// ERR14
|
||||
'ERR14_bit0': 'P0016 — Crankshaft/Camshaft Position Correlation (bank 1, sensor A)',
|
||||
'ERR14_bit1': 'P0017 — Crankshaft/Camshaft Position Correlation (bank 1, sensor B)',
|
||||
'ERR14_bit2': 'P0018 — Crankshaft/Camshaft Position Correlation (bank 2, sensor A)',
|
||||
'ERR14_bit3': 'P0019 — Crankshaft/Camshaft Position Correlation (bank 2, sensor B)',
|
||||
'ERR14_bit4': 'P0008 — Engine Position System (bank 1)',
|
||||
'ERR14_bit5': 'P0009 — Engine Position System (bank 2)',
|
||||
'ERR14_bit6': 'P0014 — Camshaft Position B — Timing Over-Advanced (bank 1)',
|
||||
'ERR14_bit7': 'P0024 — Camshaft Position B — Timing Over-Advanced (bank 2)',
|
||||
|
||||
// ERR15
|
||||
'ERR15_bit0': 'P0087 — Fuel Rail/System Pressure Too Low',
|
||||
'ERR15_bit1': 'P0088 — Fuel Rail/System Pressure Too High',
|
||||
'ERR15_bit2': 'P0089 — Fuel Pressure Regulator Performance',
|
||||
'ERR15_bit3': 'P0090 — Fuel Pressure Regulator Control Circuit',
|
||||
'ERR15_bit4': 'P0091 — Fuel Pressure Regulator Control Circuit Low',
|
||||
'ERR15_bit5': 'P0092 — Fuel Pressure Regulator Control Circuit High',
|
||||
'ERR15_bit6': 'P0201 — Injector Circuit/Open Cylinder 1',
|
||||
'ERR15_bit7': 'P0202 — Injector Circuit/Open Cylinder 2',
|
||||
|
||||
// ERR16
|
||||
'ERR16_bit0': 'P0203 — Injector Circuit/Open Cylinder 3',
|
||||
'ERR16_bit1': 'P0204 — Injector Circuit/Open Cylinder 4',
|
||||
'ERR16_bit2': 'P0261 — Injector Circuit Low Cylinder 1',
|
||||
'ERR16_bit3': 'P0264 — Injector Circuit Low Cylinder 2',
|
||||
'ERR16_bit4': 'P0267 — Injector Circuit Low Cylinder 3',
|
||||
'ERR16_bit5': 'P0270 — Injector Circuit Low Cylinder 4',
|
||||
'ERR16_bit6': 'P0262 — Injector Circuit High Cylinder 1',
|
||||
'ERR16_bit7': 'P0265 — Injector Circuit High Cylinder 2',
|
||||
|
||||
// ERR17
|
||||
'ERR17_bit0': 'P0268 — Injector Circuit High Cylinder 3',
|
||||
'ERR17_bit1': 'P0271 — Injector Circuit High Cylinder 4',
|
||||
'ERR17_bit2': 'P0351 — Ignition Coil A Primary/Secondary Circuit',
|
||||
'ERR17_bit3': 'P0352 — Ignition Coil B Primary/Secondary Circuit',
|
||||
'ERR17_bit4': 'P0353 — Ignition Coil C Primary/Secondary Circuit',
|
||||
'ERR17_bit5': 'P0354 — Ignition Coil D Primary/Secondary Circuit',
|
||||
'ERR17_bit6': 'P0627 — Fuel Pump Control Circuit Open',
|
||||
'ERR17_bit7': 'P0628 — Fuel Pump Control Circuit Low',
|
||||
};
|
||||
|
||||
/// Returns a list of active DTC descriptions from the given raw error bytes
|
||||
/// using the provided map.
|
||||
List<String> decodeDtcs(List<int> errBytes, Map<String, String> dtcMap) {
|
||||
final List<String> active = [];
|
||||
for (int byteIdx = 0; byteIdx < errBytes.length; byteIdx++) {
|
||||
final int b = errBytes[byteIdx];
|
||||
if (b == 0) continue;
|
||||
for (int bit = 0; bit < 8; bit++) {
|
||||
if ((b >> bit) & 1 == 1) {
|
||||
final String key = 'ERR${byteIdx.toString().padLeft(2, '0')}_bit$bit';
|
||||
final String? desc = dtcMap[key];
|
||||
if (desc != null) active.add(desc);
|
||||
}
|
||||
}
|
||||
}
|
||||
return active;
|
||||
}
|
||||
/// DTC maps for S300 and KPro ECU protocols.
|
||||
///
|
||||
/// Key format: 'ERRxx_bity' where xx = byte index (00-based), y = bit number (0=LSB).
|
||||
/// Value: 'Pxxxx — description'
|
||||
library;
|
||||
|
||||
/// S300 DTC map (ERR00–ERR03, 4 bytes × 8 bits = 32 possible codes)
|
||||
const Map<String, String> s300DtcMap = {
|
||||
// ERR00 (byte 0x31)
|
||||
'ERR00_bit0': 'P0130 — O2 Sensor (front)',
|
||||
'ERR00_bit1': 'P0135 — O2 Sensor Heater (front)',
|
||||
'ERR00_bit2': 'P0505 — Idle Air Control Valve',
|
||||
'ERR00_bit3': 'P0108 — MAP Sensor High',
|
||||
'ERR00_bit4': 'P0107 — MAP Sensor Low',
|
||||
'ERR00_bit5': 'P0113 — IAT Sensor High',
|
||||
'ERR00_bit6': 'P0112 — IAT Sensor Low',
|
||||
'ERR00_bit7': 'P0118 — ECT Sensor High',
|
||||
|
||||
// ERR01 (byte 0x32)
|
||||
'ERR01_bit0': 'P0117 — ECT Sensor Low',
|
||||
'ERR01_bit1': 'P0336 — CKP Sensor',
|
||||
'ERR01_bit2': 'P0341 — CMP Sensor',
|
||||
'ERR01_bit3': 'P1362 — TDC Sensor (No Signal)',
|
||||
'ERR01_bit4': 'P1363 — TDC Sensor (No Signal 2)',
|
||||
'ERR01_bit5': 'P0335 — CKP Sensor (No Signal)',
|
||||
'ERR01_bit6': 'P1361 — TDC Sensor',
|
||||
'ERR01_bit7': 'P0300 — Random Misfire',
|
||||
|
||||
// ERR02 (byte 0x33)
|
||||
'ERR02_bit0': 'P0301 — Misfire Cylinder 1',
|
||||
'ERR02_bit1': 'P0302 — Misfire Cylinder 2',
|
||||
'ERR02_bit2': 'P0303 — Misfire Cylinder 3',
|
||||
'ERR02_bit3': 'P0304 — Misfire Cylinder 4',
|
||||
'ERR02_bit4': 'P1259 — VTEC System Malfunction',
|
||||
'ERR02_bit5': 'P0420 — Catalyst System Low Efficiency',
|
||||
'ERR02_bit6': 'P0401 — EGR Insufficient Flow',
|
||||
'ERR02_bit7': 'P1456 — EVAP Leak (large) Fuel Tank Side',
|
||||
|
||||
// ERR03 (byte 0x34)
|
||||
'ERR03_bit0': 'P1457 — EVAP Leak (large) Canister Side',
|
||||
'ERR03_bit1': 'P0498 — EVAP Canister Vent Shut Valve',
|
||||
'ERR03_bit2': 'P0497 — EVAP Low Purge Flow',
|
||||
'ERR03_bit3': 'P0122 — TPS Sensor Low',
|
||||
'ERR03_bit4': 'P0123 — TPS Sensor High',
|
||||
'ERR03_bit5': 'P0562 — Battery Voltage Low',
|
||||
'ERR03_bit6': 'P0563 — Battery Voltage High',
|
||||
'ERR03_bit7': 'P1607 — PCM Internal Circuit',
|
||||
};
|
||||
|
||||
/// KPro DTC map (ERR00–ERR17, 18 bytes × 8 bits = 144 possible codes)
|
||||
const Map<String, String> kproDtcMap = {
|
||||
// ERR00
|
||||
'ERR00_bit0': 'P0130 — O2 Sensor (front)',
|
||||
'ERR00_bit1': 'P0135 — O2 Sensor Heater (front)',
|
||||
'ERR00_bit2': 'P0505 — Idle Air Control Valve',
|
||||
'ERR00_bit3': 'P0108 — MAP Sensor High',
|
||||
'ERR00_bit4': 'P0107 — MAP Sensor Low',
|
||||
'ERR00_bit5': 'P0113 — IAT Sensor High',
|
||||
'ERR00_bit6': 'P0112 — IAT Sensor Low',
|
||||
'ERR00_bit7': 'P0118 — ECT Sensor High',
|
||||
|
||||
// ERR01
|
||||
'ERR01_bit0': 'P0117 — ECT Sensor Low',
|
||||
'ERR01_bit1': 'P0336 — CKP Sensor',
|
||||
'ERR01_bit2': 'P0341 — CMP Sensor',
|
||||
'ERR01_bit3': 'P1362 — TDC Sensor (No Signal)',
|
||||
'ERR01_bit4': 'P1363 — TDC Sensor (No Signal 2)',
|
||||
'ERR01_bit5': 'P0335 — CKP Sensor (No Signal)',
|
||||
'ERR01_bit6': 'P1361 — TDC Sensor',
|
||||
'ERR01_bit7': 'P0300 — Random Misfire',
|
||||
|
||||
// ERR02
|
||||
'ERR02_bit0': 'P0301 — Misfire Cylinder 1',
|
||||
'ERR02_bit1': 'P0302 — Misfire Cylinder 2',
|
||||
'ERR02_bit2': 'P0303 — Misfire Cylinder 3',
|
||||
'ERR02_bit3': 'P0304 — Misfire Cylinder 4',
|
||||
'ERR02_bit4': 'P1259 — VTEC System Malfunction',
|
||||
'ERR02_bit5': 'P0420 — Catalyst System Low Efficiency',
|
||||
'ERR02_bit6': 'P0401 — EGR Insufficient Flow',
|
||||
'ERR02_bit7': 'P1456 — EVAP Leak (large) Fuel Tank Side',
|
||||
|
||||
// ERR03
|
||||
'ERR03_bit0': 'P1457 — EVAP Leak (large) Canister Side',
|
||||
'ERR03_bit1': 'P0498 — EVAP Canister Vent Shut Valve',
|
||||
'ERR03_bit2': 'P0497 — EVAP Low Purge Flow',
|
||||
'ERR03_bit3': 'P0122 — TPS Sensor Low',
|
||||
'ERR03_bit4': 'P0123 — TPS Sensor High',
|
||||
'ERR03_bit5': 'P0562 — Battery Voltage Low',
|
||||
'ERR03_bit6': 'P0563 — Battery Voltage High',
|
||||
'ERR03_bit7': 'P1607 — PCM Internal Circuit',
|
||||
|
||||
// ERR04
|
||||
'ERR04_bit0': 'P0136 — O2 Sensor (rear)',
|
||||
'ERR04_bit1': 'P0141 — O2 Sensor Heater (rear)',
|
||||
'ERR04_bit2': 'P1166 — Air Fuel Ratio (A/F) Sensor Heater',
|
||||
'ERR04_bit3': 'P1167 — Air Fuel Ratio (A/F) Sensor',
|
||||
'ERR04_bit4': 'P2195 — A/F Sensor Signal Stuck Lean',
|
||||
'ERR04_bit5': 'P2196 — A/F Sensor Signal Stuck Rich',
|
||||
'ERR04_bit6': 'P2237 — A/F Sensor Pump Cell Current Input Low',
|
||||
'ERR04_bit7': 'P2240 — A/F Sensor Pump Cell Current Input High',
|
||||
|
||||
// ERR05
|
||||
'ERR05_bit0': 'P2243 — A/F Sensor Reference Voltage Low',
|
||||
'ERR05_bit1': 'P2247 — A/F Sensor Reference Voltage High',
|
||||
'ERR05_bit2': 'P2251 — A/F Sensor VS Cell Voltage Low',
|
||||
'ERR05_bit3': 'P2254 — A/F Sensor VS Cell Voltage High',
|
||||
'ERR05_bit4': 'P0031 — O2 Sensor Heater Current Low (bank 1, sensor 1)',
|
||||
'ERR05_bit5': 'P0032 — O2 Sensor Heater Current High (bank 1, sensor 1)',
|
||||
'ERR05_bit6': 'P0037 — O2 Sensor Heater Current Low (bank 1, sensor 2)',
|
||||
'ERR05_bit7': 'P0038 — O2 Sensor Heater Current High (bank 1, sensor 2)',
|
||||
|
||||
// ERR06
|
||||
'ERR06_bit0': 'P0068 — MAP/MAF vs Throttle Position Correlation',
|
||||
'ERR06_bit1': 'P0101 — MAF Sensor Range/Performance',
|
||||
'ERR06_bit2': 'P0102 — MAF Sensor Low Input',
|
||||
'ERR06_bit3': 'P0103 — MAF Sensor High Input',
|
||||
'ERR06_bit4': 'P0171 — System Lean (bank 1)',
|
||||
'ERR06_bit5': 'P0172 — System Rich (bank 1)',
|
||||
'ERR06_bit6': 'P0174 — System Lean (bank 2)',
|
||||
'ERR06_bit7': 'P0175 — System Rich (bank 2)',
|
||||
|
||||
// ERR07
|
||||
'ERR07_bit0': 'P0325 — Knock Sensor',
|
||||
'ERR07_bit1': 'P0330 — Knock Sensor 2',
|
||||
'ERR07_bit2': 'P0339 — CKP Sensor Intermittent',
|
||||
'ERR07_bit3': 'P0344 — CMP Sensor Intermittent',
|
||||
'ERR07_bit4': 'P0365 — CMP Sensor B Circuit',
|
||||
'ERR07_bit5': 'P0390 — CMP Sensor B Circuit (bank 2)',
|
||||
'ERR07_bit6': 'P0498 — EVAP Canister Vent Shut Valve Low',
|
||||
'ERR07_bit7': 'P0499 — EVAP Canister Vent Shut Valve High',
|
||||
|
||||
// ERR08
|
||||
'ERR08_bit0': 'P0600 — Serial Communication Link',
|
||||
'ERR08_bit1': 'P0601 — Internal Control Module Memory',
|
||||
'ERR08_bit2': 'P0604 — Internal Control Module RAM',
|
||||
'ERR08_bit3': 'P0605 — Internal Control Module ROM',
|
||||
'ERR08_bit4': 'P0657 — Actuator Supply Voltage A Open',
|
||||
'ERR08_bit5': 'P0685 — ECM/PCM Power Relay Control Open',
|
||||
'ERR08_bit6': 'P0691 — Fan 1 Control Circuit Low',
|
||||
'ERR08_bit7': 'P0692 — Fan 1 Control Circuit High',
|
||||
|
||||
// ERR09
|
||||
'ERR09_bit0': 'P0693 — Fan 2 Control Circuit Low',
|
||||
'ERR09_bit1': 'P0694 — Fan 2 Control Circuit High',
|
||||
'ERR09_bit2': 'P1454 — EVAP Pressure Sensor Range/Performance',
|
||||
'ERR09_bit3': 'P1455 — EVAP Leak (small)',
|
||||
'ERR09_bit4': 'P1508 — IAC Valve Circuit Low',
|
||||
'ERR09_bit5': 'P1509 — IAC Valve Circuit High',
|
||||
'ERR09_bit6': 'P1519 — Idle Air Control Valve Stuck',
|
||||
'ERR09_bit7': 'P1706 — A/T Range Switch Out Of Range',
|
||||
|
||||
// ERR10
|
||||
'ERR10_bit0': 'P2101 — Throttle Actuator Control Motor Range',
|
||||
'ERR10_bit1': 'P2118 — Throttle Actuator Control Motor Current Range',
|
||||
'ERR10_bit2': 'P2119 — Throttle Actuator Control Throttle Body Range',
|
||||
'ERR10_bit3': 'P2122 — Throttle/Pedal Position Sensor D Low Input',
|
||||
'ERR10_bit4': 'P2123 — Throttle/Pedal Position Sensor D High Input',
|
||||
'ERR10_bit5': 'P2127 — Throttle/Pedal Position Sensor E Low Input',
|
||||
'ERR10_bit6': 'P2128 — Throttle/Pedal Position Sensor E High Input',
|
||||
'ERR10_bit7': 'P2135 — Throttle/Pedal Position Sensor A/B Voltage Correlation',
|
||||
|
||||
// ERR11
|
||||
'ERR11_bit0': 'P2138 — Throttle/Pedal Position Sensor D/E Voltage Correlation',
|
||||
'ERR11_bit1': 'P2176 — Throttle Actuator Control System Idle Position Not Learned',
|
||||
'ERR11_bit2': 'P2227 — Barometric Pressure Sensor A Circuit Range',
|
||||
'ERR11_bit3': 'P2228 — Barometric Pressure Sensor A Circuit Low',
|
||||
'ERR11_bit4': 'P2229 — Barometric Pressure Sensor A Circuit High',
|
||||
'ERR11_bit5': 'P2610 — ECM/PCM Internal Engine Off Timer',
|
||||
'ERR11_bit6': 'P2647 — VTEC System High (bank 1)',
|
||||
'ERR11_bit7': 'P2648 — VTEC System Low (bank 1)',
|
||||
|
||||
// ERR12
|
||||
'ERR12_bit0': 'P2649 — VTEC System High (bank 2)',
|
||||
'ERR12_bit1': 'P2650 — VTEC System Low (bank 2)',
|
||||
'ERR12_bit2': 'P3400 — VTEC System Stuck Off (bank 1)',
|
||||
'ERR12_bit3': 'P3497 — VTEC System Stuck Off (bank 2)',
|
||||
'ERR12_bit4': 'U0073 — Control Module Communication Bus Off',
|
||||
'ERR12_bit5': 'U0100 — Lost Communication With ECM/PCM A',
|
||||
'ERR12_bit6': 'U0155 — Lost Communication With Instrument Panel Cluster',
|
||||
'ERR12_bit7': 'U0164 — Lost Communication With HVAC Control Module',
|
||||
|
||||
// ERR13
|
||||
'ERR13_bit0': 'P0011 — Camshaft Position A — Timing Over-Advanced (bank 1)',
|
||||
'ERR13_bit1': 'P0012 — Camshaft Position A — Timing Over-Retarded (bank 1)',
|
||||
'ERR13_bit2': 'P0021 — Camshaft Position B — Timing Over-Advanced (bank 1)',
|
||||
'ERR13_bit3': 'P0022 — Camshaft Position B — Timing Over-Retarded (bank 1)',
|
||||
'ERR13_bit4': 'P0340 — CMP Sensor A Circuit (bank 1)',
|
||||
'ERR13_bit5': 'P0345 — CMP Sensor A Circuit (bank 2)',
|
||||
'ERR13_bit6': 'P0366 — CMP Sensor B Circuit Range/Performance (bank 1)',
|
||||
'ERR13_bit7': 'P0391 — CMP Sensor B Circuit Range/Performance (bank 2)',
|
||||
|
||||
// ERR14
|
||||
'ERR14_bit0': 'P0016 — Crankshaft/Camshaft Position Correlation (bank 1, sensor A)',
|
||||
'ERR14_bit1': 'P0017 — Crankshaft/Camshaft Position Correlation (bank 1, sensor B)',
|
||||
'ERR14_bit2': 'P0018 — Crankshaft/Camshaft Position Correlation (bank 2, sensor A)',
|
||||
'ERR14_bit3': 'P0019 — Crankshaft/Camshaft Position Correlation (bank 2, sensor B)',
|
||||
'ERR14_bit4': 'P0008 — Engine Position System (bank 1)',
|
||||
'ERR14_bit5': 'P0009 — Engine Position System (bank 2)',
|
||||
'ERR14_bit6': 'P0014 — Camshaft Position B — Timing Over-Advanced (bank 1)',
|
||||
'ERR14_bit7': 'P0024 — Camshaft Position B — Timing Over-Advanced (bank 2)',
|
||||
|
||||
// ERR15
|
||||
'ERR15_bit0': 'P0087 — Fuel Rail/System Pressure Too Low',
|
||||
'ERR15_bit1': 'P0088 — Fuel Rail/System Pressure Too High',
|
||||
'ERR15_bit2': 'P0089 — Fuel Pressure Regulator Performance',
|
||||
'ERR15_bit3': 'P0090 — Fuel Pressure Regulator Control Circuit',
|
||||
'ERR15_bit4': 'P0091 — Fuel Pressure Regulator Control Circuit Low',
|
||||
'ERR15_bit5': 'P0092 — Fuel Pressure Regulator Control Circuit High',
|
||||
'ERR15_bit6': 'P0201 — Injector Circuit/Open Cylinder 1',
|
||||
'ERR15_bit7': 'P0202 — Injector Circuit/Open Cylinder 2',
|
||||
|
||||
// ERR16
|
||||
'ERR16_bit0': 'P0203 — Injector Circuit/Open Cylinder 3',
|
||||
'ERR16_bit1': 'P0204 — Injector Circuit/Open Cylinder 4',
|
||||
'ERR16_bit2': 'P0261 — Injector Circuit Low Cylinder 1',
|
||||
'ERR16_bit3': 'P0264 — Injector Circuit Low Cylinder 2',
|
||||
'ERR16_bit4': 'P0267 — Injector Circuit Low Cylinder 3',
|
||||
'ERR16_bit5': 'P0270 — Injector Circuit Low Cylinder 4',
|
||||
'ERR16_bit6': 'P0262 — Injector Circuit High Cylinder 1',
|
||||
'ERR16_bit7': 'P0265 — Injector Circuit High Cylinder 2',
|
||||
|
||||
// ERR17
|
||||
'ERR17_bit0': 'P0268 — Injector Circuit High Cylinder 3',
|
||||
'ERR17_bit1': 'P0271 — Injector Circuit High Cylinder 4',
|
||||
'ERR17_bit2': 'P0351 — Ignition Coil A Primary/Secondary Circuit',
|
||||
'ERR17_bit3': 'P0352 — Ignition Coil B Primary/Secondary Circuit',
|
||||
'ERR17_bit4': 'P0353 — Ignition Coil C Primary/Secondary Circuit',
|
||||
'ERR17_bit5': 'P0354 — Ignition Coil D Primary/Secondary Circuit',
|
||||
'ERR17_bit6': 'P0627 — Fuel Pump Control Circuit Open',
|
||||
'ERR17_bit7': 'P0628 — Fuel Pump Control Circuit Low',
|
||||
};
|
||||
|
||||
/// Returns a list of active DTC descriptions from the given raw error bytes
|
||||
/// using the provided map.
|
||||
List<String> decodeDtcs(List<int> errBytes, Map<String, String> dtcMap) {
|
||||
final List<String> active = [];
|
||||
for (int byteIdx = 0; byteIdx < errBytes.length; byteIdx++) {
|
||||
final int b = errBytes[byteIdx];
|
||||
if (b == 0) continue;
|
||||
for (int bit = 0; bit < 8; bit++) {
|
||||
if ((b >> bit) & 1 == 1) {
|
||||
final String key = 'ERR${byteIdx.toString().padLeft(2, '0')}_bit$bit';
|
||||
final String? desc = dtcMap[key];
|
||||
if (desc != null) active.add(desc);
|
||||
}
|
||||
}
|
||||
}
|
||||
return active;
|
||||
}
|
||||
|
||||
@ -1,144 +1,147 @@
|
||||
import 'dart:typed_data';
|
||||
import 'sensor_state.dart';
|
||||
import 'temp_table.dart';
|
||||
import 'neg8.dart';
|
||||
|
||||
/// Parses a 128-byte KPro ECU response frame into a [SensorState].
|
||||
/// Throws [ArgumentError] if frame length is wrong or NEG8 checksum fails.
|
||||
SensorState parseKPro(Uint8List frame) {
|
||||
if (frame.length != 128) {
|
||||
throw ArgumentError('KPro frame must be 128 bytes, got ${frame.length}');
|
||||
}
|
||||
if (!validateFrame(frame)) {
|
||||
throw ArgumentError('KPro frame NEG8 checksum failed');
|
||||
}
|
||||
|
||||
// RPM: bytes 2..3 — raw / 4
|
||||
final int rpmRaw = _readUint16BE(frame, 2);
|
||||
final double rpm = rpmRaw / 4.0;
|
||||
|
||||
// VSS: byte 4 direct km/h
|
||||
final double vss = frame[4].toDouble();
|
||||
|
||||
// TPS: byte 5 direct %
|
||||
final double tps = frame[5].toDouble();
|
||||
|
||||
// MAP: bytes 6 (MAP1) and 33..34 (MAP2 L+H)
|
||||
final int map1Raw = frame[6];
|
||||
final int map2Raw = _readUint16BE(frame, 33);
|
||||
final double map = (map2Raw != 0) ? map2Raw / 10.0 : map1Raw.toDouble();
|
||||
|
||||
// INJ: byte 11 raw
|
||||
final double inj = frame[11].toDouble();
|
||||
|
||||
// IGN: byte 13 raw degrees
|
||||
final double ign = frame[13].toDouble();
|
||||
|
||||
// O2: byte 14 raw
|
||||
final double o2 = frame[14].toDouble();
|
||||
|
||||
// KRtrd: byte 21
|
||||
final int krtrd = frame[21];
|
||||
|
||||
// SW bitmaps
|
||||
final int sw1 = frame[31];
|
||||
final int sw2 = frame[32];
|
||||
|
||||
// ECT: byte 49. Table is tempXlt[raw] - 40, not +40 — see s300_parser.dart
|
||||
// for the reasoning (the declared -40..150 degC sensor range is only
|
||||
// reachable with subtraction).
|
||||
final double ect = (tempXlt[frame[49]] - 40).toDouble();
|
||||
|
||||
// IAT: byte 50
|
||||
final double iat = (tempXlt[frame[50]] - 40).toDouble();
|
||||
|
||||
// PA: byte 51
|
||||
final double pa = frame[51].toDouble();
|
||||
|
||||
// BAT: byte 52 — raw / 10
|
||||
final double bat = frame[52] / 10.0;
|
||||
|
||||
// STRIM: byte 32 (SW2 shares offset 32, STRIM is byte 20 per spec offset +20)
|
||||
final double strim = frame[20].toDouble();
|
||||
|
||||
// LTRIM — not explicitly listed in KPro; use 0
|
||||
const double ltrim = 0.0;
|
||||
|
||||
// ETH — not in KPro spec; use 0
|
||||
const double eth = 0.0;
|
||||
|
||||
// AFR: byte 25 (AFCMD at offset +19, then raw)
|
||||
final double afr = frame[25].toDouble();
|
||||
|
||||
// ERR bytes: 18 bytes starting at offset 0x3E = 62
|
||||
final List<int> errBytes = List<int>.generate(18, (i) => frame[62 + i]);
|
||||
|
||||
// AIN0–AIN7: bytes 0x52..0x61 (offset 82..97, uint16 BE each)
|
||||
final double ain0 = _readUint16BE(frame, 82).toDouble();
|
||||
final double ain1 = _readUint16BE(frame, 84).toDouble();
|
||||
final double ain2 = _readUint16BE(frame, 86).toDouble();
|
||||
final double ain3 = _readUint16BE(frame, 88).toDouble();
|
||||
final double ain4 = _readUint16BE(frame, 90).toDouble();
|
||||
final double ain5 = _readUint16BE(frame, 92).toDouble();
|
||||
final double ain6 = _readUint16BE(frame, 94).toDouble();
|
||||
final double ain7 = _readUint16BE(frame, 96).toDouble();
|
||||
|
||||
// Gear — not in KPro frame, default 0
|
||||
const int gear = 0;
|
||||
|
||||
// Flags
|
||||
// SW2 bit2 = MIL
|
||||
final bool mil = (sw2 & 0x04) != 0;
|
||||
// SW1 bit6 = FLR (fuel cut relay)
|
||||
final bool fuelCut = (sw1 & 0x40) != 0;
|
||||
// SW1 bit0 = FANC
|
||||
final bool fanOut = (sw1 & 0x01) != 0;
|
||||
// SW2 bit1..0 = VTS (VTEC)
|
||||
final bool vtec = (sw2 & 0x03) != 0;
|
||||
// Knock = KRtrd > 0
|
||||
final bool knock = krtrd > 0;
|
||||
// Rev limit — not directly mapped in KPro SW; false
|
||||
const bool revLimit = false;
|
||||
// Launch — not in KPro SW; false
|
||||
const bool launch = false;
|
||||
|
||||
return SensorState(
|
||||
rpm: rpm,
|
||||
vss: vss,
|
||||
map: map,
|
||||
tps: tps,
|
||||
inj: inj,
|
||||
ign: ign,
|
||||
ect: ect,
|
||||
iat: iat,
|
||||
bat: bat,
|
||||
o2: o2,
|
||||
gear: gear,
|
||||
eth: eth,
|
||||
pa: pa,
|
||||
afr: afr,
|
||||
strim: strim,
|
||||
ltrim: ltrim,
|
||||
ain0: ain0,
|
||||
ain1: ain1,
|
||||
ain2: ain2,
|
||||
ain3: ain3,
|
||||
ain4: ain4,
|
||||
ain5: ain5,
|
||||
ain6: ain6,
|
||||
ain7: ain7,
|
||||
mil: mil,
|
||||
fuelCut: fuelCut,
|
||||
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];
|
||||
}
|
||||
import 'dart:typed_data';
|
||||
import 'sensor_state.dart';
|
||||
import 'temp_table.dart';
|
||||
import 'neg8.dart';
|
||||
|
||||
/// Parses a 128-byte KPro ECU response frame into a [SensorState].
|
||||
/// Throws [ArgumentError] if frame length is wrong or NEG8 checksum fails.
|
||||
SensorState parseKPro(Uint8List frame) {
|
||||
if (frame.length != 128) {
|
||||
throw ArgumentError('KPro frame must be 128 bytes, got ${frame.length}');
|
||||
}
|
||||
if (!validateFrame(frame)) {
|
||||
throw ArgumentError('KPro frame NEG8 checksum failed');
|
||||
}
|
||||
|
||||
// RPM: bytes 2..3 — raw / 4
|
||||
final int rpmRaw = _readUint16BE(frame, 2);
|
||||
final double rpm = rpmRaw / 4.0;
|
||||
|
||||
// VSS: byte 4 direct km/h
|
||||
final double vss = frame[4].toDouble();
|
||||
|
||||
// TPS: byte 5 direct %
|
||||
final double tps = frame[5].toDouble();
|
||||
|
||||
// MAP: bytes 6 (MAP1) and 33..34 (MAP2 L+H)
|
||||
final int map1Raw = frame[6];
|
||||
final int map2Raw = _readUint16BE(frame, 33);
|
||||
final double map = (map2Raw != 0) ? map2Raw / 10.0 : map1Raw.toDouble();
|
||||
|
||||
// INJ: byte 11 raw
|
||||
final double inj = frame[11].toDouble();
|
||||
|
||||
// IGN: byte 13 raw degrees
|
||||
final double ign = frame[13].toDouble();
|
||||
|
||||
// O2: byte 14 raw
|
||||
final double o2 = frame[14].toDouble();
|
||||
|
||||
// KRtrd: byte 21
|
||||
final int krtrd = frame[21];
|
||||
|
||||
// SW bitmaps
|
||||
final int sw1 = frame[31];
|
||||
final int sw2 = frame[32];
|
||||
|
||||
// ECT: byte 49. Table is tempXlt[raw] - 40, not +40 — see s300_parser.dart
|
||||
// for the reasoning (the declared -40..150 degC sensor range is only
|
||||
// reachable with subtraction).
|
||||
final double ect = (tempXlt[frame[49]] - 40).toDouble();
|
||||
|
||||
// IAT: byte 50
|
||||
final double iat = (tempXlt[frame[50]] - 40).toDouble();
|
||||
|
||||
// PA: byte 51
|
||||
final double pa = frame[51].toDouble();
|
||||
|
||||
// BAT: byte 52 — raw / 10
|
||||
final double bat = frame[52] / 10.0;
|
||||
|
||||
// STRIM: byte 32 (SW2 shares offset 32, STRIM is byte 20 per spec offset +20)
|
||||
final double strim = frame[20].toDouble();
|
||||
|
||||
// LTRIM — not explicitly listed in KPro; use 0
|
||||
const double ltrim = 0.0;
|
||||
|
||||
// ETH — not in KPro spec; use 0
|
||||
const double eth = 0.0;
|
||||
|
||||
// AFR: byte 25 (AFCMD at offset +19, then raw)
|
||||
final double afr = frame[25].toDouble();
|
||||
|
||||
// ERR bytes: 18 bytes starting at offset 0x3E = 62
|
||||
final List<int> errBytes = List<int>.generate(18, (i) => frame[62 + i]);
|
||||
|
||||
// AIN0–AIN7: bytes 0x52..0x61 (offset 82..97, uint16 BE each)
|
||||
final double ain0 = _readUint16BE(frame, 82).toDouble();
|
||||
final double ain1 = _readUint16BE(frame, 84).toDouble();
|
||||
final double ain2 = _readUint16BE(frame, 86).toDouble();
|
||||
final double ain3 = _readUint16BE(frame, 88).toDouble();
|
||||
final double ain4 = _readUint16BE(frame, 90).toDouble();
|
||||
final double ain5 = _readUint16BE(frame, 92).toDouble();
|
||||
final double ain6 = _readUint16BE(frame, 94).toDouble();
|
||||
final double ain7 = _readUint16BE(frame, 96).toDouble();
|
||||
|
||||
// Gear — not in KPro frame, default 0
|
||||
const int gear = 0;
|
||||
|
||||
// Flags
|
||||
// SW2 bit2 = MIL
|
||||
final bool mil = (sw2 & 0x04) != 0;
|
||||
// SW1 bit6 = FLR. Unverified: never live-tested for KPro, and the
|
||||
// equivalent S300 bit turned out to be fuel PUMP relay status, not fuel
|
||||
// cut, despite an initially plausible-looking name — treat "FLR" the
|
||||
// same way until confirmed.
|
||||
final bool fuelPump = (sw1 & 0x40) != 0;
|
||||
// SW1 bit0 = FANC
|
||||
final bool fanOut = (sw1 & 0x01) != 0;
|
||||
// SW2 bit1..0 = VTS (VTEC)
|
||||
final bool vtec = (sw2 & 0x03) != 0;
|
||||
// Knock = KRtrd > 0
|
||||
final bool knock = krtrd > 0;
|
||||
// Rev limit — not directly mapped in KPro SW; false
|
||||
const bool revLimit = false;
|
||||
// Launch — not in KPro SW; false
|
||||
const bool launch = false;
|
||||
|
||||
return SensorState(
|
||||
rpm: rpm,
|
||||
vss: vss,
|
||||
map: map,
|
||||
tps: tps,
|
||||
inj: inj,
|
||||
ign: ign,
|
||||
ect: ect,
|
||||
iat: iat,
|
||||
bat: bat,
|
||||
o2: o2,
|
||||
gear: gear,
|
||||
eth: eth,
|
||||
pa: pa,
|
||||
afr: afr,
|
||||
strim: strim,
|
||||
ltrim: ltrim,
|
||||
ain0: ain0,
|
||||
ain1: ain1,
|
||||
ain2: ain2,
|
||||
ain3: ain3,
|
||||
ain4: ain4,
|
||||
ain5: ain5,
|
||||
ain6: ain6,
|
||||
ain7: ain7,
|
||||
mil: mil,
|
||||
fuelPump: fuelPump,
|
||||
fanOut: fanOut,
|
||||
vtec: vtec,
|
||||
knock: knock,
|
||||
revLimit: revLimit,
|
||||
launch: launch,
|
||||
errBytes: errBytes,
|
||||
timestamp: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
int _readUint16BE(Uint8List frame, int offset) {
|
||||
return (frame[offset] << 8) | frame[offset + 1];
|
||||
}
|
||||
|
||||
@ -1,22 +1,22 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
/// Calculates the NEG8 checksum for a buffer.
|
||||
/// NEG8: start at 0, subtract each byte, keep lower 8 bits.
|
||||
/// The result should match the last byte of a valid frame.
|
||||
int calculateNeg8(Uint8List buf) {
|
||||
int neg8 = 0;
|
||||
for (int i = 0; i < buf.length; i++) {
|
||||
neg8 = (neg8 - buf[i]) & 0xFF;
|
||||
}
|
||||
return neg8;
|
||||
}
|
||||
|
||||
/// Returns true if the frame's last byte equals the NEG8 of all preceding bytes.
|
||||
bool validateFrame(Uint8List frame) {
|
||||
if (frame.isEmpty) return false;
|
||||
int neg8 = 0;
|
||||
for (int i = 0; i < frame.length - 1; i++) {
|
||||
neg8 = (neg8 - frame[i]) & 0xFF;
|
||||
}
|
||||
return neg8 == frame[frame.length - 1];
|
||||
}
|
||||
import 'dart:typed_data';
|
||||
|
||||
/// Calculates the NEG8 checksum for a buffer.
|
||||
/// NEG8: start at 0, subtract each byte, keep lower 8 bits.
|
||||
/// The result should match the last byte of a valid frame.
|
||||
int calculateNeg8(Uint8List buf) {
|
||||
int neg8 = 0;
|
||||
for (int i = 0; i < buf.length; i++) {
|
||||
neg8 = (neg8 - buf[i]) & 0xFF;
|
||||
}
|
||||
return neg8;
|
||||
}
|
||||
|
||||
/// Returns true if the frame's last byte equals the NEG8 of all preceding bytes.
|
||||
bool validateFrame(Uint8List frame) {
|
||||
if (frame.isEmpty) return false;
|
||||
int neg8 = 0;
|
||||
for (int i = 0; i < frame.length - 1; i++) {
|
||||
neg8 = (neg8 - frame[i]) & 0xFF;
|
||||
}
|
||||
return neg8 == frame[frame.length - 1];
|
||||
}
|
||||
|
||||
@ -1,165 +1,237 @@
|
||||
import 'dart:typed_data';
|
||||
import 'sensor_state.dart';
|
||||
import 'temp_table.dart';
|
||||
import 'neg8.dart';
|
||||
|
||||
/// Parses a 128-byte S300 ECU response frame into a [SensorState].
|
||||
/// Throws [ArgumentError] if frame length is wrong, or if [validateChecksum]
|
||||
/// is true and the NEG8 checksum fails.
|
||||
SensorState parseS300(Uint8List frame, {bool validateChecksum = true}) {
|
||||
if (frame.length != 128) {
|
||||
throw ArgumentError('S300 frame must be 128 bytes, got ${frame.length}');
|
||||
}
|
||||
if (validateChecksum && !validateFrame(frame)) {
|
||||
throw ArgumentError('S300 frame NEG8 checksum failed');
|
||||
}
|
||||
|
||||
// RPM: bytes +3..4, little-endian, use directly. Confirmed against a live
|
||||
// capture: LE @ offset 3 matched a reference tool's RPM exactly, while
|
||||
// REQUIREMENTS.md's blanket "all multi-byte values are Big Endian" claim
|
||||
// did not hold for this field on real hardware.
|
||||
final double rpm = _readUint16LE(frame, 3).toDouble();
|
||||
|
||||
// VSS: bytes +5..6, little-endian (confirmed against a live capture).
|
||||
final int vssRaw = _readUint16LE(frame, 5);
|
||||
final double vss =
|
||||
(vssRaw < 893 || vssRaw == 0xFFFF) ? 0.0 : 228480.0 / vssRaw;
|
||||
|
||||
// MAP: bytes +7..8, little-endian (confirmed against a live capture — the
|
||||
// big-endian read produced multi-thousand-kPa values, physically
|
||||
// impossible for a 0-300 kPa sensor).
|
||||
final int mapRaw = _readUint16LE(frame, 7);
|
||||
final double map = mapRaw / 10.0;
|
||||
|
||||
// TPS: byte +9. Linear 0-255 -> 0-100%, confirmed against a live capture
|
||||
// (raw 127 matched a reference tool's ~50% reading; the spec's
|
||||
// "raw*51/46" formula produced impossible >100% values on the same byte).
|
||||
final int tpsRaw = frame[9];
|
||||
final double tps = (tpsRaw * 100.0 / 255.0).clamp(0.0, 100.0);
|
||||
|
||||
// INJ: bytes +10..11, BE, raw ms.
|
||||
final double inj = _readUint16BE(frame, 10).toDouble();
|
||||
|
||||
// IGN: byte +12.
|
||||
final double ign = (frame[12] + 120) / 2.0;
|
||||
|
||||
// O2: byte +16, raw.
|
||||
final double o2 = frame[16].toDouble();
|
||||
|
||||
// SW bitmaps
|
||||
final int sw1 = frame[0x11]; // +17
|
||||
final int sw2 = frame[0x12]; // +18
|
||||
final int sw3 = frame[0x13]; // +19
|
||||
final int sw5 = frame[0x43]; // +43
|
||||
|
||||
// Gear: decimal offset 27 (confirmed against a live capture — hex 0x27/39
|
||||
// read 0x00 there; decimal 27 matched a reference tool's gear exactly).
|
||||
final int gear = frame[27];
|
||||
|
||||
// Strim: +29 hex = 41 decimal
|
||||
final double strim = frame[0x29].toDouble();
|
||||
|
||||
// Ltrim: +2B hex = 43 decimal
|
||||
final double ltrim = frame[0x2B].toDouble();
|
||||
|
||||
// PA: +2C hex = 44 decimal
|
||||
final double pa = frame[0x2C].toDouble();
|
||||
|
||||
// ECT: +2D hex = 45 decimal. Table is tempXlt[raw] - 40, not +40: the
|
||||
// spec's own sensor range (section 3: ECT/IAT -40..150 degC) is only
|
||||
// reachable with subtraction, since tempXlt entries span 0..190. The
|
||||
// written "+40" formula can only ever produce 40..230 degC, which
|
||||
// contradicts that declared range and explains the ~80 degC-too-hot
|
||||
// readings seen against real hardware.
|
||||
final double ect = (tempXlt[frame[0x2D]] - 40).toDouble();
|
||||
|
||||
// IAT: +2E hex = 46 decimal
|
||||
final double iat = (tempXlt[frame[0x2E]] - 40).toDouble();
|
||||
|
||||
// BAT: +30 hex = 48 decimal
|
||||
final double bat = frame[0x30] * 26.0 / 270.0;
|
||||
|
||||
// ERR bytes: +31..+34 hex = 49..52 decimal
|
||||
final List<int> errBytes = [
|
||||
frame[0x31],
|
||||
frame[0x32],
|
||||
frame[0x33],
|
||||
frame[0x34],
|
||||
];
|
||||
|
||||
// Eth: +38 hex = 56 decimal
|
||||
final double eth = frame[0x38].toDouble();
|
||||
|
||||
// AFR: spec overlaps +34 with ERR03; reuse that byte per the spec's own table.
|
||||
final double afr = frame[0x34].toDouble();
|
||||
|
||||
// AIN0–AIN7: decimal offsets 82..97 (uint16 BE each)
|
||||
final double ain0 = _readUint16BE(frame, 82).toDouble();
|
||||
final double ain1 = _readUint16BE(frame, 84).toDouble();
|
||||
final double ain2 = _readUint16BE(frame, 86).toDouble();
|
||||
final double ain3 = _readUint16BE(frame, 88).toDouble();
|
||||
final double ain4 = _readUint16BE(frame, 90).toDouble();
|
||||
final double ain5 = _readUint16BE(frame, 92).toDouble();
|
||||
final double ain6 = _readUint16BE(frame, 94).toDouble();
|
||||
final double ain7 = _readUint16BE(frame, 96).toDouble();
|
||||
|
||||
// Flags
|
||||
// SW1 bit3 = REVL
|
||||
final bool revLimit = (sw1 & 0x08) != 0;
|
||||
// SW2 bit5 = MIL
|
||||
final bool mil = (sw2 & 0x20) != 0;
|
||||
// SW2 bit2 = Fuel (cut)
|
||||
final bool fuelCut = (sw2 & 0x04) != 0;
|
||||
// SW2 bit7 = ALTC → fanOut; also SW5 bit0 = FANC
|
||||
final bool fanOut = (sw2 & 0x80) != 0 || (sw5 & 0x01) != 0;
|
||||
// SW2 bit1..0 = VTS (VTEC solenoid — nonzero = active)
|
||||
final bool vtec = (sw2 & 0x03) != 0;
|
||||
// Knock = KRtrd > 0 (byte 13)
|
||||
final bool knock = frame[13] > 0;
|
||||
// SW3 bit7 = LnchC
|
||||
final bool launch = (sw3 & 0x80) != 0;
|
||||
|
||||
return SensorState(
|
||||
rpm: rpm,
|
||||
vss: vss,
|
||||
map: map,
|
||||
tps: tps,
|
||||
inj: inj,
|
||||
ign: ign,
|
||||
ect: ect,
|
||||
iat: iat,
|
||||
bat: bat,
|
||||
o2: o2,
|
||||
gear: gear,
|
||||
eth: eth,
|
||||
pa: pa,
|
||||
afr: afr,
|
||||
strim: strim,
|
||||
ltrim: ltrim,
|
||||
ain0: ain0,
|
||||
ain1: ain1,
|
||||
ain2: ain2,
|
||||
ain3: ain3,
|
||||
ain4: ain4,
|
||||
ain5: ain5,
|
||||
ain6: ain6,
|
||||
ain7: ain7,
|
||||
mil: mil,
|
||||
fuelCut: fuelCut,
|
||||
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);
|
||||
}
|
||||
import 'dart:typed_data';
|
||||
import 'sensor_state.dart';
|
||||
import 'temp_table.dart';
|
||||
import 'neg8.dart';
|
||||
|
||||
/// True when bytes 0-16 of the frame (header, RPM, VSS, MAP, TPS, INJ, IGN,
|
||||
/// the knock byte, and O2) match a fixed pattern instead of a real reading,
|
||||
/// with those fields pinned to fixed constants regardless of the true
|
||||
/// physical signal.
|
||||
///
|
||||
/// The frozen region is now known to extend well past byte 16. Bytes 32-36
|
||||
/// (gear, ECT, IAT, BAT at their current offsets) show the exact same fixed
|
||||
/// byte sequence (`93 a0 a0 dc 7d`) across dozens of unrelated live captures
|
||||
/// throughout this project. SW2's flags (fuelPump, mil, vtec, fanOut's ALTC
|
||||
/// bit) were also seen going wrong during an idle period where nothing
|
||||
/// changed on the bench but the rest of the frame was also frozen — the
|
||||
/// earlier belief that SW2 stays live (based on an RPM sweep showing
|
||||
/// fuelPump correlating with real changes) only holds while the frame
|
||||
/// *isn't* templated; during a template frame it goes stale like everything
|
||||
/// else tested so far. SW1 itself was never actually confirmed for template
|
||||
/// frames specifically — the "matches USB exactly" validation that seemed
|
||||
/// to support "bytes 17+ are live" was checked against a non-template frame.
|
||||
/// At this point, don't assume any field is live during a template frame
|
||||
/// unless it's been individually verified that way — the safer default is
|
||||
/// that the whole frame is templated, with only a shrinking set of
|
||||
/// individually-confirmed-live fields as exceptions (currently: none
|
||||
/// confirmed with certainty — treat the entire byte-17+ region as suspect
|
||||
/// until proven otherwise field by field).
|
||||
///
|
||||
/// Callers should hold the last known-good values for every field sourced
|
||||
/// from the frozen span (bytes 0-16, gear/ECT/IAT/BAT, and the SW2 flags)
|
||||
/// and decode only what's individually confirmed live.
|
||||
bool isS300TemplateFrame(Uint8List frame) {
|
||||
if (frame.length != 128) return false;
|
||||
|
||||
return frame[0] == 0x1B &&
|
||||
frame[1] == 0x00 &&
|
||||
frame[2] == 0x14 &&
|
||||
frame[3] == 0x00 &&
|
||||
frame[4] == 0x00 &&
|
||||
frame[5] == 0xFF &&
|
||||
frame[6] == 0xFF &&
|
||||
frame[7] == 0xF2 &&
|
||||
frame[8] == 0x03 &&
|
||||
frame[9] == 0x18 &&
|
||||
frame[10] == 0x00 &&
|
||||
frame[11] == 0x00 &&
|
||||
frame[12] == 0x10 &&
|
||||
frame[13] == 0x00 &&
|
||||
frame[14] == 0x00 &&
|
||||
frame[15] == 0x00 &&
|
||||
frame[16] == 0xC4;
|
||||
}
|
||||
|
||||
/// Parses a 128-byte S300 ECU response frame into a [SensorState].
|
||||
/// Throws [ArgumentError] if frame length is wrong, or if [validateChecksum]
|
||||
/// is true and the NEG8 checksum fails.
|
||||
SensorState parseS300(Uint8List frame, {bool validateChecksum = true}) {
|
||||
if (frame.length != 128) {
|
||||
throw ArgumentError('S300 frame must be 128 bytes, got ${frame.length}');
|
||||
}
|
||||
if (validateChecksum && !validateFrame(frame)) {
|
||||
throw ArgumentError('S300 frame NEG8 checksum failed');
|
||||
}
|
||||
|
||||
// RPM: bytes +3..4, little-endian, use directly. Confirmed against a live
|
||||
// capture: LE @ offset 3 matched a reference tool's RPM exactly, while
|
||||
// REQUIREMENTS.md's blanket "all multi-byte values are Big Endian" claim
|
||||
// did not hold for this field on real hardware.
|
||||
final double rpm = _readUint16LE(frame, 3).toDouble();
|
||||
|
||||
// VSS: bytes +5..6, little-endian (confirmed against a live capture).
|
||||
final int vssRaw = _readUint16LE(frame, 5);
|
||||
final double vss =
|
||||
(vssRaw < 893 || vssRaw == 0xFFFF) ? 0.0 : 228480.0 / vssRaw;
|
||||
|
||||
// MAP: bytes +7..8, little-endian (confirmed against a live capture — the
|
||||
// big-endian read produced multi-thousand-kPa values, physically
|
||||
// impossible for a 0-300 kPa sensor).
|
||||
final int mapRaw = _readUint16LE(frame, 7);
|
||||
final double map = mapRaw / 10.0;
|
||||
|
||||
// TPS: byte +9. Linear 0-255 -> 0-100%, confirmed against a live capture
|
||||
// (raw 127 matched a reference tool's ~50% reading; the spec's
|
||||
// "raw*51/46" formula produced impossible >100% values on the same byte).
|
||||
final int tpsRaw = frame[9];
|
||||
final double tps = (tpsRaw * 100.0 / 255.0).clamp(0.0, 100.0);
|
||||
|
||||
// INJ: bytes +10..11, BE, raw ms.
|
||||
final double inj = _readUint16BE(frame, 10).toDouble();
|
||||
|
||||
// IGN: byte +12.
|
||||
final double ign = (frame[12] + 120) / 2.0;
|
||||
|
||||
// O2: byte +16, raw.
|
||||
final double o2 = frame[16].toDouble();
|
||||
|
||||
// SW bitmaps
|
||||
final int sw1 = frame[0x11]; // +17
|
||||
final int sw2 = frame[0x12]; // +18
|
||||
final int sw3 = frame[0x13]; // +19
|
||||
final int sw5 = frame[0x43]; // +43
|
||||
|
||||
// Gear: decimal offset 27 (confirmed against a live capture — hex 0x27/39
|
||||
// read 0x00 there; decimal 27 matched a reference tool's gear exactly).
|
||||
final int gear = frame[27];
|
||||
|
||||
// Strim: +29 hex = 41 decimal
|
||||
final double strim = frame[0x29].toDouble();
|
||||
|
||||
// Ltrim: +2B hex = 43 decimal
|
||||
final double ltrim = frame[0x2B].toDouble();
|
||||
|
||||
// PA: +2C hex = 44 decimal. Unconfirmed — a live capture's closest
|
||||
// candidate (byte 50, decimal) was off by ~3 kPa from the reference
|
||||
// tool's 98.7 kPa reading, not a clean enough match to move to yet.
|
||||
final double pa = frame[0x2C].toDouble();
|
||||
|
||||
// ECT: decimal offset 33. Confirmed via live capture matching the
|
||||
// reference tool exactly: raw=0xD2(210), tempXlt[210]=38, 38-40=-2°C =
|
||||
// 28.4°F, matching the reference tool's 28.4°F reading precisely at the
|
||||
// same instant (RPM cross-checked to confirm same-moment capture). The
|
||||
// previous offset (+2D hex = 45 decimal) gave physically impossible
|
||||
// values (~210°F) against the same live data. Table is tempXlt[raw]-40,
|
||||
// not +40: the spec's own sensor range (section 3: ECT/IAT -40..150 degC)
|
||||
// is only reachable with subtraction, since tempXlt entries span 0..190.
|
||||
final double ect = (tempXlt[frame[33]] - 40).toDouble();
|
||||
|
||||
// IAT: decimal offset 34, immediately after ECT. Confirmed via the same
|
||||
// live capture: raw=0xCA(202), tempXlt[202]=43, 43-40=3°C=37.4°F vs the
|
||||
// reference tool's 36.1°F — within ~1.3°F, consistent with table
|
||||
// granularity. The previous offset (+2E hex = 46 decimal) was part of the
|
||||
// same wrong-offset block as the old ECT location.
|
||||
final double iat = (tempXlt[frame[34]] - 40).toDouble();
|
||||
|
||||
// BAT: decimal offset 36. Confirmed via live capture: raw=0x79(121),
|
||||
// 121*26/270=11.66V vs the reference tool's 11.75V — within one raw unit
|
||||
// of quantization (26/270 V per LSB ≈ 0.096V). This is also where BAT
|
||||
// lived before an earlier, unverified "fix" in this project's history
|
||||
// moved it to +30 hex = 48 decimal — that fix was wrong; this restores
|
||||
// the original, now confirmed-correct, offset.
|
||||
final double bat = frame[36] * 26.0 / 270.0;
|
||||
|
||||
// ERR bytes: +31..+34 hex = 49..52 decimal
|
||||
final List<int> errBytes = [
|
||||
frame[0x31],
|
||||
frame[0x32],
|
||||
frame[0x33],
|
||||
frame[0x34],
|
||||
];
|
||||
|
||||
// Eth: +38 hex = 56 decimal
|
||||
final double eth = frame[0x38].toDouble();
|
||||
|
||||
// AFR: spec overlaps +34 with ERR03; reuse that byte per the spec's own table.
|
||||
final double afr = frame[0x34].toDouble();
|
||||
|
||||
// AIN0–AIN7: decimal offsets 82..97 (uint16 BE each)
|
||||
final double ain0 = _readUint16BE(frame, 82).toDouble();
|
||||
final double ain1 = _readUint16BE(frame, 84).toDouble();
|
||||
final double ain2 = _readUint16BE(frame, 86).toDouble();
|
||||
final double ain3 = _readUint16BE(frame, 88).toDouble();
|
||||
final double ain4 = _readUint16BE(frame, 90).toDouble();
|
||||
final double ain5 = _readUint16BE(frame, 92).toDouble();
|
||||
final double ain6 = _readUint16BE(frame, 94).toDouble();
|
||||
final double ain7 = _readUint16BE(frame, 96).toDouble();
|
||||
|
||||
// Flags
|
||||
// SW1 bit3 = REVL
|
||||
final bool revLimit = (sw1 & 0x08) != 0;
|
||||
// SW2 bit5 = MIL
|
||||
final bool mil = (sw2 & 0x20) != 0;
|
||||
// SW2 bit2 = Fuel — this is the fuel PUMP relay status, not fuel cut (the
|
||||
// reference tool's own channel list labels it as such). There's no
|
||||
// separate "fuel cut" bit identified in this frame — REVL/IGNC/BstC/
|
||||
// LnchC/ShftC are the actual cut-type flags this ECU exposes.
|
||||
//
|
||||
// Active-LOW: confirmed via an extended live session (RPM swept 400-3842,
|
||||
// continuously above the ~300 threshold where the pump should be on) that
|
||||
// this bit read 0 the entire time — and separately confirmed live that the
|
||||
// app showed "on" below ~200 rpm (pump should be off) and "off" above the
|
||||
// threshold (pump should be on), i.e. exactly inverted from a `!= 0` read.
|
||||
// Bit clear (0) means the pump relay is energized/on; bit set (1) means off.
|
||||
final bool fuelPump = (sw2 & 0x04) == 0;
|
||||
// SW2 bit7 = ALTC → fanOut; also SW5 bit0 = FANC
|
||||
final bool fanOut = (sw2 & 0x80) != 0 || (sw5 & 0x01) != 0;
|
||||
// SW2 bit1..0 = VTS (VTEC solenoid — nonzero = active)
|
||||
final bool vtec = (sw2 & 0x03) != 0;
|
||||
// Knock = KRtrd > 0 (byte 13)
|
||||
final bool knock = frame[13] > 0;
|
||||
// SW3 bit7 = LnchC
|
||||
final bool launch = (sw3 & 0x80) != 0;
|
||||
|
||||
return SensorState(
|
||||
rpm: rpm,
|
||||
vss: vss,
|
||||
map: map,
|
||||
tps: tps,
|
||||
inj: inj,
|
||||
ign: ign,
|
||||
ect: ect,
|
||||
iat: iat,
|
||||
bat: bat,
|
||||
o2: o2,
|
||||
gear: gear,
|
||||
eth: eth,
|
||||
pa: pa,
|
||||
afr: afr,
|
||||
strim: strim,
|
||||
ltrim: ltrim,
|
||||
ain0: ain0,
|
||||
ain1: ain1,
|
||||
ain2: ain2,
|
||||
ain3: ain3,
|
||||
ain4: ain4,
|
||||
ain5: ain5,
|
||||
ain6: ain6,
|
||||
ain7: ain7,
|
||||
mil: mil,
|
||||
fuelPump: fuelPump,
|
||||
fanOut: fanOut,
|
||||
vtec: vtec,
|
||||
knock: knock,
|
||||
revLimit: revLimit,
|
||||
launch: launch,
|
||||
errBytes: errBytes,
|
||||
timestamp: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
int _readUint16BE(Uint8List frame, int offset) {
|
||||
return (frame[offset] << 8) | frame[offset + 1];
|
||||
}
|
||||
|
||||
int _readUint16LE(Uint8List frame, int offset) {
|
||||
return frame[offset] | (frame[offset + 1] << 8);
|
||||
}
|
||||
|
||||
@ -1,184 +1,184 @@
|
||||
/// Fully decoded ECU sensor state. Immutable. Produced by S300 or KPro parser.
|
||||
class SensorState {
|
||||
final double rpm;
|
||||
final double vss;
|
||||
final double map;
|
||||
final double tps;
|
||||
final double inj;
|
||||
final double ign;
|
||||
final double ect;
|
||||
final double iat;
|
||||
final double bat;
|
||||
final double o2;
|
||||
final int gear;
|
||||
final double eth;
|
||||
final double pa;
|
||||
final double afr;
|
||||
final double strim;
|
||||
final double ltrim;
|
||||
// Analog inputs (AIN0–AIN7)
|
||||
final double ain0;
|
||||
final double ain1;
|
||||
final double ain2;
|
||||
final double ain3;
|
||||
final double ain4;
|
||||
final double ain5;
|
||||
final double ain6;
|
||||
final double ain7;
|
||||
// Boolean flags
|
||||
final bool mil;
|
||||
final bool fuelCut;
|
||||
final bool fanOut;
|
||||
final bool vtec;
|
||||
final bool knock;
|
||||
final bool revLimit;
|
||||
final bool launch;
|
||||
// Raw DTC bytes (ERR00..ERR17 for KPro, ERR00..ERR03 for S300)
|
||||
final List<int> errBytes;
|
||||
// Frame timestamp
|
||||
final DateTime timestamp;
|
||||
|
||||
const SensorState({
|
||||
required this.rpm,
|
||||
required this.vss,
|
||||
required this.map,
|
||||
required this.tps,
|
||||
required this.inj,
|
||||
required this.ign,
|
||||
required this.ect,
|
||||
required this.iat,
|
||||
required this.bat,
|
||||
required this.o2,
|
||||
required this.gear,
|
||||
required this.eth,
|
||||
required this.pa,
|
||||
required this.afr,
|
||||
required this.strim,
|
||||
required this.ltrim,
|
||||
required this.ain0,
|
||||
required this.ain1,
|
||||
required this.ain2,
|
||||
required this.ain3,
|
||||
required this.ain4,
|
||||
required this.ain5,
|
||||
required this.ain6,
|
||||
required this.ain7,
|
||||
required this.mil,
|
||||
required this.fuelCut,
|
||||
required this.fanOut,
|
||||
required this.vtec,
|
||||
required this.knock,
|
||||
required this.revLimit,
|
||||
required this.launch,
|
||||
required this.errBytes,
|
||||
required this.timestamp,
|
||||
});
|
||||
|
||||
factory SensorState.zero() => SensorState(
|
||||
rpm: 0,
|
||||
vss: 0,
|
||||
map: 0,
|
||||
tps: 0,
|
||||
inj: 0,
|
||||
ign: 0,
|
||||
ect: 0,
|
||||
iat: 0,
|
||||
bat: 0,
|
||||
o2: 0,
|
||||
gear: 0,
|
||||
eth: 0,
|
||||
pa: 0,
|
||||
afr: 0,
|
||||
strim: 0,
|
||||
ltrim: 0,
|
||||
ain0: 0,
|
||||
ain1: 0,
|
||||
ain2: 0,
|
||||
ain3: 0,
|
||||
ain4: 0,
|
||||
ain5: 0,
|
||||
ain6: 0,
|
||||
ain7: 0,
|
||||
mil: false,
|
||||
fuelCut: false,
|
||||
fanOut: false,
|
||||
vtec: false,
|
||||
knock: false,
|
||||
revLimit: false,
|
||||
launch: false,
|
||||
errBytes: const [],
|
||||
timestamp: DateTime(0),
|
||||
);
|
||||
|
||||
SensorState copyWith({
|
||||
double? rpm,
|
||||
double? vss,
|
||||
double? map,
|
||||
double? tps,
|
||||
double? inj,
|
||||
double? ign,
|
||||
double? ect,
|
||||
double? iat,
|
||||
double? bat,
|
||||
double? o2,
|
||||
int? gear,
|
||||
double? eth,
|
||||
double? pa,
|
||||
double? afr,
|
||||
double? strim,
|
||||
double? ltrim,
|
||||
double? ain0,
|
||||
double? ain1,
|
||||
double? ain2,
|
||||
double? ain3,
|
||||
double? ain4,
|
||||
double? ain5,
|
||||
double? ain6,
|
||||
double? ain7,
|
||||
bool? mil,
|
||||
bool? fuelCut,
|
||||
bool? fanOut,
|
||||
bool? vtec,
|
||||
bool? knock,
|
||||
bool? revLimit,
|
||||
bool? launch,
|
||||
List<int>? errBytes,
|
||||
DateTime? timestamp,
|
||||
}) {
|
||||
return SensorState(
|
||||
rpm: rpm ?? this.rpm,
|
||||
vss: vss ?? this.vss,
|
||||
map: map ?? this.map,
|
||||
tps: tps ?? this.tps,
|
||||
inj: inj ?? this.inj,
|
||||
ign: ign ?? this.ign,
|
||||
ect: ect ?? this.ect,
|
||||
iat: iat ?? this.iat,
|
||||
bat: bat ?? this.bat,
|
||||
o2: o2 ?? this.o2,
|
||||
gear: gear ?? this.gear,
|
||||
eth: eth ?? this.eth,
|
||||
pa: pa ?? this.pa,
|
||||
afr: afr ?? this.afr,
|
||||
strim: strim ?? this.strim,
|
||||
ltrim: ltrim ?? this.ltrim,
|
||||
ain0: ain0 ?? this.ain0,
|
||||
ain1: ain1 ?? this.ain1,
|
||||
ain2: ain2 ?? this.ain2,
|
||||
ain3: ain3 ?? this.ain3,
|
||||
ain4: ain4 ?? this.ain4,
|
||||
ain5: ain5 ?? this.ain5,
|
||||
ain6: ain6 ?? this.ain6,
|
||||
ain7: ain7 ?? this.ain7,
|
||||
mil: mil ?? this.mil,
|
||||
fuelCut: fuelCut ?? this.fuelCut,
|
||||
fanOut: fanOut ?? this.fanOut,
|
||||
vtec: vtec ?? this.vtec,
|
||||
knock: knock ?? this.knock,
|
||||
revLimit: revLimit ?? this.revLimit,
|
||||
launch: launch ?? this.launch,
|
||||
errBytes: errBytes ?? this.errBytes,
|
||||
timestamp: timestamp ?? this.timestamp,
|
||||
);
|
||||
}
|
||||
}
|
||||
/// Fully decoded ECU sensor state. Immutable. Produced by S300 or KPro parser.
|
||||
class SensorState {
|
||||
final double rpm;
|
||||
final double vss;
|
||||
final double map;
|
||||
final double tps;
|
||||
final double inj;
|
||||
final double ign;
|
||||
final double ect;
|
||||
final double iat;
|
||||
final double bat;
|
||||
final double o2;
|
||||
final int gear;
|
||||
final double eth;
|
||||
final double pa;
|
||||
final double afr;
|
||||
final double strim;
|
||||
final double ltrim;
|
||||
// Analog inputs (AIN0–AIN7)
|
||||
final double ain0;
|
||||
final double ain1;
|
||||
final double ain2;
|
||||
final double ain3;
|
||||
final double ain4;
|
||||
final double ain5;
|
||||
final double ain6;
|
||||
final double ain7;
|
||||
// Boolean flags
|
||||
final bool mil;
|
||||
final bool fuelPump;
|
||||
final bool fanOut;
|
||||
final bool vtec;
|
||||
final bool knock;
|
||||
final bool revLimit;
|
||||
final bool launch;
|
||||
// Raw DTC bytes (ERR00..ERR17 for KPro, ERR00..ERR03 for S300)
|
||||
final List<int> errBytes;
|
||||
// Frame timestamp
|
||||
final DateTime timestamp;
|
||||
|
||||
const SensorState({
|
||||
required this.rpm,
|
||||
required this.vss,
|
||||
required this.map,
|
||||
required this.tps,
|
||||
required this.inj,
|
||||
required this.ign,
|
||||
required this.ect,
|
||||
required this.iat,
|
||||
required this.bat,
|
||||
required this.o2,
|
||||
required this.gear,
|
||||
required this.eth,
|
||||
required this.pa,
|
||||
required this.afr,
|
||||
required this.strim,
|
||||
required this.ltrim,
|
||||
required this.ain0,
|
||||
required this.ain1,
|
||||
required this.ain2,
|
||||
required this.ain3,
|
||||
required this.ain4,
|
||||
required this.ain5,
|
||||
required this.ain6,
|
||||
required this.ain7,
|
||||
required this.mil,
|
||||
required this.fuelPump,
|
||||
required this.fanOut,
|
||||
required this.vtec,
|
||||
required this.knock,
|
||||
required this.revLimit,
|
||||
required this.launch,
|
||||
required this.errBytes,
|
||||
required this.timestamp,
|
||||
});
|
||||
|
||||
factory SensorState.zero() => SensorState(
|
||||
rpm: 0,
|
||||
vss: 0,
|
||||
map: 0,
|
||||
tps: 0,
|
||||
inj: 0,
|
||||
ign: 0,
|
||||
ect: 0,
|
||||
iat: 0,
|
||||
bat: 0,
|
||||
o2: 0,
|
||||
gear: 0,
|
||||
eth: 0,
|
||||
pa: 0,
|
||||
afr: 0,
|
||||
strim: 0,
|
||||
ltrim: 0,
|
||||
ain0: 0,
|
||||
ain1: 0,
|
||||
ain2: 0,
|
||||
ain3: 0,
|
||||
ain4: 0,
|
||||
ain5: 0,
|
||||
ain6: 0,
|
||||
ain7: 0,
|
||||
mil: false,
|
||||
fuelPump: false,
|
||||
fanOut: false,
|
||||
vtec: false,
|
||||
knock: false,
|
||||
revLimit: false,
|
||||
launch: false,
|
||||
errBytes: const [],
|
||||
timestamp: DateTime(0),
|
||||
);
|
||||
|
||||
SensorState copyWith({
|
||||
double? rpm,
|
||||
double? vss,
|
||||
double? map,
|
||||
double? tps,
|
||||
double? inj,
|
||||
double? ign,
|
||||
double? ect,
|
||||
double? iat,
|
||||
double? bat,
|
||||
double? o2,
|
||||
int? gear,
|
||||
double? eth,
|
||||
double? pa,
|
||||
double? afr,
|
||||
double? strim,
|
||||
double? ltrim,
|
||||
double? ain0,
|
||||
double? ain1,
|
||||
double? ain2,
|
||||
double? ain3,
|
||||
double? ain4,
|
||||
double? ain5,
|
||||
double? ain6,
|
||||
double? ain7,
|
||||
bool? mil,
|
||||
bool? fuelPump,
|
||||
bool? fanOut,
|
||||
bool? vtec,
|
||||
bool? knock,
|
||||
bool? revLimit,
|
||||
bool? launch,
|
||||
List<int>? errBytes,
|
||||
DateTime? timestamp,
|
||||
}) {
|
||||
return SensorState(
|
||||
rpm: rpm ?? this.rpm,
|
||||
vss: vss ?? this.vss,
|
||||
map: map ?? this.map,
|
||||
tps: tps ?? this.tps,
|
||||
inj: inj ?? this.inj,
|
||||
ign: ign ?? this.ign,
|
||||
ect: ect ?? this.ect,
|
||||
iat: iat ?? this.iat,
|
||||
bat: bat ?? this.bat,
|
||||
o2: o2 ?? this.o2,
|
||||
gear: gear ?? this.gear,
|
||||
eth: eth ?? this.eth,
|
||||
pa: pa ?? this.pa,
|
||||
afr: afr ?? this.afr,
|
||||
strim: strim ?? this.strim,
|
||||
ltrim: ltrim ?? this.ltrim,
|
||||
ain0: ain0 ?? this.ain0,
|
||||
ain1: ain1 ?? this.ain1,
|
||||
ain2: ain2 ?? this.ain2,
|
||||
ain3: ain3 ?? this.ain3,
|
||||
ain4: ain4 ?? this.ain4,
|
||||
ain5: ain5 ?? this.ain5,
|
||||
ain6: ain6 ?? this.ain6,
|
||||
ain7: ain7 ?? this.ain7,
|
||||
mil: mil ?? this.mil,
|
||||
fuelPump: fuelPump ?? this.fuelPump,
|
||||
fanOut: fanOut ?? this.fanOut,
|
||||
vtec: vtec ?? this.vtec,
|
||||
knock: knock ?? this.knock,
|
||||
revLimit: revLimit ?? this.revLimit,
|
||||
launch: launch ?? this.launch,
|
||||
errBytes: errBytes ?? this.errBytes,
|
||||
timestamp: timestamp ?? this.timestamp,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,22 +1,22 @@
|
||||
// Shared temperature lookup table for S300 and KPro ECU protocols.
|
||||
// Index = raw ECT/IAT byte value. Decoded temp = tempXlt[raw] - 40 (°C).
|
||||
// (Subtraction, not addition: table entries span 0..190, and only
|
||||
// subtracting 40 reaches the sensor's declared -40..150°C range.)
|
||||
const List<int> tempXlt = [
|
||||
190, 190, 188, 186, 183, 181, 179, 177, 175, 172, 170, 168, 166, 163, 161, 159,
|
||||
157, 155, 153, 151, 149, 146, 144, 142, 140, 138, 137, 136, 134, 133, 132, 130,
|
||||
129, 128, 127, 125, 124, 123, 122, 121, 121, 120, 119, 118, 117, 116, 115, 115,
|
||||
114, 113, 112, 111, 110, 110, 109, 109, 108, 107, 107, 106, 106, 105, 104, 104,
|
||||
103, 102, 102, 101, 101, 100, 99, 99, 98, 98, 97, 96, 96, 95, 95, 94,
|
||||
94, 93, 93, 93, 92, 92, 91, 91, 90, 90, 90, 89, 89, 88, 88, 87,
|
||||
87, 87, 86, 86, 85, 85, 85, 84, 84, 83, 83, 82, 82, 82, 81, 81,
|
||||
80, 80, 79, 79, 79, 78, 78, 77, 77, 76, 76, 76, 75, 75, 74, 74,
|
||||
73, 73, 73, 72, 72, 71, 71, 71, 70, 70, 69, 69, 68, 68, 68, 67,
|
||||
67, 66, 66, 66, 65, 65, 64, 64, 64, 63, 63, 62, 62, 61, 61, 60,
|
||||
60, 60, 59, 59, 58, 58, 58, 57, 57, 56, 56, 55, 55, 55, 54, 54,
|
||||
53, 53, 52, 52, 52, 51, 51, 50, 50, 49, 49, 49, 48, 48, 47, 47,
|
||||
47, 46, 46, 45, 45, 44, 44, 44, 43, 43, 42, 42, 41, 41, 40, 39,
|
||||
39, 39, 38, 37, 37, 36, 36, 35, 34, 34, 33, 33, 32, 31, 31, 30,
|
||||
30, 29, 28, 28, 27, 27, 26, 25, 24, 23, 21, 20, 19, 18, 17, 16,
|
||||
15, 14, 12, 11, 10, 9, 8, 7, 5, 4, 3, 1, 0, 0, 0, 0,
|
||||
];
|
||||
// Shared temperature lookup table for S300 and KPro ECU protocols.
|
||||
// Index = raw ECT/IAT byte value. Decoded temp = tempXlt[raw] - 40 (°C).
|
||||
// (Subtraction, not addition: table entries span 0..190, and only
|
||||
// subtracting 40 reaches the sensor's declared -40..150°C range.)
|
||||
const List<int> tempXlt = [
|
||||
190, 190, 188, 186, 183, 181, 179, 177, 175, 172, 170, 168, 166, 163, 161, 159,
|
||||
157, 155, 153, 151, 149, 146, 144, 142, 140, 138, 137, 136, 134, 133, 132, 130,
|
||||
129, 128, 127, 125, 124, 123, 122, 121, 121, 120, 119, 118, 117, 116, 115, 115,
|
||||
114, 113, 112, 111, 110, 110, 109, 109, 108, 107, 107, 106, 106, 105, 104, 104,
|
||||
103, 102, 102, 101, 101, 100, 99, 99, 98, 98, 97, 96, 96, 95, 95, 94,
|
||||
94, 93, 93, 93, 92, 92, 91, 91, 90, 90, 90, 89, 89, 88, 88, 87,
|
||||
87, 87, 86, 86, 85, 85, 85, 84, 84, 83, 83, 82, 82, 82, 81, 81,
|
||||
80, 80, 79, 79, 79, 78, 78, 77, 77, 76, 76, 76, 75, 75, 74, 74,
|
||||
73, 73, 73, 72, 72, 71, 71, 71, 70, 70, 69, 69, 68, 68, 68, 67,
|
||||
67, 66, 66, 66, 65, 65, 64, 64, 64, 63, 63, 62, 62, 61, 61, 60,
|
||||
60, 60, 59, 59, 58, 58, 58, 57, 57, 56, 56, 55, 55, 55, 54, 54,
|
||||
53, 53, 52, 52, 52, 51, 51, 50, 50, 49, 49, 49, 48, 48, 47, 47,
|
||||
47, 46, 46, 45, 45, 44, 44, 44, 43, 43, 42, 42, 41, 41, 40, 39,
|
||||
39, 39, 38, 37, 37, 36, 36, 35, 34, 34, 33, 33, 32, 31, 31, 30,
|
||||
30, 29, 28, 28, 27, 27, 26, 25, 24, 23, 21, 20, 19, 18, 17, 16,
|
||||
15, 14, 12, 11, 10, 9, 8, 7, 5, 4, 3, 1, 0, 0, 0, 0,
|
||||
];
|
||||
|
||||
@ -1,149 +1,236 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../bluetooth/bt_poller.dart';
|
||||
import '../bluetooth/bt_service.dart';
|
||||
import 'settings_provider.dart';
|
||||
|
||||
// ─── Connection status enum ──────────────────────────────────────────────────
|
||||
|
||||
enum BtStatus { disconnected, connecting, connected, error }
|
||||
|
||||
// ─── State ───────────────────────────────────────────────────────────────────
|
||||
|
||||
class BtState {
|
||||
final BtStatus status;
|
||||
final String? deviceName;
|
||||
final String? error;
|
||||
final int frameCount;
|
||||
final int droppedFrames;
|
||||
final Uint8List? lastFrame;
|
||||
|
||||
const BtState({
|
||||
required this.status,
|
||||
this.deviceName,
|
||||
this.error,
|
||||
this.frameCount = 0,
|
||||
this.droppedFrames = 0,
|
||||
this.lastFrame,
|
||||
});
|
||||
|
||||
const BtState.disconnected() : this(status: BtStatus.disconnected);
|
||||
|
||||
BtState connecting(String name) =>
|
||||
BtState(status: BtStatus.connecting, deviceName: name);
|
||||
|
||||
BtState connected(String name) =>
|
||||
BtState(status: BtStatus.connected, deviceName: name);
|
||||
|
||||
BtState withError(String msg) => BtState(
|
||||
status: BtStatus.error,
|
||||
deviceName: deviceName,
|
||||
error: msg,
|
||||
frameCount: frameCount,
|
||||
droppedFrames: droppedFrames,
|
||||
lastFrame: lastFrame,
|
||||
);
|
||||
|
||||
BtState withFrameStats(int frames, int dropped, Uint8List frame) => BtState(
|
||||
status: status,
|
||||
deviceName: deviceName,
|
||||
frameCount: frames,
|
||||
droppedFrames: dropped,
|
||||
lastFrame: Uint8List.fromList(frame),
|
||||
);
|
||||
|
||||
bool get isConnected => status == BtStatus.connected;
|
||||
bool get isConnecting => status == BtStatus.connecting;
|
||||
}
|
||||
|
||||
// ─── Notifier ────────────────────────────────────────────────────────────────
|
||||
|
||||
class BtNotifier extends StateNotifier<BtState> {
|
||||
final Ref _ref;
|
||||
BtPoller? _poller;
|
||||
StreamSubscription<Uint8List>? _pollSub;
|
||||
final StreamController<Uint8List> _frameController =
|
||||
StreamController<Uint8List>.broadcast();
|
||||
|
||||
BtNotifier(this._ref) : super(const BtState.disconnected());
|
||||
|
||||
/// Raw validated 128-byte frame stream — consumed by sensorStateProvider.
|
||||
Stream<Uint8List> get frameStream => _frameController.stream;
|
||||
|
||||
Future<void> connect(BluetoothDevice device) async {
|
||||
state = state.connecting(device.name ?? device.address);
|
||||
final service = _ref.read(btServiceProvider);
|
||||
final settings = _ref.read(settingsProvider);
|
||||
|
||||
try {
|
||||
await service.connect(device);
|
||||
|
||||
_poller = BtPoller(
|
||||
service,
|
||||
ecuType: settings.ecuType,
|
||||
pollingInterval: settings.pollingInterval,
|
||||
);
|
||||
|
||||
_pollSub = _poller!.frameStream.listen(
|
||||
(frame) {
|
||||
_frameController.add(frame);
|
||||
state = state.withFrameStats(
|
||||
_poller!.validFrames,
|
||||
_poller!.droppedFrames,
|
||||
frame,
|
||||
);
|
||||
},
|
||||
onError: (Object e) {
|
||||
_frameController.addError(e);
|
||||
state = state.withError(e.toString());
|
||||
},
|
||||
onDone: () => state = state.withError('BT stream closed'),
|
||||
);
|
||||
|
||||
_poller!.start();
|
||||
state = state.connected(device.name ?? device.address);
|
||||
} catch (e) {
|
||||
await service.disconnect();
|
||||
state = state.withError(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
await _pollSub?.cancel();
|
||||
_poller?.stop();
|
||||
_poller?.dispose();
|
||||
_poller = null;
|
||||
await _ref.read(btServiceProvider).disconnect();
|
||||
state = const BtState.disconnected();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pollSub?.cancel();
|
||||
_poller?.dispose();
|
||||
_frameController.close();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Providers ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Singleton BtService — lives as long as the app.
|
||||
final btServiceProvider = Provider<BtService>((ref) {
|
||||
final service = BtService();
|
||||
ref.onDispose(service.dispose);
|
||||
return service;
|
||||
});
|
||||
|
||||
/// BT connection state notifier.
|
||||
final btProvider =
|
||||
StateNotifierProvider<BtNotifier, BtState>((ref) => BtNotifier(ref));
|
||||
|
||||
/// Paired BT devices list (re-fetchable via ref.refresh).
|
||||
final pairedDevicesProvider = FutureProvider<List<BluetoothDevice>>((ref) {
|
||||
return FlutterBluetoothSerial.instance.getBondedDevices();
|
||||
});
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../bluetooth/bt_poller.dart';
|
||||
import '../bluetooth/bt_service.dart';
|
||||
import '../protocol/neg8.dart';
|
||||
import 'settings_provider.dart';
|
||||
|
||||
// ─── Connection status enum ──────────────────────────────────────────────────
|
||||
|
||||
enum BtStatus { disconnected, connecting, connected, error }
|
||||
|
||||
// ─── State ───────────────────────────────────────────────────────────────────
|
||||
|
||||
class BtState {
|
||||
final BtStatus status;
|
||||
final String? deviceName;
|
||||
final String? error;
|
||||
final int frameCount;
|
||||
final int droppedFrames;
|
||||
final Uint8List? lastFrame;
|
||||
|
||||
const BtState({
|
||||
required this.status,
|
||||
this.deviceName,
|
||||
this.error,
|
||||
this.frameCount = 0,
|
||||
this.droppedFrames = 0,
|
||||
this.lastFrame,
|
||||
});
|
||||
|
||||
const BtState.disconnected() : this(status: BtStatus.disconnected);
|
||||
|
||||
BtState connecting(String name) =>
|
||||
BtState(status: BtStatus.connecting, deviceName: name);
|
||||
|
||||
BtState connected(String name) =>
|
||||
BtState(status: BtStatus.connected, deviceName: name);
|
||||
|
||||
BtState withError(String msg) => BtState(
|
||||
status: BtStatus.error,
|
||||
deviceName: deviceName,
|
||||
error: msg,
|
||||
frameCount: frameCount,
|
||||
droppedFrames: droppedFrames,
|
||||
lastFrame: lastFrame,
|
||||
);
|
||||
|
||||
BtState withFrameStats(int frames, int dropped, Uint8List frame) => BtState(
|
||||
status: status,
|
||||
deviceName: deviceName,
|
||||
frameCount: frames,
|
||||
droppedFrames: dropped,
|
||||
lastFrame: Uint8List.fromList(frame),
|
||||
);
|
||||
|
||||
bool get isConnected => status == BtStatus.connected;
|
||||
bool get isConnecting => status == BtStatus.connecting;
|
||||
}
|
||||
|
||||
// ─── Notifier ────────────────────────────────────────────────────────────────
|
||||
|
||||
class BtNotifier extends StateNotifier<BtState> {
|
||||
final Ref _ref;
|
||||
BtPoller? _poller;
|
||||
StreamSubscription<Uint8List>? _pollSub;
|
||||
final StreamController<Uint8List> _frameController =
|
||||
StreamController<Uint8List>.broadcast();
|
||||
|
||||
BtNotifier(this._ref) : super(const BtState.disconnected());
|
||||
|
||||
/// Raw validated 128-byte frame stream — consumed by sensorStateProvider.
|
||||
Stream<Uint8List> get frameStream => _frameController.stream;
|
||||
|
||||
Future<void> connect(BluetoothDevice device) async {
|
||||
state = state.connecting(device.name ?? device.address);
|
||||
final service = _ref.read(btServiceProvider);
|
||||
final settings = _ref.read(settingsProvider);
|
||||
|
||||
try {
|
||||
await service.connect(device);
|
||||
|
||||
_poller = BtPoller(
|
||||
service,
|
||||
ecuType: settings.ecuType,
|
||||
pollingInterval: settings.pollingInterval,
|
||||
);
|
||||
|
||||
_pollSub = _poller!.frameStream.listen(
|
||||
(frame) {
|
||||
_frameController.add(frame);
|
||||
state = state.withFrameStats(
|
||||
_poller!.validFrames,
|
||||
_poller!.droppedFrames,
|
||||
frame,
|
||||
);
|
||||
},
|
||||
onError: (Object e) {
|
||||
_frameController.addError(e);
|
||||
state = state.withError(e.toString());
|
||||
},
|
||||
onDone: () => state = state.withError('BT stream closed'),
|
||||
);
|
||||
|
||||
_poller!.start();
|
||||
state = state.connected(device.name ?? device.address);
|
||||
} catch (e) {
|
||||
await service.disconnect();
|
||||
state = state.withError(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// One-off diagnostic: sends the official Hondata Bluetooth protocol's
|
||||
/// DL_DeviceInfo command ({ Protocol=0x00, ID=0x02, Size=0x0004 LE }) and
|
||||
/// returns whatever raw bytes come back within the response window. Used
|
||||
/// to check whether this device/firmware understands the newer,
|
||||
/// self-describing channel-based protocol (Bluetooth.pdf) as opposed to
|
||||
/// only the legacy fixed-offset frame format this app currently speaks.
|
||||
/// Pauses the legacy poller for the duration so its own request/response
|
||||
/// traffic doesn't interleave with the probe's reply on the same link.
|
||||
Future<Uint8List> probeOfficialProtocol() async {
|
||||
final service = _ref.read(btServiceProvider);
|
||||
if (!service.isConnected) return Uint8List(0);
|
||||
|
||||
_poller?.stop();
|
||||
|
||||
// stop() only cancels the poller's own timer and listener — it can't
|
||||
// un-send a legacy request the poller already wrote moments earlier.
|
||||
// That request's 128-byte reply is very likely still in flight, so
|
||||
// drain and discard whatever arrives here before sending the probe, or
|
||||
// that leftover legacy frame gets mistaken for the probe's response.
|
||||
final drainSub = service.rawStream.listen((_) {});
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
await drainSub.cancel();
|
||||
|
||||
final collected = <int>[];
|
||||
final sub = service.rawStream.listen((chunk) => collected.addAll(chunk));
|
||||
|
||||
try {
|
||||
await service.write(Uint8List.fromList([0x00, 0x02, 0x04, 0x00]));
|
||||
await Future.delayed(const Duration(milliseconds: 800));
|
||||
} finally {
|
||||
await sub.cancel();
|
||||
_poller?.start();
|
||||
}
|
||||
|
||||
return Uint8List.fromList(collected);
|
||||
}
|
||||
|
||||
/// Sends the DTC-reset command from the legacy protocol notes
|
||||
/// (hvbt_protocol.pdf): { 0x1B, 0x70, NEG8 }, NEG8 over the first two
|
||||
/// bytes. The ECU echoes the same 3 bytes back to acknowledge.
|
||||
///
|
||||
/// Deliberately does NOT pause the poller first (unlike
|
||||
/// probeOfficialProtocol()) — pausing-then-sending got zero response both
|
||||
/// here and for the official-protocol probe, while the regular poll cycle
|
||||
/// always gets answered. One untested explanation: this adapter may only
|
||||
/// answer additional commands while it keeps seeing the normal poll
|
||||
/// traffic, not when that traffic stops. So this injects the command
|
||||
/// alongside ongoing polling and scans the stream for the 3-byte ack
|
||||
/// appearing anywhere in it — it'll be interleaved with regular 128-byte
|
||||
/// datalog frames rather than arriving in isolation — over several poll
|
||||
/// cycles' worth of time, in case the reply is delayed by an EEPROM/flash
|
||||
/// write on the ECU rather than a simple RAM read.
|
||||
Future<({bool acknowledged, Uint8List raw})> clearDtcs() async {
|
||||
final service = _ref.read(btServiceProvider);
|
||||
if (!service.isConnected) return (acknowledged: false, raw: Uint8List(0));
|
||||
|
||||
final command = Uint8List.fromList([0x1B, 0x70, 0x00]);
|
||||
command[2] = calculateNeg8(Uint8List.fromList([0x1B, 0x70]));
|
||||
|
||||
final collected = <int>[];
|
||||
final sub = service.rawStream.listen((chunk) => collected.addAll(chunk));
|
||||
|
||||
bool acknowledged = false;
|
||||
try {
|
||||
await service.write(command);
|
||||
final deadline = DateTime.now().add(const Duration(seconds: 3));
|
||||
while (DateTime.now().isBefore(deadline) && !acknowledged) {
|
||||
await Future.delayed(const Duration(milliseconds: 150));
|
||||
acknowledged = _containsAck(collected);
|
||||
}
|
||||
} finally {
|
||||
await sub.cancel();
|
||||
}
|
||||
|
||||
return (acknowledged: acknowledged, raw: Uint8List.fromList(collected));
|
||||
}
|
||||
|
||||
static bool _containsAck(List<int> bytes) {
|
||||
for (var i = 0; i + 2 < bytes.length; i++) {
|
||||
if (bytes[i] == 0x1B && bytes[i + 1] == 0x70 && bytes[i + 2] == 0x75) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
await _pollSub?.cancel();
|
||||
_poller?.stop();
|
||||
_poller?.dispose();
|
||||
_poller = null;
|
||||
await _ref.read(btServiceProvider).disconnect();
|
||||
state = const BtState.disconnected();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pollSub?.cancel();
|
||||
_poller?.dispose();
|
||||
_frameController.close();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Providers ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Singleton BtService — lives as long as the app.
|
||||
final btServiceProvider = Provider<BtService>((ref) {
|
||||
final service = BtService();
|
||||
ref.onDispose(service.dispose);
|
||||
return service;
|
||||
});
|
||||
|
||||
/// BT connection state notifier.
|
||||
final btProvider =
|
||||
StateNotifierProvider<BtNotifier, BtState>((ref) => BtNotifier(ref));
|
||||
|
||||
/// Paired BT devices list (re-fetchable via ref.refresh).
|
||||
final pairedDevicesProvider = FutureProvider<List<BluetoothDevice>>((ref) {
|
||||
return FlutterBluetoothSerial.instance.getBondedDevices();
|
||||
});
|
||||
|
||||
@ -1,206 +1,206 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../datalog/datalog_db.dart';
|
||||
import '../datalog/datalog_player.dart';
|
||||
import '../datalog/datalog_recorder.dart';
|
||||
import '../models/datalog_session.dart';
|
||||
import 'bt_provider.dart';
|
||||
import 'settings_provider.dart';
|
||||
|
||||
// ─── Singletons ───────────────────────────────────────────────────────────────
|
||||
|
||||
final datalogDbProvider = Provider<DatalogDb>((ref) {
|
||||
final db = DatalogDb();
|
||||
ref.onDispose(db.close);
|
||||
return db;
|
||||
});
|
||||
|
||||
final datalogRecorderProvider = Provider<DatalogRecorder>((ref) {
|
||||
final recorder = DatalogRecorder(ref.watch(datalogDbProvider));
|
||||
ref.onDispose(recorder.dispose);
|
||||
return recorder;
|
||||
});
|
||||
|
||||
final datalogPlayerProvider = Provider<DatalogPlayer>((ref) {
|
||||
final player = DatalogPlayer(ref.watch(datalogDbProvider));
|
||||
ref.onDispose(player.dispose);
|
||||
return player;
|
||||
});
|
||||
|
||||
// ─── Recording state ──────────────────────────────────────────────────────────
|
||||
|
||||
enum RecordingStatus { idle, recording }
|
||||
|
||||
class RecordingState {
|
||||
final RecordingStatus status;
|
||||
final int frameCount;
|
||||
final int? sessionId;
|
||||
|
||||
const RecordingState({
|
||||
this.status = RecordingStatus.idle,
|
||||
this.frameCount = 0,
|
||||
this.sessionId,
|
||||
});
|
||||
|
||||
bool get isRecording => status == RecordingStatus.recording;
|
||||
|
||||
RecordingState copyWith({
|
||||
RecordingStatus? status,
|
||||
int? frameCount,
|
||||
int? sessionId,
|
||||
}) =>
|
||||
RecordingState(
|
||||
status: status ?? this.status,
|
||||
frameCount: frameCount ?? this.frameCount,
|
||||
sessionId: sessionId ?? this.sessionId,
|
||||
);
|
||||
}
|
||||
|
||||
class RecordingNotifier extends StateNotifier<RecordingState> {
|
||||
final Ref _ref;
|
||||
|
||||
RecordingNotifier(this._ref) : super(const RecordingState());
|
||||
|
||||
Future<void> startRecording() async {
|
||||
if (state.isRecording) return;
|
||||
|
||||
final recorder = _ref.read(datalogRecorderProvider);
|
||||
final btNotifier = _ref.read(btProvider.notifier);
|
||||
final ecuType = _ref.read(settingsProvider).ecuType.name;
|
||||
|
||||
await recorder.start(btNotifier.frameStream, ecuType);
|
||||
|
||||
state = RecordingState(
|
||||
status: RecordingStatus.recording,
|
||||
sessionId: recorder.sessionId,
|
||||
);
|
||||
}
|
||||
|
||||
Future<DatalogSession?> stopRecording() async {
|
||||
if (!state.isRecording) return null;
|
||||
|
||||
final recorder = _ref.read(datalogRecorderProvider);
|
||||
final session = await recorder.stop();
|
||||
|
||||
state = const RecordingState();
|
||||
// Refresh session list
|
||||
_ref.invalidate(sessionListProvider);
|
||||
return session;
|
||||
}
|
||||
|
||||
/// Called periodically from UI to update live frame count display.
|
||||
void syncFrameCount() {
|
||||
if (!state.isRecording) return;
|
||||
final count = _ref.read(datalogRecorderProvider).frameCount;
|
||||
if (count != state.frameCount) {
|
||||
state = state.copyWith(frameCount: count);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final recordingProvider =
|
||||
StateNotifierProvider<RecordingNotifier, RecordingState>(
|
||||
(ref) => RecordingNotifier(ref),
|
||||
);
|
||||
|
||||
// ─── Session list ─────────────────────────────────────────────────────────────
|
||||
|
||||
final sessionListProvider = FutureProvider<List<DatalogSession>>((ref) {
|
||||
return ref.watch(datalogDbProvider).getSessions();
|
||||
});
|
||||
|
||||
// ─── Playback state ───────────────────────────────────────────────────────────
|
||||
|
||||
class PlaybackState {
|
||||
final bool isActive;
|
||||
final int? sessionId;
|
||||
final int position;
|
||||
final int total;
|
||||
final double speed;
|
||||
final PlaybackStatus status;
|
||||
|
||||
const PlaybackState({
|
||||
this.isActive = false,
|
||||
this.sessionId,
|
||||
this.position = 0,
|
||||
this.total = 0,
|
||||
this.speed = 1.0,
|
||||
this.status = PlaybackStatus.idle,
|
||||
});
|
||||
|
||||
double get progress => total == 0 ? 0 : position / total;
|
||||
|
||||
PlaybackState copyWith({
|
||||
bool? isActive,
|
||||
int? sessionId,
|
||||
int? position,
|
||||
int? total,
|
||||
double? speed,
|
||||
PlaybackStatus? status,
|
||||
}) =>
|
||||
PlaybackState(
|
||||
isActive: isActive ?? this.isActive,
|
||||
sessionId: sessionId ?? this.sessionId,
|
||||
position: position ?? this.position,
|
||||
total: total ?? this.total,
|
||||
speed: speed ?? this.speed,
|
||||
status: status ?? this.status,
|
||||
);
|
||||
}
|
||||
|
||||
class PlaybackNotifier extends StateNotifier<PlaybackState> {
|
||||
final Ref _ref;
|
||||
|
||||
PlaybackNotifier(this._ref) : super(const PlaybackState());
|
||||
|
||||
Future<void> loadSession(int sessionId) async {
|
||||
final player = _ref.read(datalogPlayerProvider);
|
||||
await player.load(sessionId);
|
||||
|
||||
player.onProgress = (pos, total) {
|
||||
state = state.copyWith(
|
||||
position: pos,
|
||||
total: total,
|
||||
status: player.status,
|
||||
);
|
||||
};
|
||||
|
||||
state = PlaybackState(
|
||||
isActive: true,
|
||||
sessionId: sessionId,
|
||||
total: player.totalFrames,
|
||||
speed: player.speed,
|
||||
status: PlaybackStatus.idle,
|
||||
);
|
||||
}
|
||||
|
||||
void play() {
|
||||
_ref.read(datalogPlayerProvider).play();
|
||||
state = state.copyWith(status: PlaybackStatus.playing);
|
||||
}
|
||||
|
||||
void pause() {
|
||||
_ref.read(datalogPlayerProvider).pause();
|
||||
state = state.copyWith(status: PlaybackStatus.paused);
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
await _ref.read(datalogPlayerProvider).stop();
|
||||
state = const PlaybackState();
|
||||
}
|
||||
|
||||
void seek(int index) {
|
||||
_ref.read(datalogPlayerProvider).seek(index);
|
||||
state = state.copyWith(position: index);
|
||||
}
|
||||
|
||||
void setSpeed(double speed) {
|
||||
_ref.read(datalogPlayerProvider).speed = speed;
|
||||
state = state.copyWith(speed: speed);
|
||||
}
|
||||
}
|
||||
|
||||
final playbackProvider =
|
||||
StateNotifierProvider<PlaybackNotifier, PlaybackState>(
|
||||
(ref) => PlaybackNotifier(ref),
|
||||
);
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../datalog/datalog_db.dart';
|
||||
import '../datalog/datalog_player.dart';
|
||||
import '../datalog/datalog_recorder.dart';
|
||||
import '../models/datalog_session.dart';
|
||||
import 'bt_provider.dart';
|
||||
import 'settings_provider.dart';
|
||||
|
||||
// ─── Singletons ───────────────────────────────────────────────────────────────
|
||||
|
||||
final datalogDbProvider = Provider<DatalogDb>((ref) {
|
||||
final db = DatalogDb();
|
||||
ref.onDispose(db.close);
|
||||
return db;
|
||||
});
|
||||
|
||||
final datalogRecorderProvider = Provider<DatalogRecorder>((ref) {
|
||||
final recorder = DatalogRecorder(ref.watch(datalogDbProvider));
|
||||
ref.onDispose(recorder.dispose);
|
||||
return recorder;
|
||||
});
|
||||
|
||||
final datalogPlayerProvider = Provider<DatalogPlayer>((ref) {
|
||||
final player = DatalogPlayer(ref.watch(datalogDbProvider));
|
||||
ref.onDispose(player.dispose);
|
||||
return player;
|
||||
});
|
||||
|
||||
// ─── Recording state ──────────────────────────────────────────────────────────
|
||||
|
||||
enum RecordingStatus { idle, recording }
|
||||
|
||||
class RecordingState {
|
||||
final RecordingStatus status;
|
||||
final int frameCount;
|
||||
final int? sessionId;
|
||||
|
||||
const RecordingState({
|
||||
this.status = RecordingStatus.idle,
|
||||
this.frameCount = 0,
|
||||
this.sessionId,
|
||||
});
|
||||
|
||||
bool get isRecording => status == RecordingStatus.recording;
|
||||
|
||||
RecordingState copyWith({
|
||||
RecordingStatus? status,
|
||||
int? frameCount,
|
||||
int? sessionId,
|
||||
}) =>
|
||||
RecordingState(
|
||||
status: status ?? this.status,
|
||||
frameCount: frameCount ?? this.frameCount,
|
||||
sessionId: sessionId ?? this.sessionId,
|
||||
);
|
||||
}
|
||||
|
||||
class RecordingNotifier extends StateNotifier<RecordingState> {
|
||||
final Ref _ref;
|
||||
|
||||
RecordingNotifier(this._ref) : super(const RecordingState());
|
||||
|
||||
Future<void> startRecording() async {
|
||||
if (state.isRecording) return;
|
||||
|
||||
final recorder = _ref.read(datalogRecorderProvider);
|
||||
final btNotifier = _ref.read(btProvider.notifier);
|
||||
final ecuType = _ref.read(settingsProvider).ecuType.name;
|
||||
|
||||
await recorder.start(btNotifier.frameStream, ecuType);
|
||||
|
||||
state = RecordingState(
|
||||
status: RecordingStatus.recording,
|
||||
sessionId: recorder.sessionId,
|
||||
);
|
||||
}
|
||||
|
||||
Future<DatalogSession?> stopRecording() async {
|
||||
if (!state.isRecording) return null;
|
||||
|
||||
final recorder = _ref.read(datalogRecorderProvider);
|
||||
final session = await recorder.stop();
|
||||
|
||||
state = const RecordingState();
|
||||
// Refresh session list
|
||||
_ref.invalidate(sessionListProvider);
|
||||
return session;
|
||||
}
|
||||
|
||||
/// Called periodically from UI to update live frame count display.
|
||||
void syncFrameCount() {
|
||||
if (!state.isRecording) return;
|
||||
final count = _ref.read(datalogRecorderProvider).frameCount;
|
||||
if (count != state.frameCount) {
|
||||
state = state.copyWith(frameCount: count);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final recordingProvider =
|
||||
StateNotifierProvider<RecordingNotifier, RecordingState>(
|
||||
(ref) => RecordingNotifier(ref),
|
||||
);
|
||||
|
||||
// ─── Session list ─────────────────────────────────────────────────────────────
|
||||
|
||||
final sessionListProvider = FutureProvider<List<DatalogSession>>((ref) {
|
||||
return ref.watch(datalogDbProvider).getSessions();
|
||||
});
|
||||
|
||||
// ─── Playback state ───────────────────────────────────────────────────────────
|
||||
|
||||
class PlaybackState {
|
||||
final bool isActive;
|
||||
final int? sessionId;
|
||||
final int position;
|
||||
final int total;
|
||||
final double speed;
|
||||
final PlaybackStatus status;
|
||||
|
||||
const PlaybackState({
|
||||
this.isActive = false,
|
||||
this.sessionId,
|
||||
this.position = 0,
|
||||
this.total = 0,
|
||||
this.speed = 1.0,
|
||||
this.status = PlaybackStatus.idle,
|
||||
});
|
||||
|
||||
double get progress => total == 0 ? 0 : position / total;
|
||||
|
||||
PlaybackState copyWith({
|
||||
bool? isActive,
|
||||
int? sessionId,
|
||||
int? position,
|
||||
int? total,
|
||||
double? speed,
|
||||
PlaybackStatus? status,
|
||||
}) =>
|
||||
PlaybackState(
|
||||
isActive: isActive ?? this.isActive,
|
||||
sessionId: sessionId ?? this.sessionId,
|
||||
position: position ?? this.position,
|
||||
total: total ?? this.total,
|
||||
speed: speed ?? this.speed,
|
||||
status: status ?? this.status,
|
||||
);
|
||||
}
|
||||
|
||||
class PlaybackNotifier extends StateNotifier<PlaybackState> {
|
||||
final Ref _ref;
|
||||
|
||||
PlaybackNotifier(this._ref) : super(const PlaybackState());
|
||||
|
||||
Future<void> loadSession(int sessionId) async {
|
||||
final player = _ref.read(datalogPlayerProvider);
|
||||
await player.load(sessionId);
|
||||
|
||||
player.onProgress = (pos, total) {
|
||||
state = state.copyWith(
|
||||
position: pos,
|
||||
total: total,
|
||||
status: player.status,
|
||||
);
|
||||
};
|
||||
|
||||
state = PlaybackState(
|
||||
isActive: true,
|
||||
sessionId: sessionId,
|
||||
total: player.totalFrames,
|
||||
speed: player.speed,
|
||||
status: PlaybackStatus.idle,
|
||||
);
|
||||
}
|
||||
|
||||
void play() {
|
||||
_ref.read(datalogPlayerProvider).play();
|
||||
state = state.copyWith(status: PlaybackStatus.playing);
|
||||
}
|
||||
|
||||
void pause() {
|
||||
_ref.read(datalogPlayerProvider).pause();
|
||||
state = state.copyWith(status: PlaybackStatus.paused);
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
await _ref.read(datalogPlayerProvider).stop();
|
||||
state = const PlaybackState();
|
||||
}
|
||||
|
||||
void seek(int index) {
|
||||
_ref.read(datalogPlayerProvider).seek(index);
|
||||
state = state.copyWith(position: index);
|
||||
}
|
||||
|
||||
void setSpeed(double speed) {
|
||||
_ref.read(datalogPlayerProvider).speed = speed;
|
||||
state = state.copyWith(speed: speed);
|
||||
}
|
||||
}
|
||||
|
||||
final playbackProvider =
|
||||
StateNotifierProvider<PlaybackNotifier, PlaybackState>(
|
||||
(ref) => PlaybackNotifier(ref),
|
||||
);
|
||||
|
||||
@ -1,88 +1,88 @@
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../models/sensor_defs.dart';
|
||||
import '../protocol/sensor_state.dart';
|
||||
import 'sensor_provider.dart';
|
||||
|
||||
const _maxPoints = 18000;
|
||||
|
||||
class SensorHistory {
|
||||
final Map<String, List<FlSpot>> _data = {
|
||||
for (final d in sensorDefs) d.id: [],
|
||||
};
|
||||
double? _t0;
|
||||
|
||||
void add(SensorState state) {
|
||||
final tSec = state.timestamp.millisecondsSinceEpoch / 1000.0;
|
||||
_t0 ??= tSec;
|
||||
final x = tSec - _t0!;
|
||||
|
||||
void push(String id, double v) {
|
||||
final list = _data[id]!;
|
||||
list.add(FlSpot(x, v));
|
||||
if (list.length > _maxPoints) list.removeAt(0);
|
||||
}
|
||||
|
||||
push('rpm', state.rpm);
|
||||
push('vss', state.vss);
|
||||
push('map', state.map);
|
||||
push('tps', state.tps);
|
||||
push('inj', state.inj);
|
||||
push('ign', state.ign);
|
||||
push('ect', state.ect);
|
||||
push('iat', state.iat);
|
||||
push('bat', state.bat);
|
||||
push('o2', state.o2);
|
||||
push('gear', state.gear.toDouble());
|
||||
push('eth', state.eth);
|
||||
push('pa', state.pa);
|
||||
push('afr', state.afr);
|
||||
push('strim', state.strim);
|
||||
push('ltrim', state.ltrim);
|
||||
push('ain0', state.ain0);
|
||||
push('ain1', state.ain1);
|
||||
push('ain2', state.ain2);
|
||||
push('ain3', state.ain3);
|
||||
push('ain4', state.ain4);
|
||||
push('ain5', state.ain5);
|
||||
push('ain6', state.ain6);
|
||||
push('ain7', state.ain7);
|
||||
}
|
||||
|
||||
List<FlSpot> get(String id) => List.unmodifiable(_data[id] ?? []);
|
||||
|
||||
void clear() {
|
||||
for (final list in _data.values) {
|
||||
list.clear();
|
||||
}
|
||||
_t0 = null;
|
||||
}
|
||||
}
|
||||
|
||||
class SensorHistoryNotifier extends StateNotifier<SensorHistory> {
|
||||
SensorHistoryNotifier() : super(SensorHistory());
|
||||
|
||||
void addState(SensorState s) {
|
||||
state.add(s);
|
||||
// trigger rebuild by reassigning same object
|
||||
// ignore: invalid_use_of_protected_member
|
||||
state = state;
|
||||
}
|
||||
|
||||
void clear() {
|
||||
state.clear();
|
||||
state = SensorHistory();
|
||||
}
|
||||
}
|
||||
|
||||
final sensorHistoryProvider =
|
||||
StateNotifierProvider<SensorHistoryNotifier, SensorHistory>((ref) {
|
||||
final notifier = SensorHistoryNotifier();
|
||||
|
||||
// Feed every parsed sensor state into history
|
||||
ref.listen<AsyncValue<SensorState>>(sensorStateProvider, (_, next) {
|
||||
next.whenData(notifier.addState);
|
||||
});
|
||||
|
||||
return notifier;
|
||||
});
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../models/sensor_defs.dart';
|
||||
import '../protocol/sensor_state.dart';
|
||||
import 'sensor_provider.dart';
|
||||
|
||||
const _maxPoints = 18000;
|
||||
|
||||
class SensorHistory {
|
||||
final Map<String, List<FlSpot>> _data = {
|
||||
for (final d in sensorDefs) d.id: [],
|
||||
};
|
||||
double? _t0;
|
||||
|
||||
void add(SensorState state) {
|
||||
final tSec = state.timestamp.millisecondsSinceEpoch / 1000.0;
|
||||
_t0 ??= tSec;
|
||||
final x = tSec - _t0!;
|
||||
|
||||
void push(String id, double v) {
|
||||
final list = _data[id]!;
|
||||
list.add(FlSpot(x, v));
|
||||
if (list.length > _maxPoints) list.removeAt(0);
|
||||
}
|
||||
|
||||
push('rpm', state.rpm);
|
||||
push('vss', state.vss);
|
||||
push('map', state.map);
|
||||
push('tps', state.tps);
|
||||
push('inj', state.inj);
|
||||
push('ign', state.ign);
|
||||
push('ect', state.ect);
|
||||
push('iat', state.iat);
|
||||
push('bat', state.bat);
|
||||
push('o2', state.o2);
|
||||
push('gear', state.gear.toDouble());
|
||||
push('eth', state.eth);
|
||||
push('pa', state.pa);
|
||||
push('afr', state.afr);
|
||||
push('strim', state.strim);
|
||||
push('ltrim', state.ltrim);
|
||||
push('ain0', state.ain0);
|
||||
push('ain1', state.ain1);
|
||||
push('ain2', state.ain2);
|
||||
push('ain3', state.ain3);
|
||||
push('ain4', state.ain4);
|
||||
push('ain5', state.ain5);
|
||||
push('ain6', state.ain6);
|
||||
push('ain7', state.ain7);
|
||||
}
|
||||
|
||||
List<FlSpot> get(String id) => List.unmodifiable(_data[id] ?? []);
|
||||
|
||||
void clear() {
|
||||
for (final list in _data.values) {
|
||||
list.clear();
|
||||
}
|
||||
_t0 = null;
|
||||
}
|
||||
}
|
||||
|
||||
class SensorHistoryNotifier extends StateNotifier<SensorHistory> {
|
||||
SensorHistoryNotifier() : super(SensorHistory());
|
||||
|
||||
void addState(SensorState s) {
|
||||
state.add(s);
|
||||
// trigger rebuild by reassigning same object
|
||||
// ignore: invalid_use_of_protected_member
|
||||
state = state;
|
||||
}
|
||||
|
||||
void clear() {
|
||||
state.clear();
|
||||
state = SensorHistory();
|
||||
}
|
||||
}
|
||||
|
||||
final sensorHistoryProvider =
|
||||
StateNotifierProvider<SensorHistoryNotifier, SensorHistory>((ref) {
|
||||
final notifier = SensorHistoryNotifier();
|
||||
|
||||
// Feed every parsed sensor state into history
|
||||
ref.listen<AsyncValue<SensorState>>(sensorStateProvider, (_, next) {
|
||||
next.whenData(notifier.addState);
|
||||
});
|
||||
|
||||
return notifier;
|
||||
});
|
||||
|
||||
@ -1,12 +1,36 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../bluetooth/bt_poller.dart';
|
||||
import '../models/sensor_defs.dart';
|
||||
import '../protocol/kpro_parser.dart';
|
||||
import '../protocol/s300_parser.dart';
|
||||
import '../protocol/sensor_state.dart';
|
||||
import 'bt_provider.dart';
|
||||
import 'settings_provider.dart';
|
||||
|
||||
// NEG8 is only an 8-bit checksum, so roughly 1 in 256 corrupted frames can
|
||||
// still pass validation — rare, but enough to occasionally show an
|
||||
// impossible value: a single flipped bit in the high byte of a 16-bit field
|
||||
// can jump it into the tens of thousands. Clamp just the fields where that's
|
||||
// possible (multi-byte reads whose units genuinely match sensorDefs — RPM,
|
||||
// VSS, MAP, INJ) to their realistic range, the same bounds the dashboard's
|
||||
// gauges already use for normalization. Single-byte fields (TPS, gear, ECT/
|
||||
// IAT, etc.) are already bounded to 0-255 raw by construction and can't
|
||||
// produce this kind of runaway spike, so they're left alone here.
|
||||
SensorState _clampToRealisticRange(SensorState s) {
|
||||
double clampFor(String id, double value) {
|
||||
final def = sensorDefs.firstWhere((d) => d.id == id);
|
||||
return value.clamp(def.min, def.max);
|
||||
}
|
||||
|
||||
return s.copyWith(
|
||||
rpm: clampFor('rpm', s.rpm),
|
||||
vss: clampFor('vss', s.vss),
|
||||
map: clampFor('map', s.map),
|
||||
inj: clampFor('inj', s.inj),
|
||||
);
|
||||
}
|
||||
|
||||
/// Counts of what sensorStateProvider actually did with each frame it saw,
|
||||
/// exposed for the debug screen so the live pipeline can be inspected
|
||||
/// directly instead of inferred from symptoms.
|
||||
@ -27,16 +51,6 @@ class DecodeStats {
|
||||
|
||||
final decodeStatsProvider = StateProvider<DecodeStats>((ref) => const DecodeStats());
|
||||
|
||||
// Bluetooth's round-trip jitter can make an individual request land outside
|
||||
// the ECU's transient tach pulse-capture window, so a poll reports "no
|
||||
// fresh count" (rpm=0) even though the engine/bench signal never actually
|
||||
// changed, and jitter runs can span more than a couple of polls. Hold the
|
||||
// last real RPM for this long (measured in wall-clock time, not frame count,
|
||||
// since frame count at the same real gap would mean a different actual delay
|
||||
// at every polling-interval setting) before accepting a run of zeros as a
|
||||
// genuine stop.
|
||||
const _rpmHoldDuration = Duration(milliseconds: 1000);
|
||||
|
||||
/// Emits a parsed [SensorState] for every live data frame received from the ECU.
|
||||
/// Automatically picks S300 or KPro parser based on [settingsProvider].
|
||||
final sensorStateProvider = StreamProvider<SensorState>((ref) async* {
|
||||
@ -50,27 +64,113 @@ final sensorStateProvider = StreamProvider<SensorState>((ref) async* {
|
||||
// never sees them and the loop just keeps waiting for the next frame.
|
||||
final safeStream = btNotifier.frameStream.handleError((Object _, StackTrace __) {});
|
||||
|
||||
double lastGoodRpm = 0;
|
||||
DateTime? lastGoodRpmAt;
|
||||
SensorState? lastGood;
|
||||
int dbgFrameCount = 0; // TEMP: live-debugging session, remove after.
|
||||
|
||||
await for (final frame in safeStream) {
|
||||
// A single bad frame must never take down the rest of the live stream.
|
||||
try {
|
||||
SensorState decoded =
|
||||
ecuType == EcuType.s300 ? parseS300(frame) : parseKPro(frame);
|
||||
decoded = _clampToRealisticRange(decoded);
|
||||
|
||||
if (decoded.rpm > 0) {
|
||||
lastGoodRpm = decoded.rpm;
|
||||
lastGoodRpmAt = decoded.timestamp;
|
||||
} else if (lastGoodRpm > 0) {
|
||||
// Anchored to the last real reading rather than refreshed on every
|
||||
// held frame, so a genuine stop is accepted once this much wall-clock
|
||||
// time has passed regardless of how many zero polls occurred in it.
|
||||
if (decoded.timestamp.difference(lastGoodRpmAt!) < _rpmHoldDuration) {
|
||||
decoded = decoded.copyWith(rpm: lastGoodRpm);
|
||||
} else {
|
||||
lastGoodRpm = 0;
|
||||
// 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);
|
||||
|
||||
@ -1,30 +1,48 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../bluetooth/bt_poller.dart';
|
||||
|
||||
class AppSettings {
|
||||
final EcuType ecuType;
|
||||
final Duration pollingInterval;
|
||||
|
||||
const AppSettings({
|
||||
this.ecuType = EcuType.s300,
|
||||
this.pollingInterval = const Duration(milliseconds: 50),
|
||||
});
|
||||
|
||||
AppSettings copyWith({EcuType? ecuType, Duration? pollingInterval}) =>
|
||||
AppSettings(
|
||||
ecuType: ecuType ?? this.ecuType,
|
||||
pollingInterval: pollingInterval ?? this.pollingInterval,
|
||||
);
|
||||
}
|
||||
|
||||
class SettingsNotifier extends StateNotifier<AppSettings> {
|
||||
SettingsNotifier() : super(const AppSettings());
|
||||
|
||||
void setEcuType(EcuType type) => state = state.copyWith(ecuType: type);
|
||||
void setPollingInterval(Duration d) =>
|
||||
state = state.copyWith(pollingInterval: d);
|
||||
}
|
||||
|
||||
final settingsProvider = StateNotifierProvider<SettingsNotifier, AppSettings>(
|
||||
(ref) => SettingsNotifier(),
|
||||
);
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../bluetooth/bt_poller.dart';
|
||||
|
||||
class AppSettings {
|
||||
final EcuType ecuType;
|
||||
final Duration pollingInterval;
|
||||
|
||||
const AppSettings({
|
||||
this.ecuType = EcuType.s300,
|
||||
this.pollingInterval = const Duration(milliseconds: 50),
|
||||
});
|
||||
|
||||
AppSettings copyWith({EcuType? ecuType, Duration? pollingInterval}) =>
|
||||
AppSettings(
|
||||
ecuType: ecuType ?? this.ecuType,
|
||||
pollingInterval: pollingInterval ?? this.pollingInterval,
|
||||
);
|
||||
}
|
||||
|
||||
const _ecuTypeKey = 'ecuType';
|
||||
|
||||
class SettingsNotifier extends StateNotifier<AppSettings> {
|
||||
SettingsNotifier() : super(const AppSettings()) {
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final saved = prefs.getString(_ecuTypeKey);
|
||||
if (saved == EcuType.kpro.name) {
|
||||
state = state.copyWith(ecuType: EcuType.kpro);
|
||||
}
|
||||
}
|
||||
|
||||
void setEcuType(EcuType type) {
|
||||
state = state.copyWith(ecuType: type);
|
||||
SharedPreferences.getInstance()
|
||||
.then((prefs) => prefs.setString(_ecuTypeKey, type.name));
|
||||
}
|
||||
|
||||
void setPollingInterval(Duration d) =>
|
||||
state = state.copyWith(pollingInterval: d);
|
||||
}
|
||||
|
||||
final settingsProvider = StateNotifierProvider<SettingsNotifier, AppSettings>(
|
||||
(ref) => SettingsNotifier(),
|
||||
);
|
||||
|
||||
@ -1,32 +1,32 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
enum AppThemeVariant { redDark, redLight, greenDark, greenLight }
|
||||
|
||||
class ThemeNotifier extends StateNotifier<AppThemeVariant> {
|
||||
ThemeNotifier() : super(AppThemeVariant.redDark);
|
||||
|
||||
void set(AppThemeVariant v) => state = v;
|
||||
}
|
||||
|
||||
final themeProvider =
|
||||
StateNotifierProvider<ThemeNotifier, AppThemeVariant>(
|
||||
(ref) => ThemeNotifier(),
|
||||
);
|
||||
|
||||
/// Returns the accent color for the current theme variant.
|
||||
extension AppThemeVariantX on AppThemeVariant {
|
||||
Color get accentColor {
|
||||
switch (this) {
|
||||
case AppThemeVariant.redDark:
|
||||
case AppThemeVariant.redLight:
|
||||
return const Color(0xFFFF4444);
|
||||
case AppThemeVariant.greenDark:
|
||||
case AppThemeVariant.greenLight:
|
||||
return const Color(0xFF00E676);
|
||||
}
|
||||
}
|
||||
|
||||
bool get isDark =>
|
||||
this == AppThemeVariant.redDark || this == AppThemeVariant.greenDark;
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
enum AppThemeVariant { redDark, redLight, greenDark, greenLight }
|
||||
|
||||
class ThemeNotifier extends StateNotifier<AppThemeVariant> {
|
||||
ThemeNotifier() : super(AppThemeVariant.redDark);
|
||||
|
||||
void set(AppThemeVariant v) => state = v;
|
||||
}
|
||||
|
||||
final themeProvider =
|
||||
StateNotifierProvider<ThemeNotifier, AppThemeVariant>(
|
||||
(ref) => ThemeNotifier(),
|
||||
);
|
||||
|
||||
/// Returns the accent color for the current theme variant.
|
||||
extension AppThemeVariantX on AppThemeVariant {
|
||||
Color get accentColor {
|
||||
switch (this) {
|
||||
case AppThemeVariant.redDark:
|
||||
case AppThemeVariant.redLight:
|
||||
return const Color(0xFFFF4444);
|
||||
case AppThemeVariant.greenDark:
|
||||
case AppThemeVariant.greenLight:
|
||||
return const Color(0xFF00E676);
|
||||
}
|
||||
}
|
||||
|
||||
bool get isDark =>
|
||||
this == AppThemeVariant.redDark || this == AppThemeVariant.greenDark;
|
||||
}
|
||||
|
||||
@ -1,26 +1,26 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'core/providers/theme_provider.dart';
|
||||
import 'ui/theme/app_theme.dart';
|
||||
import 'ui/screens/bt_picker/bt_picker_screen.dart';
|
||||
|
||||
void main() {
|
||||
runApp(const ProviderScope(child: HvbtApp()));
|
||||
}
|
||||
|
||||
class HvbtApp extends ConsumerWidget {
|
||||
const HvbtApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final themeVariant = ref.watch(themeProvider);
|
||||
|
||||
return MaterialApp(
|
||||
title: 'HV BT Dashboard',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: AppTheme.build(themeVariant),
|
||||
home: const BtPickerScreen(),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'core/providers/theme_provider.dart';
|
||||
import 'ui/theme/app_theme.dart';
|
||||
import 'ui/screens/bt_picker/bt_picker_screen.dart';
|
||||
|
||||
void main() {
|
||||
runApp(const ProviderScope(child: HvbtApp()));
|
||||
}
|
||||
|
||||
class HvbtApp extends ConsumerWidget {
|
||||
const HvbtApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final themeVariant = ref.watch(themeProvider);
|
||||
|
||||
return MaterialApp(
|
||||
title: 'HV BT Dashboard',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: AppTheme.build(themeVariant),
|
||||
home: const BtPickerScreen(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,178 +1,154 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import '../../../core/bluetooth/bt_poller.dart';
|
||||
import '../../../core/providers/bt_provider.dart';
|
||||
import '../../../core/providers/settings_provider.dart';
|
||||
import '../../widgets/main_nav.dart';
|
||||
|
||||
class BtPickerScreen extends ConsumerStatefulWidget {
|
||||
const BtPickerScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<BtPickerScreen> createState() => _BtPickerScreenState();
|
||||
}
|
||||
|
||||
class _BtPickerScreenState extends ConsumerState<BtPickerScreen> {
|
||||
String? _error;
|
||||
bool _initDone = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _initBluetooth());
|
||||
}
|
||||
|
||||
Future<void> _initBluetooth() async {
|
||||
setState(() => _error = null);
|
||||
|
||||
final statuses = await [
|
||||
Permission.bluetoothConnect,
|
||||
Permission.bluetoothScan,
|
||||
Permission.locationWhenInUse,
|
||||
].request();
|
||||
|
||||
final denied = statuses.entries
|
||||
.where((e) => e.value.isDenied || e.value.isPermanentlyDenied)
|
||||
.map((e) => e.key.toString())
|
||||
.toList();
|
||||
|
||||
if (denied.isNotEmpty) {
|
||||
setState(() {
|
||||
_error = 'Permissions denied: ${denied.join(", ")}\n\n'
|
||||
'Go to Settings → Apps → HV BT Dashboard → Permissions.';
|
||||
_initDone = true;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
final btState = await FlutterBluetoothSerial.instance.state;
|
||||
if (btState != BluetoothState.STATE_ON) {
|
||||
await FlutterBluetoothSerial.instance.requestEnable();
|
||||
await Future<void>.delayed(const Duration(seconds: 1));
|
||||
}
|
||||
|
||||
setState(() => _initDone = true);
|
||||
ref.invalidate(pairedDevicesProvider);
|
||||
}
|
||||
|
||||
Future<void> _connect(BluetoothDevice device, EcuType ecuType) async {
|
||||
ref.read(settingsProvider.notifier).setEcuType(ecuType);
|
||||
await ref.read(btProvider.notifier).connect(device);
|
||||
if (!mounted) return;
|
||||
if (ref.read(btProvider).isConnected) {
|
||||
Navigator.of(context).pushReplacement(
|
||||
MaterialPageRoute(builder: (_) => const MainNav()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _showProtocolDialog(BluetoothDevice device) {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(device.name ?? device.address),
|
||||
content: const Text('Select ECU protocol:'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () { Navigator.pop(ctx); _connect(device, EcuType.s300); },
|
||||
child: const Text('S300'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () { Navigator.pop(ctx); _connect(device, EcuType.kpro); },
|
||||
child: const Text('KPro'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final btState = ref.watch(btProvider);
|
||||
final devicesAsync = ref.watch(pairedDevicesProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Connect to ECU'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: _loading ? null : _initBluetooth,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
if (btState.status == BtStatus.error)
|
||||
_banner('Connection failed: ${btState.error}'),
|
||||
if (_error != null) _banner(_error!),
|
||||
Expanded(
|
||||
child: !_initDone
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: devicesAsync.when(
|
||||
loading: () => const Center(
|
||||
child: CircularProgressIndicator()),
|
||||
error: (e, _) =>
|
||||
Center(child: _banner('Failed to load: $e')),
|
||||
data: (devices) => devices.isEmpty
|
||||
? const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Text(
|
||||
'No paired BT devices.\nPair your ECU in Android Settings first.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
),
|
||||
)
|
||||
: ListView.separated(
|
||||
itemCount: devices.length,
|
||||
separatorBuilder: (_, __) =>
|
||||
const Divider(height: 1),
|
||||
itemBuilder: (_, i) {
|
||||
final d = devices[i];
|
||||
final connecting = btState.isConnecting &&
|
||||
btState.deviceName ==
|
||||
(d.name ?? d.address);
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.bluetooth),
|
||||
title: Text(d.name ?? 'Unknown'),
|
||||
subtitle: Text(d.address),
|
||||
trailing: connecting
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2))
|
||||
: const Icon(
|
||||
Icons.arrow_forward_ios,
|
||||
size: 14),
|
||||
onTap: connecting
|
||||
? null
|
||||
: () => _showProtocolDialog(d),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool get _loading => !_initDone || ref.read(btProvider).isConnecting;
|
||||
|
||||
Widget _banner(String msg) => Container(
|
||||
width: double.infinity,
|
||||
color: const Color(0xFF4A0000),
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Text(msg,
|
||||
style: const TextStyle(color: Colors.redAccent, fontSize: 12)),
|
||||
);
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import '../../../core/providers/bt_provider.dart';
|
||||
import '../../widgets/main_nav.dart';
|
||||
|
||||
class BtPickerScreen extends ConsumerStatefulWidget {
|
||||
const BtPickerScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<BtPickerScreen> createState() => _BtPickerScreenState();
|
||||
}
|
||||
|
||||
class _BtPickerScreenState extends ConsumerState<BtPickerScreen> {
|
||||
String? _error;
|
||||
bool _initDone = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _initBluetooth());
|
||||
}
|
||||
|
||||
Future<void> _initBluetooth() async {
|
||||
setState(() => _error = null);
|
||||
|
||||
final statuses = await [
|
||||
Permission.bluetoothConnect,
|
||||
Permission.bluetoothScan,
|
||||
Permission.locationWhenInUse,
|
||||
].request();
|
||||
|
||||
final denied = statuses.entries
|
||||
.where((e) => e.value.isDenied || e.value.isPermanentlyDenied)
|
||||
.map((e) => e.key.toString())
|
||||
.toList();
|
||||
|
||||
if (denied.isNotEmpty) {
|
||||
setState(() {
|
||||
_error = 'Permissions denied: ${denied.join(", ")}\n\n'
|
||||
'Go to Settings → Apps → HV BT Dashboard → Permissions.';
|
||||
_initDone = true;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
final btState = await FlutterBluetoothSerial.instance.state;
|
||||
if (btState != BluetoothState.STATE_ON) {
|
||||
await FlutterBluetoothSerial.instance.requestEnable();
|
||||
await Future<void>.delayed(const Duration(seconds: 1));
|
||||
}
|
||||
|
||||
setState(() => _initDone = true);
|
||||
ref.invalidate(pairedDevicesProvider);
|
||||
}
|
||||
|
||||
// ECU protocol is chosen once via Settings (or defaults to S300) and
|
||||
// persisted from there — no need to ask again on every connection. A
|
||||
// dialog here used to re-prompt "S300 or KPro?" on every single tap,
|
||||
// which was pure friction once the user had already told the app which
|
||||
// ECU they have.
|
||||
Future<void> _connect(BluetoothDevice device) async {
|
||||
await ref.read(btProvider.notifier).connect(device);
|
||||
if (!mounted) return;
|
||||
if (ref.read(btProvider).isConnected) {
|
||||
Navigator.of(context).pushReplacement(
|
||||
MaterialPageRoute(builder: (_) => const MainNav()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final btState = ref.watch(btProvider);
|
||||
final devicesAsync = ref.watch(pairedDevicesProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Connect to ECU'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: _loading ? null : _initBluetooth,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
if (btState.status == BtStatus.error)
|
||||
_banner('Connection failed: ${btState.error}'),
|
||||
if (_error != null) _banner(_error!),
|
||||
Expanded(
|
||||
child: !_initDone
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: devicesAsync.when(
|
||||
loading: () => const Center(
|
||||
child: CircularProgressIndicator()),
|
||||
error: (e, _) =>
|
||||
Center(child: _banner('Failed to load: $e')),
|
||||
data: (devices) => devices.isEmpty
|
||||
? const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Text(
|
||||
'No paired BT devices.\nPair your ECU in Android Settings first.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
),
|
||||
)
|
||||
: ListView.separated(
|
||||
itemCount: devices.length,
|
||||
separatorBuilder: (_, __) =>
|
||||
const Divider(height: 1),
|
||||
itemBuilder: (_, i) {
|
||||
final d = devices[i];
|
||||
final connecting = btState.isConnecting &&
|
||||
btState.deviceName ==
|
||||
(d.name ?? d.address);
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.bluetooth),
|
||||
title: Text(d.name ?? 'Unknown'),
|
||||
subtitle: Text(d.address),
|
||||
trailing: connecting
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2))
|
||||
: const Icon(
|
||||
Icons.arrow_forward_ios,
|
||||
size: 14),
|
||||
onTap: connecting ? null : () => _connect(d),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool get _loading => !_initDone || ref.read(btProvider).isConnecting;
|
||||
|
||||
Widget _banner(String msg) => Container(
|
||||
width: double.infinity,
|
||||
color: const Color(0xFF4A0000),
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Text(msg,
|
||||
style: const TextStyle(color: Colors.redAccent, fontSize: 12)),
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,113 +1,113 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/providers/sensor_provider.dart';
|
||||
import '../../../core/providers/theme_provider.dart';
|
||||
import '../../../ui/theme/app_colors.dart';
|
||||
import 'widgets/analog_gauge.dart';
|
||||
import 'widgets/flag_pill.dart';
|
||||
import 'widgets/sensor_tile.dart';
|
||||
import 'widgets/sensor_picker_sheet.dart';
|
||||
import 'dashboard_slots_provider.dart';
|
||||
|
||||
class DashboardHorizontal extends ConsumerWidget {
|
||||
const DashboardHorizontal({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final state = ref.watch(latestSensorProvider);
|
||||
final themeVariant = ref.watch(themeProvider);
|
||||
final colors = AppColors.forTheme(themeVariant);
|
||||
final slots = ref.watch(dashboardSlotsProvider);
|
||||
|
||||
return Container(
|
||||
color: const Color(0xFF0A0A0A),
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Left tiles (slots 0-2)
|
||||
SizedBox(
|
||||
width: 140,
|
||||
child: Column(
|
||||
children: [
|
||||
for (int i = 0; i < 3; i++)
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: SensorTile(
|
||||
slotIndex: i,
|
||||
sensorId: slots[i],
|
||||
state: state,
|
||||
accentColor: colors.accent,
|
||||
onTap: () => _pickSensor(context, ref, i),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Center gauge
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: AnalogGauge(
|
||||
value: state.rpm,
|
||||
minValue: 0,
|
||||
maxValue: 9000,
|
||||
label: 'RPM',
|
||||
unit: 'rpm',
|
||||
accentColor: colors.accent,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Right tiles (slots 3-5)
|
||||
SizedBox(
|
||||
width: 140,
|
||||
child: Column(
|
||||
children: [
|
||||
for (int i = 3; i < 6; i++)
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: SensorTile(
|
||||
slotIndex: i,
|
||||
sensorId: slots[i],
|
||||
state: state,
|
||||
accentColor: colors.accent,
|
||||
onTap: () => _pickSensor(context, ref, i),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Flag pills row
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: FlagRow(state: state, accentColor: colors.accent),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _pickSensor(BuildContext context, WidgetRef ref, int slotIndex) {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
builder: (_) => SensorPickerSheet(
|
||||
currentId: ref.read(dashboardSlotsProvider)[slotIndex],
|
||||
onSelected: (id) {
|
||||
ref.read(dashboardSlotsProvider.notifier).setSlot(slotIndex, id);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/providers/sensor_provider.dart';
|
||||
import '../../../core/providers/theme_provider.dart';
|
||||
import '../../../ui/theme/app_colors.dart';
|
||||
import 'widgets/analog_gauge.dart';
|
||||
import 'widgets/flag_pill.dart';
|
||||
import 'widgets/sensor_tile.dart';
|
||||
import 'widgets/sensor_picker_sheet.dart';
|
||||
import 'dashboard_slots_provider.dart';
|
||||
|
||||
class DashboardHorizontal extends ConsumerWidget {
|
||||
const DashboardHorizontal({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final state = ref.watch(latestSensorProvider);
|
||||
final themeVariant = ref.watch(themeProvider);
|
||||
final colors = AppColors.forTheme(themeVariant);
|
||||
final slots = ref.watch(dashboardSlotsProvider);
|
||||
|
||||
return Container(
|
||||
color: const Color(0xFF0A0A0A),
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Left tiles (slots 0-2)
|
||||
SizedBox(
|
||||
width: 140,
|
||||
child: Column(
|
||||
children: [
|
||||
for (int i = 0; i < 3; i++)
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: SensorTile(
|
||||
slotIndex: i,
|
||||
sensorId: slots[i],
|
||||
state: state,
|
||||
accentColor: colors.accent,
|
||||
onTap: () => _pickSensor(context, ref, i),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Center gauge
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: AnalogGauge(
|
||||
value: state.rpm,
|
||||
minValue: 0,
|
||||
maxValue: 9000,
|
||||
label: 'RPM',
|
||||
unit: 'rpm',
|
||||
accentColor: colors.accent,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Right tiles (slots 3-5)
|
||||
SizedBox(
|
||||
width: 140,
|
||||
child: Column(
|
||||
children: [
|
||||
for (int i = 3; i < 6; i++)
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: SensorTile(
|
||||
slotIndex: i,
|
||||
sensorId: slots[i],
|
||||
state: state,
|
||||
accentColor: colors.accent,
|
||||
onTap: () => _pickSensor(context, ref, i),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Flag pills row
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: FlagRow(state: state, accentColor: colors.accent),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _pickSensor(BuildContext context, WidgetRef ref, int slotIndex) {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
builder: (_) => SensorPickerSheet(
|
||||
currentId: ref.read(dashboardSlotsProvider)[slotIndex],
|
||||
onSelected: (id) {
|
||||
ref.read(dashboardSlotsProvider.notifier).setSlot(slotIndex, id);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,19 +1,19 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'dashboard_horizontal.dart';
|
||||
import 'dashboard_vertical.dart';
|
||||
|
||||
class DashboardScreen extends ConsumerWidget {
|
||||
const DashboardScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return OrientationBuilder(
|
||||
builder: (context, orientation) {
|
||||
return orientation == Orientation.landscape
|
||||
? const DashboardHorizontal()
|
||||
: const DashboardVertical();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'dashboard_horizontal.dart';
|
||||
import 'dashboard_vertical.dart';
|
||||
|
||||
class DashboardScreen extends ConsumerWidget {
|
||||
const DashboardScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return OrientationBuilder(
|
||||
builder: (context, orientation) {
|
||||
return orientation == Orientation.landscape
|
||||
? const DashboardHorizontal()
|
||||
: const DashboardVertical();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,19 +1,19 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
// Default sensor IDs for the 6 dashboard tile slots
|
||||
const _defaultSlots = ['vss', 'map', 'tps', 'ect', 'iat', 'bat'];
|
||||
|
||||
class DashboardSlotsNotifier extends StateNotifier<List<String>> {
|
||||
DashboardSlotsNotifier() : super(List.from(_defaultSlots));
|
||||
|
||||
void setSlot(int index, String sensorId) {
|
||||
final next = List<String>.from(state);
|
||||
next[index] = sensorId;
|
||||
state = next;
|
||||
}
|
||||
}
|
||||
|
||||
final dashboardSlotsProvider =
|
||||
StateNotifierProvider<DashboardSlotsNotifier, List<String>>(
|
||||
(ref) => DashboardSlotsNotifier(),
|
||||
);
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
// Default sensor IDs for the 6 dashboard tile slots
|
||||
const _defaultSlots = ['vss', 'map', 'tps', 'ect', 'iat', 'bat'];
|
||||
|
||||
class DashboardSlotsNotifier extends StateNotifier<List<String>> {
|
||||
DashboardSlotsNotifier() : super(List.from(_defaultSlots));
|
||||
|
||||
void setSlot(int index, String sensorId) {
|
||||
final next = List<String>.from(state);
|
||||
next[index] = sensorId;
|
||||
state = next;
|
||||
}
|
||||
}
|
||||
|
||||
final dashboardSlotsProvider =
|
||||
StateNotifierProvider<DashboardSlotsNotifier, List<String>>(
|
||||
(ref) => DashboardSlotsNotifier(),
|
||||
);
|
||||
|
||||
@ -1,80 +1,81 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/providers/sensor_provider.dart';
|
||||
import '../../../core/providers/theme_provider.dart';
|
||||
import '../../../ui/theme/app_colors.dart';
|
||||
import 'widgets/analog_gauge.dart';
|
||||
import 'widgets/flag_pill.dart';
|
||||
import 'widgets/sensor_tile.dart';
|
||||
import 'widgets/sensor_picker_sheet.dart';
|
||||
import 'dashboard_slots_provider.dart';
|
||||
|
||||
class DashboardVertical extends ConsumerWidget {
|
||||
const DashboardVertical({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final state = ref.watch(latestSensorProvider);
|
||||
final themeVariant = ref.watch(themeProvider);
|
||||
final colors = AppColors.forTheme(themeVariant);
|
||||
final slots = ref.watch(dashboardSlotsProvider);
|
||||
|
||||
return Container(
|
||||
color: const Color(0xFF0A0A0A),
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 8, 24, 0),
|
||||
child: AnalogGauge(
|
||||
value: state.rpm,
|
||||
minValue: 0,
|
||||
maxValue: 9000,
|
||||
label: 'RPM',
|
||||
unit: 'rpm',
|
||||
accentColor: colors.accent,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12),
|
||||
child: FlagRow(state: state, accentColor: colors.accent),
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
|
||||
child: GridView.count(
|
||||
crossAxisCount: 2,
|
||||
mainAxisSpacing: 6,
|
||||
crossAxisSpacing: 6,
|
||||
childAspectRatio: 2.4,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
children: [
|
||||
for (int i = 0; i < slots.length; i++)
|
||||
SensorTile(
|
||||
slotIndex: i,
|
||||
sensorId: slots[i],
|
||||
state: state,
|
||||
accentColor: colors.accent,
|
||||
onTap: () => _pickSensor(context, ref, i),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _pickSensor(BuildContext context, WidgetRef ref, int slotIndex) {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
builder: (_) => SensorPickerSheet(
|
||||
currentId: ref.read(dashboardSlotsProvider)[slotIndex],
|
||||
onSelected: (id) {
|
||||
ref.read(dashboardSlotsProvider.notifier).setSlot(slotIndex, id);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/providers/sensor_provider.dart';
|
||||
import '../../../core/providers/theme_provider.dart';
|
||||
import '../../../ui/theme/app_colors.dart';
|
||||
import 'widgets/analog_gauge.dart';
|
||||
import 'widgets/flag_pill.dart';
|
||||
import 'widgets/sensor_tile.dart';
|
||||
import 'widgets/sensor_picker_sheet.dart';
|
||||
import 'dashboard_slots_provider.dart';
|
||||
|
||||
class DashboardVertical extends ConsumerWidget {
|
||||
const DashboardVertical({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final state = ref.watch(latestSensorProvider);
|
||||
final themeVariant = ref.watch(themeProvider);
|
||||
final colors = AppColors.forTheme(themeVariant);
|
||||
final slots = ref.watch(dashboardSlotsProvider);
|
||||
|
||||
return Container(
|
||||
color: const Color(0xFF0A0A0A),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 8, 24, 0),
|
||||
child: AnalogGauge(
|
||||
value: state.rpm,
|
||||
minValue: 0,
|
||||
maxValue: 9000,
|
||||
label: 'RPM',
|
||||
unit: 'rpm',
|
||||
accentColor: colors.accent,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12),
|
||||
child: FlagRow(state: state, accentColor: colors.accent),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
|
||||
child: GridView.count(
|
||||
shrinkWrap: true,
|
||||
crossAxisCount: 2,
|
||||
mainAxisSpacing: 6,
|
||||
crossAxisSpacing: 6,
|
||||
childAspectRatio: 2.4,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
children: [
|
||||
for (int i = 0; i < slots.length; i++)
|
||||
SensorTile(
|
||||
slotIndex: i,
|
||||
sensorId: slots[i],
|
||||
state: state,
|
||||
accentColor: colors.accent,
|
||||
onTap: () => _pickSensor(context, ref, i),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _pickSensor(BuildContext context, WidgetRef ref, int slotIndex) {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
builder: (_) => SensorPickerSheet(
|
||||
currentId: ref.read(dashboardSlotsProvider)[slotIndex],
|
||||
onSelected: (id) {
|
||||
ref.read(dashboardSlotsProvider.notifier).setSlot(slotIndex, id);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,263 +1,263 @@
|
||||
import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AnalogGauge extends StatefulWidget {
|
||||
final double value;
|
||||
final double minValue;
|
||||
final double maxValue;
|
||||
final String label;
|
||||
final String unit;
|
||||
final Color accentColor;
|
||||
|
||||
const AnalogGauge({
|
||||
super.key,
|
||||
required this.value,
|
||||
required this.minValue,
|
||||
required this.maxValue,
|
||||
required this.label,
|
||||
required this.unit,
|
||||
required this.accentColor,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AnalogGauge> createState() => _AnalogGaugeState();
|
||||
}
|
||||
|
||||
class _AnalogGaugeState extends State<AnalogGauge>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _ctrl;
|
||||
late Animation<double> _anim;
|
||||
|
||||
double get _normalized =>
|
||||
((widget.value - widget.minValue) /
|
||||
(widget.maxValue - widget.minValue))
|
||||
.clamp(0.0, 1.0);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_ctrl = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 150),
|
||||
);
|
||||
_anim = Tween<double>(begin: 0, end: _normalized)
|
||||
.animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOut));
|
||||
_ctrl.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(AnalogGauge old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (old.value != widget.value) {
|
||||
_anim = Tween<double>(begin: _anim.value, end: _normalized)
|
||||
.animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOut));
|
||||
_ctrl.forward(from: 0);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ctrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: AnimatedBuilder(
|
||||
animation: _anim,
|
||||
builder: (_, __) => CustomPaint(
|
||||
painter: _GaugePainter(
|
||||
normalizedValue: _anim.value,
|
||||
accentColor: widget.accentColor,
|
||||
displayValue: widget.value,
|
||||
unit: widget.unit,
|
||||
label: widget.label,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GaugePainter extends CustomPainter {
|
||||
final double normalizedValue; // 0.0–1.0
|
||||
final Color accentColor;
|
||||
final double displayValue;
|
||||
final String unit;
|
||||
final String label;
|
||||
|
||||
static const double _startAngle = 135 * pi / 180; // 7:30 position
|
||||
static const double _sweepAngle = 270 * pi / 180; // 270° sweep
|
||||
|
||||
_GaugePainter({
|
||||
required this.normalizedValue,
|
||||
required this.accentColor,
|
||||
required this.displayValue,
|
||||
required this.unit,
|
||||
required this.label,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final center = Offset(size.width / 2, size.height / 2);
|
||||
final radius = size.width * 0.42;
|
||||
final rect = Rect.fromCircle(center: center, radius: radius);
|
||||
|
||||
// ── Background arc ──────────────────────────────────────────────
|
||||
final bgPaint = Paint()
|
||||
..color = const Color(0xFF2A2A2A)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = size.width * 0.06
|
||||
..strokeCap = StrokeCap.round;
|
||||
canvas.drawArc(rect, _startAngle, _sweepAngle, false, bgPaint);
|
||||
|
||||
// ── Red zone (last 20%) ──────────────────────────────────────────
|
||||
final redPaint = Paint()
|
||||
..color = Colors.red.withAlpha(120)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = size.width * 0.06
|
||||
..strokeCap = StrokeCap.round;
|
||||
canvas.drawArc(
|
||||
rect,
|
||||
_startAngle + _sweepAngle * 0.8,
|
||||
_sweepAngle * 0.2,
|
||||
false,
|
||||
redPaint,
|
||||
);
|
||||
|
||||
// ── Value arc ────────────────────────────────────────────────────
|
||||
if (normalizedValue > 0) {
|
||||
final isRedZone = normalizedValue > 0.8;
|
||||
final valPaint = Paint()
|
||||
..color = isRedZone ? Colors.red : accentColor
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = size.width * 0.06
|
||||
..strokeCap = StrokeCap.round;
|
||||
canvas.drawArc(
|
||||
rect,
|
||||
_startAngle,
|
||||
_sweepAngle * normalizedValue,
|
||||
false,
|
||||
valPaint,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tick marks ───────────────────────────────────────────────────
|
||||
_drawTicks(canvas, center, radius, size);
|
||||
|
||||
// ── Needle ────────────────────────────────────────────────────────
|
||||
_drawNeedle(canvas, center, radius, size);
|
||||
|
||||
// ── Center text ──────────────────────────────────────────────────
|
||||
_drawCenterText(canvas, center, size);
|
||||
}
|
||||
|
||||
void _drawTicks(Canvas canvas, Offset center, double radius, Size size) {
|
||||
final majorPaint = Paint()
|
||||
..color = Colors.white54
|
||||
..strokeWidth = size.width * 0.012
|
||||
..strokeCap = StrokeCap.round;
|
||||
final minorPaint = Paint()
|
||||
..color = Colors.white24
|
||||
..strokeWidth = size.width * 0.006
|
||||
..strokeCap = StrokeCap.round;
|
||||
|
||||
const totalTicks = 50; // 2% each
|
||||
for (int i = 0; i <= totalTicks; i++) {
|
||||
final t = i / totalTicks;
|
||||
final angle = _startAngle + _sweepAngle * t;
|
||||
final isMajor = i % 5 == 0;
|
||||
final paint = isMajor ? majorPaint : minorPaint;
|
||||
final outerR = radius - size.width * 0.01;
|
||||
final innerR = outerR - (isMajor ? size.width * 0.06 : size.width * 0.03);
|
||||
canvas.drawLine(
|
||||
center + Offset(cos(angle) * innerR, sin(angle) * innerR),
|
||||
center + Offset(cos(angle) * outerR, sin(angle) * outerR),
|
||||
paint,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _drawNeedle(Canvas canvas, Offset center, double radius, Size size) {
|
||||
final angle = _startAngle + _sweepAngle * normalizedValue;
|
||||
final tipR = radius - size.width * 0.08;
|
||||
final tailR = size.width * 0.08;
|
||||
|
||||
final needlePaint = Paint()
|
||||
..color = Colors.white
|
||||
..strokeWidth = size.width * 0.018
|
||||
..strokeCap = StrokeCap.round;
|
||||
|
||||
canvas.drawLine(
|
||||
center - Offset(cos(angle) * tailR, sin(angle) * tailR),
|
||||
center + Offset(cos(angle) * tipR, sin(angle) * tipR),
|
||||
needlePaint,
|
||||
);
|
||||
|
||||
// Center dot
|
||||
canvas.drawCircle(
|
||||
center,
|
||||
size.width * 0.04,
|
||||
Paint()..color = Colors.white,
|
||||
);
|
||||
canvas.drawCircle(
|
||||
center,
|
||||
size.width * 0.025,
|
||||
Paint()..color = const Color(0xFF0A0A0A),
|
||||
);
|
||||
}
|
||||
|
||||
void _drawCenterText(Canvas canvas, Offset center, Size size) {
|
||||
// Value
|
||||
final valSpan = TextSpan(
|
||||
text: displayValue.toStringAsFixed(0),
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: size.width * 0.14,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: -1,
|
||||
),
|
||||
);
|
||||
final valPainter = TextPainter(
|
||||
text: valSpan,
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout();
|
||||
valPainter.paint(
|
||||
canvas,
|
||||
center +
|
||||
Offset(
|
||||
-valPainter.width / 2,
|
||||
size.width * 0.1,
|
||||
),
|
||||
);
|
||||
|
||||
// Unit
|
||||
final unitSpan = TextSpan(
|
||||
text: unit,
|
||||
style: TextStyle(
|
||||
color: Colors.white54,
|
||||
fontSize: size.width * 0.07,
|
||||
),
|
||||
);
|
||||
final unitPainter = TextPainter(
|
||||
text: unitSpan,
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout();
|
||||
unitPainter.paint(
|
||||
canvas,
|
||||
center +
|
||||
Offset(
|
||||
-unitPainter.width / 2,
|
||||
size.width * 0.1 + valPainter.height + 2,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_GaugePainter old) =>
|
||||
old.normalizedValue != normalizedValue ||
|
||||
old.accentColor != accentColor ||
|
||||
old.displayValue != displayValue;
|
||||
}
|
||||
import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AnalogGauge extends StatefulWidget {
|
||||
final double value;
|
||||
final double minValue;
|
||||
final double maxValue;
|
||||
final String label;
|
||||
final String unit;
|
||||
final Color accentColor;
|
||||
|
||||
const AnalogGauge({
|
||||
super.key,
|
||||
required this.value,
|
||||
required this.minValue,
|
||||
required this.maxValue,
|
||||
required this.label,
|
||||
required this.unit,
|
||||
required this.accentColor,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AnalogGauge> createState() => _AnalogGaugeState();
|
||||
}
|
||||
|
||||
class _AnalogGaugeState extends State<AnalogGauge>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _ctrl;
|
||||
late Animation<double> _anim;
|
||||
|
||||
double get _normalized =>
|
||||
((widget.value - widget.minValue) /
|
||||
(widget.maxValue - widget.minValue))
|
||||
.clamp(0.0, 1.0);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_ctrl = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 150),
|
||||
);
|
||||
_anim = Tween<double>(begin: 0, end: _normalized)
|
||||
.animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOut));
|
||||
_ctrl.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(AnalogGauge old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (old.value != widget.value) {
|
||||
_anim = Tween<double>(begin: _anim.value, end: _normalized)
|
||||
.animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOut));
|
||||
_ctrl.forward(from: 0);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ctrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: AnimatedBuilder(
|
||||
animation: _anim,
|
||||
builder: (_, __) => CustomPaint(
|
||||
painter: _GaugePainter(
|
||||
normalizedValue: _anim.value,
|
||||
accentColor: widget.accentColor,
|
||||
displayValue: widget.value,
|
||||
unit: widget.unit,
|
||||
label: widget.label,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GaugePainter extends CustomPainter {
|
||||
final double normalizedValue; // 0.0–1.0
|
||||
final Color accentColor;
|
||||
final double displayValue;
|
||||
final String unit;
|
||||
final String label;
|
||||
|
||||
static const double _startAngle = 135 * pi / 180; // 7:30 position
|
||||
static const double _sweepAngle = 270 * pi / 180; // 270° sweep
|
||||
|
||||
_GaugePainter({
|
||||
required this.normalizedValue,
|
||||
required this.accentColor,
|
||||
required this.displayValue,
|
||||
required this.unit,
|
||||
required this.label,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final center = Offset(size.width / 2, size.height / 2);
|
||||
final radius = size.width * 0.42;
|
||||
final rect = Rect.fromCircle(center: center, radius: radius);
|
||||
|
||||
// ── Background arc ──────────────────────────────────────────────
|
||||
final bgPaint = Paint()
|
||||
..color = const Color(0xFF2A2A2A)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = size.width * 0.06
|
||||
..strokeCap = StrokeCap.round;
|
||||
canvas.drawArc(rect, _startAngle, _sweepAngle, false, bgPaint);
|
||||
|
||||
// ── Red zone (last 20%) ──────────────────────────────────────────
|
||||
final redPaint = Paint()
|
||||
..color = Colors.red.withAlpha(120)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = size.width * 0.06
|
||||
..strokeCap = StrokeCap.round;
|
||||
canvas.drawArc(
|
||||
rect,
|
||||
_startAngle + _sweepAngle * 0.8,
|
||||
_sweepAngle * 0.2,
|
||||
false,
|
||||
redPaint,
|
||||
);
|
||||
|
||||
// ── Value arc ────────────────────────────────────────────────────
|
||||
if (normalizedValue > 0) {
|
||||
final isRedZone = normalizedValue > 0.8;
|
||||
final valPaint = Paint()
|
||||
..color = isRedZone ? Colors.red : accentColor
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = size.width * 0.06
|
||||
..strokeCap = StrokeCap.round;
|
||||
canvas.drawArc(
|
||||
rect,
|
||||
_startAngle,
|
||||
_sweepAngle * normalizedValue,
|
||||
false,
|
||||
valPaint,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tick marks ───────────────────────────────────────────────────
|
||||
_drawTicks(canvas, center, radius, size);
|
||||
|
||||
// ── Needle ────────────────────────────────────────────────────────
|
||||
_drawNeedle(canvas, center, radius, size);
|
||||
|
||||
// ── Center text ──────────────────────────────────────────────────
|
||||
_drawCenterText(canvas, center, size);
|
||||
}
|
||||
|
||||
void _drawTicks(Canvas canvas, Offset center, double radius, Size size) {
|
||||
final majorPaint = Paint()
|
||||
..color = Colors.white54
|
||||
..strokeWidth = size.width * 0.012
|
||||
..strokeCap = StrokeCap.round;
|
||||
final minorPaint = Paint()
|
||||
..color = Colors.white24
|
||||
..strokeWidth = size.width * 0.006
|
||||
..strokeCap = StrokeCap.round;
|
||||
|
||||
const totalTicks = 50; // 2% each
|
||||
for (int i = 0; i <= totalTicks; i++) {
|
||||
final t = i / totalTicks;
|
||||
final angle = _startAngle + _sweepAngle * t;
|
||||
final isMajor = i % 5 == 0;
|
||||
final paint = isMajor ? majorPaint : minorPaint;
|
||||
final outerR = radius - size.width * 0.01;
|
||||
final innerR = outerR - (isMajor ? size.width * 0.06 : size.width * 0.03);
|
||||
canvas.drawLine(
|
||||
center + Offset(cos(angle) * innerR, sin(angle) * innerR),
|
||||
center + Offset(cos(angle) * outerR, sin(angle) * outerR),
|
||||
paint,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _drawNeedle(Canvas canvas, Offset center, double radius, Size size) {
|
||||
final angle = _startAngle + _sweepAngle * normalizedValue;
|
||||
final tipR = radius - size.width * 0.08;
|
||||
final tailR = size.width * 0.08;
|
||||
|
||||
final needlePaint = Paint()
|
||||
..color = Colors.white
|
||||
..strokeWidth = size.width * 0.018
|
||||
..strokeCap = StrokeCap.round;
|
||||
|
||||
canvas.drawLine(
|
||||
center - Offset(cos(angle) * tailR, sin(angle) * tailR),
|
||||
center + Offset(cos(angle) * tipR, sin(angle) * tipR),
|
||||
needlePaint,
|
||||
);
|
||||
|
||||
// Center dot
|
||||
canvas.drawCircle(
|
||||
center,
|
||||
size.width * 0.04,
|
||||
Paint()..color = Colors.white,
|
||||
);
|
||||
canvas.drawCircle(
|
||||
center,
|
||||
size.width * 0.025,
|
||||
Paint()..color = const Color(0xFF0A0A0A),
|
||||
);
|
||||
}
|
||||
|
||||
void _drawCenterText(Canvas canvas, Offset center, Size size) {
|
||||
// Value
|
||||
final valSpan = TextSpan(
|
||||
text: displayValue.toStringAsFixed(0),
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: size.width * 0.14,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: -1,
|
||||
),
|
||||
);
|
||||
final valPainter = TextPainter(
|
||||
text: valSpan,
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout();
|
||||
valPainter.paint(
|
||||
canvas,
|
||||
center +
|
||||
Offset(
|
||||
-valPainter.width / 2,
|
||||
size.width * 0.1,
|
||||
),
|
||||
);
|
||||
|
||||
// Unit
|
||||
final unitSpan = TextSpan(
|
||||
text: unit,
|
||||
style: TextStyle(
|
||||
color: Colors.white54,
|
||||
fontSize: size.width * 0.07,
|
||||
),
|
||||
);
|
||||
final unitPainter = TextPainter(
|
||||
text: unitSpan,
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout();
|
||||
unitPainter.paint(
|
||||
canvas,
|
||||
center +
|
||||
Offset(
|
||||
-unitPainter.width / 2,
|
||||
size.width * 0.1 + valPainter.height + 2,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_GaugePainter old) =>
|
||||
old.normalizedValue != normalizedValue ||
|
||||
old.accentColor != accentColor ||
|
||||
old.displayValue != displayValue;
|
||||
}
|
||||
|
||||
@ -1,66 +1,66 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../core/protocol/sensor_state.dart';
|
||||
|
||||
class FlagPill extends StatelessWidget {
|
||||
final String label;
|
||||
final bool active;
|
||||
final Color color;
|
||||
|
||||
const FlagPill({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.active,
|
||||
required this.color,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 100),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: active ? color.withAlpha(40) : const Color(0xFF1A1A1A),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: active ? color : Colors.white12,
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: active ? color : Colors.grey,
|
||||
fontSize: 11,
|
||||
fontWeight: active ? FontWeight.bold : FontWeight.normal,
|
||||
letterSpacing: 1,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Row of all flag pills derived from a SensorState.
|
||||
class FlagRow extends StatelessWidget {
|
||||
final SensorState state;
|
||||
final Color accentColor;
|
||||
|
||||
const FlagRow({super.key, required this.state, required this.accentColor});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
alignment: WrapAlignment.center,
|
||||
children: [
|
||||
FlagPill(label: 'MIL', active: state.mil, color: Colors.amber),
|
||||
FlagPill(label: 'VTEC', active: state.vtec, color: Colors.blue),
|
||||
FlagPill(label: 'KNOCK', active: state.knock, color: Colors.red),
|
||||
FlagPill(label: 'FUEL CUT', active: state.fuelCut, color: Colors.red),
|
||||
FlagPill(label: 'REV LIM', active: state.revLimit, color: Colors.orange),
|
||||
FlagPill(label: 'FAN', active: state.fanOut, color: Colors.green),
|
||||
FlagPill(label: 'LAUNCH', active: state.launch, color: Colors.purple),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../core/protocol/sensor_state.dart';
|
||||
|
||||
class FlagPill extends StatelessWidget {
|
||||
final String label;
|
||||
final bool active;
|
||||
final Color color;
|
||||
|
||||
const FlagPill({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.active,
|
||||
required this.color,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 100),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: active ? color.withAlpha(40) : const Color(0xFF1A1A1A),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: active ? color : Colors.white12,
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: active ? color : Colors.grey,
|
||||
fontSize: 11,
|
||||
fontWeight: active ? FontWeight.bold : FontWeight.normal,
|
||||
letterSpacing: 1,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Row of all flag pills derived from a SensorState.
|
||||
class FlagRow extends StatelessWidget {
|
||||
final SensorState state;
|
||||
final Color accentColor;
|
||||
|
||||
const FlagRow({super.key, required this.state, required this.accentColor});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
alignment: WrapAlignment.center,
|
||||
children: [
|
||||
FlagPill(label: 'MIL', active: state.mil, color: Colors.amber),
|
||||
FlagPill(label: 'VTEC', active: state.vtec, color: Colors.blue),
|
||||
FlagPill(label: 'KNOCK', active: state.knock, color: Colors.red),
|
||||
FlagPill(label: 'FUEL PUMP', active: state.fuelPump, color: Colors.cyan),
|
||||
FlagPill(label: 'REV LIM', active: state.revLimit, color: Colors.orange),
|
||||
FlagPill(label: 'FAN', active: state.fanOut, color: Colors.green),
|
||||
FlagPill(label: 'LAUNCH', active: state.launch, color: Colors.purple),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,85 +1,85 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../core/models/sensor_defs.dart';
|
||||
|
||||
class SensorPickerSheet extends StatelessWidget {
|
||||
final String currentId;
|
||||
final void Function(String id) onSelected;
|
||||
|
||||
const SensorPickerSheet({
|
||||
super.key,
|
||||
required this.currentId,
|
||||
required this.onSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DraggableScrollableSheet(
|
||||
expand: false,
|
||||
initialChildSize: 0.6,
|
||||
minChildSize: 0.4,
|
||||
maxChildSize: 0.9,
|
||||
builder: (context, scrollController) {
|
||||
return Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFF1A1A1A),
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: 40, height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white24,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Text(
|
||||
'SELECT SENSOR',
|
||||
style: TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 12,
|
||||
letterSpacing: 2,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(height: 1, color: Colors.white10),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
controller: scrollController,
|
||||
itemCount: sensorDefs.length,
|
||||
itemBuilder: (context, i) {
|
||||
final def = sensorDefs[i];
|
||||
final isSelected = def.id == currentId;
|
||||
return ListTile(
|
||||
title: Text(
|
||||
def.displayName,
|
||||
style: TextStyle(
|
||||
color: isSelected ? Colors.white : Colors.white70,
|
||||
fontWeight: isSelected
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
def.unit,
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
||||
),
|
||||
trailing: isSelected
|
||||
? const Icon(Icons.check, color: Colors.green, size: 18)
|
||||
: null,
|
||||
onTap: () => onSelected(def.id),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../core/models/sensor_defs.dart';
|
||||
|
||||
class SensorPickerSheet extends StatelessWidget {
|
||||
final String currentId;
|
||||
final void Function(String id) onSelected;
|
||||
|
||||
const SensorPickerSheet({
|
||||
super.key,
|
||||
required this.currentId,
|
||||
required this.onSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DraggableScrollableSheet(
|
||||
expand: false,
|
||||
initialChildSize: 0.6,
|
||||
minChildSize: 0.4,
|
||||
maxChildSize: 0.9,
|
||||
builder: (context, scrollController) {
|
||||
return Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFF1A1A1A),
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: 40, height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white24,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Text(
|
||||
'SELECT SENSOR',
|
||||
style: TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 12,
|
||||
letterSpacing: 2,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(height: 1, color: Colors.white10),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
controller: scrollController,
|
||||
itemCount: sensorDefs.length,
|
||||
itemBuilder: (context, i) {
|
||||
final def = sensorDefs[i];
|
||||
final isSelected = def.id == currentId;
|
||||
return ListTile(
|
||||
title: Text(
|
||||
def.displayName,
|
||||
style: TextStyle(
|
||||
color: isSelected ? Colors.white : Colors.white70,
|
||||
fontWeight: isSelected
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
def.unit,
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
||||
),
|
||||
trailing: isSelected
|
||||
? const Icon(Icons.check, color: Colors.green, size: 18)
|
||||
: null,
|
||||
onTap: () => onSelected(def.id),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,128 +1,128 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../core/models/sensor_defs.dart';
|
||||
import '../../../../core/protocol/sensor_state.dart';
|
||||
|
||||
class SensorTile extends StatelessWidget {
|
||||
final int slotIndex;
|
||||
final String sensorId;
|
||||
final SensorState state;
|
||||
final Color accentColor;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const SensorTile({
|
||||
super.key,
|
||||
required this.slotIndex,
|
||||
required this.sensorId,
|
||||
required this.state,
|
||||
required this.accentColor,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final def = sensorDefs.firstWhere(
|
||||
(d) => d.id == sensorId,
|
||||
orElse: () => sensorDefs.first,
|
||||
);
|
||||
final value = _getValue(state, sensorId);
|
||||
final normalized =
|
||||
((value - def.min) / (def.max - def.min)).clamp(0.0, 1.0);
|
||||
|
||||
return GestureDetector(
|
||||
onLongPress: onTap,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF1A1A1A),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: Colors.white10),
|
||||
),
|
||||
padding: const EdgeInsets.fromLTRB(10, 8, 10, 6),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
def.displayName.toUpperCase(),
|
||||
style: const TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 10,
|
||||
letterSpacing: 1,
|
||||
),
|
||||
),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
_format(value, sensorId),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: Text(
|
||||
def.unit,
|
||||
style: TextStyle(color: accentColor, fontSize: 11),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
// Bar gauge
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
child: LinearProgressIndicator(
|
||||
value: normalized,
|
||||
minHeight: 4,
|
||||
backgroundColor: Colors.white10,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
normalized > 0.8 ? Colors.red : accentColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _format(double v, String id) {
|
||||
if (id == 'gear') return v.toStringAsFixed(0);
|
||||
if (id == 'rpm') return v.toStringAsFixed(0);
|
||||
if (v.abs() >= 100) return v.toStringAsFixed(1);
|
||||
return v.toStringAsFixed(2);
|
||||
}
|
||||
}
|
||||
|
||||
double _getValue(SensorState s, String id) {
|
||||
switch (id) {
|
||||
case 'rpm': return s.rpm;
|
||||
case 'vss': return s.vss;
|
||||
case 'map': return s.map;
|
||||
case 'tps': return s.tps;
|
||||
case 'inj': return s.inj;
|
||||
case 'ign': return s.ign;
|
||||
case 'ect': return s.ect;
|
||||
case 'iat': return s.iat;
|
||||
case 'bat': return s.bat;
|
||||
case 'o2': return s.o2;
|
||||
case 'gear': return s.gear.toDouble();
|
||||
case 'eth': return s.eth;
|
||||
case 'pa': return s.pa;
|
||||
case 'afr': return s.afr;
|
||||
case 'strim': return s.strim;
|
||||
case 'ltrim': return s.ltrim;
|
||||
case 'ain0': return s.ain0;
|
||||
case 'ain1': return s.ain1;
|
||||
case 'ain2': return s.ain2;
|
||||
case 'ain3': return s.ain3;
|
||||
case 'ain4': return s.ain4;
|
||||
case 'ain5': return s.ain5;
|
||||
case 'ain6': return s.ain6;
|
||||
case 'ain7': return s.ain7;
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../core/models/sensor_defs.dart';
|
||||
import '../../../../core/protocol/sensor_state.dart';
|
||||
|
||||
class SensorTile extends StatelessWidget {
|
||||
final int slotIndex;
|
||||
final String sensorId;
|
||||
final SensorState state;
|
||||
final Color accentColor;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const SensorTile({
|
||||
super.key,
|
||||
required this.slotIndex,
|
||||
required this.sensorId,
|
||||
required this.state,
|
||||
required this.accentColor,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final def = sensorDefs.firstWhere(
|
||||
(d) => d.id == sensorId,
|
||||
orElse: () => sensorDefs.first,
|
||||
);
|
||||
final value = _getValue(state, sensorId);
|
||||
final normalized =
|
||||
((value - def.min) / (def.max - def.min)).clamp(0.0, 1.0);
|
||||
|
||||
return GestureDetector(
|
||||
onLongPress: onTap,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF1A1A1A),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: Colors.white10),
|
||||
),
|
||||
padding: const EdgeInsets.fromLTRB(10, 8, 10, 6),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
def.displayName.toUpperCase(),
|
||||
style: const TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 10,
|
||||
letterSpacing: 1,
|
||||
),
|
||||
),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
_format(value, sensorId),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: Text(
|
||||
def.unit,
|
||||
style: TextStyle(color: accentColor, fontSize: 11),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
// Bar gauge
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
child: LinearProgressIndicator(
|
||||
value: normalized,
|
||||
minHeight: 4,
|
||||
backgroundColor: Colors.white10,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
normalized > 0.8 ? Colors.red : accentColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _format(double v, String id) {
|
||||
if (id == 'gear') return v.toStringAsFixed(0);
|
||||
if (id == 'rpm') return v.toStringAsFixed(0);
|
||||
if (v.abs() >= 100) return v.toStringAsFixed(1);
|
||||
return v.toStringAsFixed(2);
|
||||
}
|
||||
}
|
||||
|
||||
double _getValue(SensorState s, String id) {
|
||||
switch (id) {
|
||||
case 'rpm': return s.rpm;
|
||||
case 'vss': return s.vss;
|
||||
case 'map': return s.map;
|
||||
case 'tps': return s.tps;
|
||||
case 'inj': return s.inj;
|
||||
case 'ign': return s.ign;
|
||||
case 'ect': return s.ect;
|
||||
case 'iat': return s.iat;
|
||||
case 'bat': return s.bat;
|
||||
case 'o2': return s.o2;
|
||||
case 'gear': return s.gear.toDouble();
|
||||
case 'eth': return s.eth;
|
||||
case 'pa': return s.pa;
|
||||
case 'afr': return s.afr;
|
||||
case 'strim': return s.strim;
|
||||
case 'ltrim': return s.ltrim;
|
||||
case 'ain0': return s.ain0;
|
||||
case 'ain1': return s.ain1;
|
||||
case 'ain2': return s.ain2;
|
||||
case 'ain3': return s.ain3;
|
||||
case 'ain4': return s.ain4;
|
||||
case 'ain5': return s.ain5;
|
||||
case 'ain6': return s.ain6;
|
||||
case 'ain7': return s.ain7;
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,124 +1,124 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../../core/models/datalog_session.dart';
|
||||
import '../../../core/providers/datalog_provider.dart';
|
||||
import 'datalog_playback_bar.dart';
|
||||
|
||||
class DatalogListScreen extends ConsumerWidget {
|
||||
const DatalogListScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final sessionsAsync = ref.watch(sessionListProvider);
|
||||
final playback = ref.watch(playbackProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Saved Sessions')),
|
||||
body: Column(
|
||||
children: [
|
||||
if (playback.isActive)
|
||||
DatalogPlaybackBar(state: playback),
|
||||
Expanded(
|
||||
child: sessionsAsync.when(
|
||||
loading: () =>
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(
|
||||
child: Text('Error: $e',
|
||||
style: const TextStyle(color: Colors.red))),
|
||||
data: (sessions) => sessions.isEmpty
|
||||
? const Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.videocam_off,
|
||||
size: 56, color: Colors.grey),
|
||||
SizedBox(height: 12),
|
||||
Text('No recorded sessions',
|
||||
style: TextStyle(color: Colors.grey)),
|
||||
],
|
||||
),
|
||||
)
|
||||
: ListView.separated(
|
||||
itemCount: sessions.length,
|
||||
separatorBuilder: (_, __) =>
|
||||
const Divider(height: 1, color: Colors.white10),
|
||||
itemBuilder: (context, i) =>
|
||||
_SessionTile(session: sessions[i]),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SessionTile extends ConsumerWidget {
|
||||
final DatalogSession session;
|
||||
const _SessionTile({required this.session});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fmt = DateFormat('dd MMM yyyy HH:mm');
|
||||
final isPlaying =
|
||||
ref.watch(playbackProvider).sessionId == session.id;
|
||||
|
||||
return Dismissible(
|
||||
key: ValueKey(session.id),
|
||||
direction: DismissDirection.endToStart,
|
||||
background: Container(
|
||||
color: Colors.red,
|
||||
alignment: Alignment.centerRight,
|
||||
padding: const EdgeInsets.only(right: 20),
|
||||
child: const Icon(Icons.delete, color: Colors.white),
|
||||
),
|
||||
confirmDismiss: (_) async {
|
||||
return await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Delete session?'),
|
||||
content: const Text('This cannot be undone.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('Cancel')),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('Delete',
|
||||
style: TextStyle(color: Colors.red))),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
onDismissed: (_) async {
|
||||
await ref
|
||||
.read(datalogDbProvider)
|
||||
.deleteSession(session.id!);
|
||||
ref.invalidate(sessionListProvider);
|
||||
},
|
||||
child: ListTile(
|
||||
leading: Icon(
|
||||
isPlaying ? Icons.play_circle : Icons.videocam,
|
||||
color: isPlaying ? Colors.green : Colors.grey,
|
||||
),
|
||||
title: Text(
|
||||
fmt.format(session.startTime),
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
subtitle: Text(
|
||||
'${session.ecuType.toUpperCase()} • '
|
||||
'${session.frameCount} frames • ${session.durationLabel}',
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 11),
|
||||
),
|
||||
trailing: const Icon(Icons.play_arrow, size: 18),
|
||||
onTap: () async {
|
||||
await ref
|
||||
.read(playbackProvider.notifier)
|
||||
.loadSession(session.id!);
|
||||
ref.read(playbackProvider.notifier).play();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../../core/models/datalog_session.dart';
|
||||
import '../../../core/providers/datalog_provider.dart';
|
||||
import 'datalog_playback_bar.dart';
|
||||
|
||||
class DatalogListScreen extends ConsumerWidget {
|
||||
const DatalogListScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final sessionsAsync = ref.watch(sessionListProvider);
|
||||
final playback = ref.watch(playbackProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Saved Sessions')),
|
||||
body: Column(
|
||||
children: [
|
||||
if (playback.isActive)
|
||||
DatalogPlaybackBar(state: playback),
|
||||
Expanded(
|
||||
child: sessionsAsync.when(
|
||||
loading: () =>
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(
|
||||
child: Text('Error: $e',
|
||||
style: const TextStyle(color: Colors.red))),
|
||||
data: (sessions) => sessions.isEmpty
|
||||
? const Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.videocam_off,
|
||||
size: 56, color: Colors.grey),
|
||||
SizedBox(height: 12),
|
||||
Text('No recorded sessions',
|
||||
style: TextStyle(color: Colors.grey)),
|
||||
],
|
||||
),
|
||||
)
|
||||
: ListView.separated(
|
||||
itemCount: sessions.length,
|
||||
separatorBuilder: (_, __) =>
|
||||
const Divider(height: 1, color: Colors.white10),
|
||||
itemBuilder: (context, i) =>
|
||||
_SessionTile(session: sessions[i]),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SessionTile extends ConsumerWidget {
|
||||
final DatalogSession session;
|
||||
const _SessionTile({required this.session});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fmt = DateFormat('dd MMM yyyy HH:mm');
|
||||
final isPlaying =
|
||||
ref.watch(playbackProvider).sessionId == session.id;
|
||||
|
||||
return Dismissible(
|
||||
key: ValueKey(session.id),
|
||||
direction: DismissDirection.endToStart,
|
||||
background: Container(
|
||||
color: Colors.red,
|
||||
alignment: Alignment.centerRight,
|
||||
padding: const EdgeInsets.only(right: 20),
|
||||
child: const Icon(Icons.delete, color: Colors.white),
|
||||
),
|
||||
confirmDismiss: (_) async {
|
||||
return await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Delete session?'),
|
||||
content: const Text('This cannot be undone.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('Cancel')),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('Delete',
|
||||
style: TextStyle(color: Colors.red))),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
onDismissed: (_) async {
|
||||
await ref
|
||||
.read(datalogDbProvider)
|
||||
.deleteSession(session.id!);
|
||||
ref.invalidate(sessionListProvider);
|
||||
},
|
||||
child: ListTile(
|
||||
leading: Icon(
|
||||
isPlaying ? Icons.play_circle : Icons.videocam,
|
||||
color: isPlaying ? Colors.green : Colors.grey,
|
||||
),
|
||||
title: Text(
|
||||
fmt.format(session.startTime),
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
subtitle: Text(
|
||||
'${session.ecuType.toUpperCase()} • '
|
||||
'${session.frameCount} frames • ${session.durationLabel}',
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 11),
|
||||
),
|
||||
trailing: const Icon(Icons.play_arrow, size: 18),
|
||||
onTap: () async {
|
||||
await ref
|
||||
.read(playbackProvider.notifier)
|
||||
.loadSession(session.id!);
|
||||
ref.read(playbackProvider.notifier).play();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,90 +1,90 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/datalog/datalog_player.dart';
|
||||
import '../../../core/providers/datalog_provider.dart';
|
||||
|
||||
class DatalogPlaybackBar extends ConsumerWidget {
|
||||
final PlaybackState state;
|
||||
const DatalogPlaybackBar({super.key, required this.state});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final notifier = ref.read(playbackProvider.notifier);
|
||||
final isPlaying = state.status == PlaybackStatus.playing;
|
||||
|
||||
return Container(
|
||||
color: const Color(0xFF1A1A1A),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Progress slider
|
||||
SliderTheme(
|
||||
data: SliderTheme.of(context).copyWith(
|
||||
trackHeight: 3,
|
||||
thumbShape:
|
||||
const RoundSliderThumbShape(enabledThumbRadius: 6),
|
||||
overlayShape:
|
||||
const RoundSliderOverlayShape(overlayRadius: 12),
|
||||
),
|
||||
child: Slider(
|
||||
value: state.progress,
|
||||
onChanged: (v) =>
|
||||
notifier.seek((v * state.total).round()),
|
||||
),
|
||||
),
|
||||
// Controls row
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
// Frame counter
|
||||
Text(
|
||||
'${state.position} / ${state.total}',
|
||||
style:
|
||||
const TextStyle(color: Colors.grey, fontSize: 11),
|
||||
),
|
||||
// Play / Pause / Stop
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
isPlaying ? Icons.pause : Icons.play_arrow,
|
||||
size: 22,
|
||||
),
|
||||
onPressed: isPlaying ? notifier.pause : notifier.play,
|
||||
padding: const EdgeInsets.all(4),
|
||||
constraints: const BoxConstraints(),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.stop, size: 22),
|
||||
onPressed: () async => notifier.stop(),
|
||||
padding: const EdgeInsets.all(4),
|
||||
constraints: const BoxConstraints(),
|
||||
),
|
||||
],
|
||||
),
|
||||
// Speed selector
|
||||
DropdownButton<double>(
|
||||
value: state.speed,
|
||||
isDense: true,
|
||||
underline: const SizedBox(),
|
||||
style: const TextStyle(
|
||||
color: Colors.white70, fontSize: 12),
|
||||
items: const [
|
||||
DropdownMenuItem(value: 0.5, child: Text('0.5×')),
|
||||
DropdownMenuItem(value: 1.0, child: Text('1×')),
|
||||
DropdownMenuItem(value: 2.0, child: Text('2×')),
|
||||
DropdownMenuItem(value: 5.0, child: Text('5×')),
|
||||
],
|
||||
onChanged: (v) {
|
||||
if (v != null) notifier.setSpeed(v);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/datalog/datalog_player.dart';
|
||||
import '../../../core/providers/datalog_provider.dart';
|
||||
|
||||
class DatalogPlaybackBar extends ConsumerWidget {
|
||||
final PlaybackState state;
|
||||
const DatalogPlaybackBar({super.key, required this.state});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final notifier = ref.read(playbackProvider.notifier);
|
||||
final isPlaying = state.status == PlaybackStatus.playing;
|
||||
|
||||
return Container(
|
||||
color: const Color(0xFF1A1A1A),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Progress slider
|
||||
SliderTheme(
|
||||
data: SliderTheme.of(context).copyWith(
|
||||
trackHeight: 3,
|
||||
thumbShape:
|
||||
const RoundSliderThumbShape(enabledThumbRadius: 6),
|
||||
overlayShape:
|
||||
const RoundSliderOverlayShape(overlayRadius: 12),
|
||||
),
|
||||
child: Slider(
|
||||
value: state.progress,
|
||||
onChanged: (v) =>
|
||||
notifier.seek((v * state.total).round()),
|
||||
),
|
||||
),
|
||||
// Controls row
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
// Frame counter
|
||||
Text(
|
||||
'${state.position} / ${state.total}',
|
||||
style:
|
||||
const TextStyle(color: Colors.grey, fontSize: 11),
|
||||
),
|
||||
// Play / Pause / Stop
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
isPlaying ? Icons.pause : Icons.play_arrow,
|
||||
size: 22,
|
||||
),
|
||||
onPressed: isPlaying ? notifier.pause : notifier.play,
|
||||
padding: const EdgeInsets.all(4),
|
||||
constraints: const BoxConstraints(),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.stop, size: 22),
|
||||
onPressed: () async => notifier.stop(),
|
||||
padding: const EdgeInsets.all(4),
|
||||
constraints: const BoxConstraints(),
|
||||
),
|
||||
],
|
||||
),
|
||||
// Speed selector
|
||||
DropdownButton<double>(
|
||||
value: state.speed,
|
||||
isDense: true,
|
||||
underline: const SizedBox(),
|
||||
style: const TextStyle(
|
||||
color: Colors.white70, fontSize: 12),
|
||||
items: const [
|
||||
DropdownMenuItem(value: 0.5, child: Text('0.5×')),
|
||||
DropdownMenuItem(value: 1.0, child: Text('1×')),
|
||||
DropdownMenuItem(value: 2.0, child: Text('2×')),
|
||||
DropdownMenuItem(value: 5.0, child: Text('5×')),
|
||||
],
|
||||
onChanged: (v) {
|
||||
if (v != null) notifier.setSpeed(v);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,265 +1,391 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/bluetooth/bt_poller.dart';
|
||||
import '../../../core/providers/bt_provider.dart';
|
||||
import '../../../core/providers/sensor_provider.dart';
|
||||
import '../../../core/providers/settings_provider.dart';
|
||||
import '../../../core/protocol/neg8.dart';
|
||||
import '../../../core/protocol/sensor_state.dart';
|
||||
|
||||
class RawFrameDebugScreen extends ConsumerWidget {
|
||||
const RawFrameDebugScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final bt = ref.watch(btProvider);
|
||||
final settings = ref.watch(settingsProvider);
|
||||
final sensorAsync = ref.watch(sensorStateProvider);
|
||||
final state = ref.watch(latestSensorProvider);
|
||||
final decodeStats = ref.watch(decodeStatsProvider);
|
||||
final frame = bt.lastFrame;
|
||||
final hex = frame == null ? '' : _hex(frame);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('ECU Frame Debug'),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Copy raw frame',
|
||||
icon: const Icon(Icons.copy),
|
||||
onPressed: frame == null
|
||||
? null
|
||||
: () async {
|
||||
await Clipboard.setData(ClipboardData(text: hex));
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Raw frame copied')),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(12),
|
||||
children: [
|
||||
_Section(
|
||||
title: 'Connection',
|
||||
rows: [
|
||||
_RowData('Protocol', settings.ecuType.name.toUpperCase()),
|
||||
_RowData('Frames (raw, from poller)', bt.frameCount.toString()),
|
||||
_RowData('Dropped (bad checksum)', bt.droppedFrames.toString()),
|
||||
_RowData('Last frame bytes', frame?.length.toString() ?? 'none'),
|
||||
if (frame != null)
|
||||
_RowData(
|
||||
'Checksum',
|
||||
validateFrame(frame) ? 'valid' : 'bad',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_Section(
|
||||
title: 'Decode Pipeline',
|
||||
rows: [
|
||||
_RowData('Decoded -> UI', decodeStats.decoded.toString()),
|
||||
_RowData('Skipped (decode error)', decodeStats.decodeErrors.toString()),
|
||||
_RowData(
|
||||
'sensorStateProvider raw',
|
||||
sensorAsync.when(
|
||||
data: (s) =>
|
||||
'data(rpm=${s.rpm.toStringAsFixed(0)}, age=${DateTime.now().difference(s.timestamp).inMilliseconds}ms)',
|
||||
loading: () => 'loading (no frame yet)',
|
||||
error: (e, st) => 'error: $e',
|
||||
),
|
||||
),
|
||||
_RowData(
|
||||
'latestSensorProvider age',
|
||||
'${DateTime.now().difference(state.timestamp).inMilliseconds}ms since decode',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_Section(
|
||||
title: 'Current App Decode',
|
||||
rows: _decodedRows(state),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (frame == null)
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Text(
|
||||
'No ECU frame received yet.',
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
),
|
||||
)
|
||||
else ...[
|
||||
if (settings.ecuType == EcuType.s300) ...[
|
||||
_Section(
|
||||
title: 'S300 Raw Candidates',
|
||||
rows: _s300CandidateRows(frame),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
_RawHexBlock(hex: hex),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<_RowData> _decodedRows(SensorState s) => [
|
||||
_RowData('RPM', s.rpm.toStringAsFixed(0)),
|
||||
_RowData('Speed km/h', s.vss.toStringAsFixed(2)),
|
||||
_RowData('MAP kPa', s.map.toStringAsFixed(2)),
|
||||
_RowData('TPS', s.tps.toStringAsFixed(2)),
|
||||
_RowData('ECT', s.ect.toStringAsFixed(2)),
|
||||
_RowData('IAT', s.iat.toStringAsFixed(2)),
|
||||
_RowData('Battery', s.bat.toStringAsFixed(2)),
|
||||
_RowData('O2', s.o2.toStringAsFixed(2)),
|
||||
_RowData('AFR', s.afr.toStringAsFixed(2)),
|
||||
];
|
||||
|
||||
List<_RowData> _s300CandidateRows(Uint8List f) {
|
||||
final rpmLe = _u16le(f, 3);
|
||||
final vssLe = _u16le(f, 5);
|
||||
final mapLe = _u16le(f, 7);
|
||||
|
||||
return [
|
||||
_RowData('Header bytes', '${_b(f, 0)} ${_b(f, 1)} ${_b(f, 2)}'),
|
||||
_RowData('RPM raw @3 LE', rpmLe.toString()),
|
||||
_RowData('VSS raw @5 LE', vssLe.toString()),
|
||||
_RowData('VSS current km/h', _s300Vss(vssLe).toStringAsFixed(2)),
|
||||
_RowData('MAP raw @7 LE', mapLe.toString()),
|
||||
_RowData('MAP current kPa', _s300Map(mapLe).toStringAsFixed(2)),
|
||||
_RowData('TPS raw @9', f[9].toString()),
|
||||
_RowData('TPS current', _s300Tps(f[9]).toStringAsFixed(2)),
|
||||
_RowData('Gear raw @27 (decimal)', f[27].toString()),
|
||||
_RowData('INJ raw @10 LE (offset unverified)', _u16le(f, 10).toString()),
|
||||
_RowData('IGN raw @12 (offset unverified)', f[12].toString()),
|
||||
_RowData('O2 current raw (offset unverified)', f[16].toString()),
|
||||
_RowData('ECT raw @45', f[0x2D].toString()),
|
||||
_RowData('IAT raw @46', f[0x2E].toString()),
|
||||
_RowData('BAT raw @48 (offset unverified)', f[0x30].toString()),
|
||||
_RowData('PA raw @44 (offset unverified)', f[0x2C].toString()),
|
||||
_RowData('ERR bytes @49-52',
|
||||
'${_b(f, 0x31)} ${_b(f, 0x32)} ${_b(f, 0x33)} ${_b(f, 0x34)}'),
|
||||
];
|
||||
}
|
||||
|
||||
static int _u16le(Uint8List f, int o) => f[o] | (f[o + 1] << 8);
|
||||
static String _b(Uint8List f, int o) =>
|
||||
f[o].toRadixString(16).padLeft(2, '0');
|
||||
|
||||
static double _s300Vss(int raw) {
|
||||
return (raw < 893 || raw == 0xFFFF) ? 0.0 : 228480.0 / raw;
|
||||
}
|
||||
|
||||
static double _s300Map(int raw) {
|
||||
return raw / 10.0;
|
||||
}
|
||||
|
||||
static double _s300Tps(int raw) {
|
||||
return (raw * 100.0 / 255.0).clamp(0.0, 100.0);
|
||||
}
|
||||
|
||||
static String _hex(Uint8List frame) {
|
||||
final parts = <String>[];
|
||||
for (var i = 0; i < frame.length; i++) {
|
||||
if (i > 0 && i % 16 == 0) parts.add('\n');
|
||||
parts.add(frame[i].toRadixString(16).padLeft(2, '0'));
|
||||
}
|
||||
return parts.join(' ');
|
||||
}
|
||||
}
|
||||
|
||||
class _Section extends StatelessWidget {
|
||||
final String title;
|
||||
final List<_RowData> rows;
|
||||
|
||||
const _Section({required this.title, required this.rows});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
for (final row in rows) _InfoRow(row),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoRow extends StatelessWidget {
|
||||
final _RowData row;
|
||||
|
||||
const _InfoRow(this.row);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 3),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
row.label,
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Flexible(
|
||||
child: Text(
|
||||
row.value,
|
||||
textAlign: TextAlign.right,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RawHexBlock extends StatelessWidget {
|
||||
final String hex;
|
||||
|
||||
const _RawHexBlock({required this.hex});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: SelectableText(
|
||||
hex,
|
||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 11),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RowData {
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
const _RowData(this.label, this.value);
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/bluetooth/bt_poller.dart';
|
||||
import '../../../core/providers/bt_provider.dart';
|
||||
import '../../../core/providers/sensor_provider.dart';
|
||||
import '../../../core/providers/settings_provider.dart';
|
||||
import '../../../core/protocol/neg8.dart';
|
||||
import '../../../core/protocol/sensor_state.dart';
|
||||
|
||||
class RawFrameDebugScreen extends ConsumerStatefulWidget {
|
||||
const RawFrameDebugScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<RawFrameDebugScreen> createState() =>
|
||||
_RawFrameDebugScreenState();
|
||||
}
|
||||
|
||||
class _RawFrameDebugScreenState extends ConsumerState<RawFrameDebugScreen> {
|
||||
Uint8List? _probeResult;
|
||||
bool _probing = false;
|
||||
|
||||
Future<void> _runProbe() async {
|
||||
setState(() {
|
||||
_probing = true;
|
||||
_probeResult = null;
|
||||
});
|
||||
final result =
|
||||
await ref.read(btProvider.notifier).probeOfficialProtocol();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_probing = false;
|
||||
_probeResult = result;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bt = ref.watch(btProvider);
|
||||
final settings = ref.watch(settingsProvider);
|
||||
final sensorAsync = ref.watch(sensorStateProvider);
|
||||
final state = ref.watch(latestSensorProvider);
|
||||
final decodeStats = ref.watch(decodeStatsProvider);
|
||||
final frame = bt.lastFrame;
|
||||
final hex = frame == null ? '' : _hex(frame);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('ECU Frame Debug'),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Copy raw frame',
|
||||
icon: const Icon(Icons.copy),
|
||||
onPressed: frame == null
|
||||
? null
|
||||
: () async {
|
||||
await Clipboard.setData(ClipboardData(text: hex));
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Raw frame copied')),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(12),
|
||||
children: [
|
||||
_Section(
|
||||
title: 'Connection',
|
||||
rows: [
|
||||
_RowData('Protocol', settings.ecuType.name.toUpperCase()),
|
||||
_RowData('Frames (raw, from poller)', bt.frameCount.toString()),
|
||||
_RowData('Dropped (bad checksum)', bt.droppedFrames.toString()),
|
||||
_RowData('Last frame bytes', frame?.length.toString() ?? 'none'),
|
||||
if (frame != null)
|
||||
_RowData(
|
||||
'Checksum',
|
||||
validateFrame(frame) ? 'valid' : 'bad',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_ProtocolProbeCard(
|
||||
connected: bt.isConnected,
|
||||
probing: _probing,
|
||||
result: _probeResult,
|
||||
onProbe: _runProbe,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_Section(
|
||||
title: 'Decode Pipeline',
|
||||
rows: [
|
||||
_RowData('Decoded -> UI', decodeStats.decoded.toString()),
|
||||
_RowData('Skipped (decode error)', decodeStats.decodeErrors.toString()),
|
||||
_RowData(
|
||||
'sensorStateProvider raw',
|
||||
sensorAsync.when(
|
||||
data: (s) =>
|
||||
'data(rpm=${s.rpm.toStringAsFixed(0)}, age=${DateTime.now().difference(s.timestamp).inMilliseconds}ms)',
|
||||
loading: () => 'loading (no frame yet)',
|
||||
error: (e, st) => 'error: $e',
|
||||
),
|
||||
),
|
||||
_RowData(
|
||||
'latestSensorProvider age',
|
||||
'${DateTime.now().difference(state.timestamp).inMilliseconds}ms since decode',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_Section(
|
||||
title: 'Current App Decode',
|
||||
rows: _decodedRows(state),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (frame == null)
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Text(
|
||||
'No ECU frame received yet.',
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
),
|
||||
)
|
||||
else ...[
|
||||
if (settings.ecuType == EcuType.s300) ...[
|
||||
_Section(
|
||||
title: 'S300 Raw Candidates',
|
||||
rows: _s300CandidateRows(frame),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
_RawHexBlock(hex: hex),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<_RowData> _decodedRows(SensorState s) => [
|
||||
_RowData('RPM', s.rpm.toStringAsFixed(0)),
|
||||
_RowData('Speed km/h', s.vss.toStringAsFixed(2)),
|
||||
_RowData('MAP kPa', s.map.toStringAsFixed(2)),
|
||||
_RowData('TPS', s.tps.toStringAsFixed(2)),
|
||||
_RowData('ECT', s.ect.toStringAsFixed(2)),
|
||||
_RowData('IAT', s.iat.toStringAsFixed(2)),
|
||||
_RowData('Battery', s.bat.toStringAsFixed(2)),
|
||||
_RowData('O2', s.o2.toStringAsFixed(2)),
|
||||
_RowData('AFR', s.afr.toStringAsFixed(2)),
|
||||
_RowData('MIL (unverified)', s.mil.toString()),
|
||||
_RowData('Fuel Pump', s.fuelPump.toString()),
|
||||
_RowData('Fan Out (unverified)', s.fanOut.toString()),
|
||||
_RowData('VTEC (unverified)', s.vtec.toString()),
|
||||
_RowData('Knock', s.knock.toString()),
|
||||
_RowData('Rev Limit (unverified)', s.revLimit.toString()),
|
||||
_RowData('Launch (unverified)', s.launch.toString()),
|
||||
];
|
||||
|
||||
List<_RowData> _s300CandidateRows(Uint8List f) {
|
||||
final rpmLe = _u16le(f, 3);
|
||||
final vssLe = _u16le(f, 5);
|
||||
final mapLe = _u16le(f, 7);
|
||||
|
||||
return [
|
||||
_RowData('Header bytes', '${_b(f, 0)} ${_b(f, 1)} ${_b(f, 2)}'),
|
||||
_RowData('RPM raw @3 LE', rpmLe.toString()),
|
||||
_RowData('VSS raw @5 LE', vssLe.toString()),
|
||||
_RowData('VSS current km/h', _s300Vss(vssLe).toStringAsFixed(2)),
|
||||
_RowData('MAP raw @7 LE', mapLe.toString()),
|
||||
_RowData('MAP current kPa', _s300Map(mapLe).toStringAsFixed(2)),
|
||||
_RowData('TPS raw @9', f[9].toString()),
|
||||
_RowData('TPS current', _s300Tps(f[9]).toStringAsFixed(2)),
|
||||
_RowData('Gear raw @27 (decimal)', f[27].toString()),
|
||||
_RowData('INJ raw @10 LE (offset unverified)', _u16le(f, 10).toString()),
|
||||
_RowData('IGN raw @12 (offset unverified)', f[12].toString()),
|
||||
_RowData('O2 current raw (offset unverified)', f[16].toString()),
|
||||
_RowData('ECT raw @33', f[33].toString()),
|
||||
_RowData('IAT raw @34', f[34].toString()),
|
||||
_RowData('BAT raw @36', f[36].toString()),
|
||||
_RowData('PA raw @44 (offset unverified)', f[0x2C].toString()),
|
||||
_RowData('PA candidate raw @50 (unconfirmed, ~3kPa off)', f[50].toString()),
|
||||
_RowData('ERR bytes @49-52',
|
||||
'${_b(f, 0x31)} ${_b(f, 0x32)} ${_b(f, 0x33)} ${_b(f, 0x34)}'),
|
||||
_RowData('SW1 @17 (bit7..0)', '${_b(f, 0x11)} ${_bin(f, 0x11)}'),
|
||||
_RowData('SW2 @18 (bit7..0)', '${_b(f, 0x12)} ${_bin(f, 0x12)}'),
|
||||
_RowData('SW3 @19 (bit7..0)', '${_b(f, 0x13)} ${_bin(f, 0x13)}'),
|
||||
_RowData('SW5 @67 (bit7..0)', '${_b(f, 0x43)} ${_bin(f, 0x43)}'),
|
||||
];
|
||||
}
|
||||
|
||||
static int _u16le(Uint8List f, int o) => f[o] | (f[o + 1] << 8);
|
||||
static String _b(Uint8List f, int o) =>
|
||||
f[o].toRadixString(16).padLeft(2, '0');
|
||||
static String _bin(Uint8List f, int o) =>
|
||||
f[o].toRadixString(2).padLeft(8, '0');
|
||||
|
||||
static double _s300Vss(int raw) {
|
||||
return (raw < 893 || raw == 0xFFFF) ? 0.0 : 228480.0 / raw;
|
||||
}
|
||||
|
||||
static double _s300Map(int raw) {
|
||||
return raw / 10.0;
|
||||
}
|
||||
|
||||
static double _s300Tps(int raw) {
|
||||
return (raw * 100.0 / 255.0).clamp(0.0, 100.0);
|
||||
}
|
||||
|
||||
static String _hex(Uint8List frame) {
|
||||
final parts = <String>[];
|
||||
for (var i = 0; i < frame.length; i++) {
|
||||
if (i > 0 && i % 16 == 0) parts.add('\n');
|
||||
parts.add(frame[i].toRadixString(16).padLeft(2, '0'));
|
||||
}
|
||||
return parts.join(' ');
|
||||
}
|
||||
}
|
||||
|
||||
/// Step 1 of the data-layer review: checks whether this device/firmware
|
||||
/// understands the official, self-describing Hondata Bluetooth protocol
|
||||
/// (Bluetooth.pdf) rather than only the legacy fixed-offset frame format
|
||||
/// this app currently speaks. A real reply here (Protocol=0x00, ID=0x02,
|
||||
/// with a recognizable DeviceType byte) means migrating to that protocol is
|
||||
/// on the table; silence means this hardware is legacy-only.
|
||||
class _ProtocolProbeCard extends StatelessWidget {
|
||||
final bool connected;
|
||||
final bool probing;
|
||||
final Uint8List? result;
|
||||
final VoidCallback onProbe;
|
||||
|
||||
const _ProtocolProbeCard({
|
||||
required this.connected,
|
||||
required this.probing,
|
||||
required this.result,
|
||||
required this.onProbe,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Official Protocol Probe',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
const Text(
|
||||
'Sends DL_DeviceInfo (00 02 04 00). Pauses normal polling for '
|
||||
'the duration of the probe.',
|
||||
style: TextStyle(color: Colors.grey, fontSize: 11),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: (connected && !probing) ? onProbe : null,
|
||||
child: Text(probing ? 'Probing…' : 'Send Probe'),
|
||||
),
|
||||
),
|
||||
if (result != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
for (final row in _interpret(result!)) _InfoRow(row),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<_RowData> _interpret(Uint8List r) {
|
||||
if (r.isEmpty) {
|
||||
return const [
|
||||
_RowData('Result', 'No response — likely legacy protocol only'),
|
||||
];
|
||||
}
|
||||
|
||||
final hex = r.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ');
|
||||
final rows = [_RowData('Raw bytes', hex)];
|
||||
if (r.length >= 4) {
|
||||
final size = r[2] | (r[3] << 8); // little-endian per TDLHeader.Size
|
||||
rows.add(_RowData('Protocol / ID / Size', '${r[0]} / ${r[1]} / $size'));
|
||||
}
|
||||
if (r.length >= 6) {
|
||||
final deviceType = switch (r[4]) {
|
||||
0xC0 => 'S300',
|
||||
0xC1 => 'KPro',
|
||||
0xC2 => 'FlashPro',
|
||||
_ => 'unknown (0x${r[4].toRadixString(16)})',
|
||||
};
|
||||
rows.add(_RowData('Device type', deviceType));
|
||||
rows.add(_RowData('Ignition', r[5] != 0 ? 'On' : 'Off'));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
|
||||
class _Section extends StatelessWidget {
|
||||
final String title;
|
||||
final List<_RowData> rows;
|
||||
|
||||
const _Section({required this.title, required this.rows});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
for (final row in rows) _InfoRow(row),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoRow extends StatelessWidget {
|
||||
final _RowData row;
|
||||
|
||||
const _InfoRow(this.row);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 3),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
row.label,
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Flexible(
|
||||
child: Text(
|
||||
row.value,
|
||||
textAlign: TextAlign.right,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RawHexBlock extends StatelessWidget {
|
||||
final String hex;
|
||||
|
||||
const _RawHexBlock({required this.hex});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: SelectableText(
|
||||
hex,
|
||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 11),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RowData {
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
const _RowData(this.label, this.value);
|
||||
}
|
||||
|
||||
@ -1,92 +1,232 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/protocol/dtc_map.dart';
|
||||
import '../../../core/providers/sensor_provider.dart';
|
||||
import '../../../core/providers/settings_provider.dart';
|
||||
import '../../../core/bluetooth/bt_poller.dart';
|
||||
import 'widgets/dtc_row.dart';
|
||||
|
||||
class DtcScreen extends ConsumerWidget {
|
||||
const DtcScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final state = ref.watch(latestSensorProvider);
|
||||
final ecuType = ref.watch(settingsProvider).ecuType;
|
||||
|
||||
final dtcMap =
|
||||
ecuType == EcuType.s300 ? s300DtcMap : kproDtcMap;
|
||||
final active = decodeDtcs(state.errBytes, dtcMap);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// Header bar
|
||||
Container(
|
||||
color: const Color(0xFF1A1A1A),
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
active.isEmpty
|
||||
? Icons.check_circle_outline
|
||||
: Icons.warning_amber_rounded,
|
||||
color: active.isEmpty ? Colors.green : Colors.amber,
|
||||
size: 18,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
active.isEmpty
|
||||
? 'No active fault codes'
|
||||
: '${active.length} active DTC${active.length > 1 ? "s" : ""}',
|
||||
style: TextStyle(
|
||||
color: active.isEmpty ? Colors.green : Colors.amber,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
ecuType == EcuType.s300 ? 'S300' : 'KPro',
|
||||
style:
|
||||
const TextStyle(color: Colors.grey, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1, color: Colors.white10),
|
||||
|
||||
// DTC list
|
||||
Expanded(
|
||||
child: active.isEmpty
|
||||
? const Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.check_circle_outline,
|
||||
size: 56, color: Colors.green),
|
||||
SizedBox(height: 12),
|
||||
Text('All clear — no fault codes',
|
||||
style: TextStyle(
|
||||
color: Colors.grey, fontSize: 15)),
|
||||
],
|
||||
),
|
||||
)
|
||||
: ListView.separated(
|
||||
itemCount: active.length,
|
||||
separatorBuilder: (_, __) =>
|
||||
const Divider(height: 1, color: Colors.white10),
|
||||
itemBuilder: (context, i) {
|
||||
final full = active[i]; // "P0130 — O2 Sensor (front)"
|
||||
final parts = full.split(' — ');
|
||||
final code = parts.first.trim();
|
||||
final desc =
|
||||
parts.length > 1 ? parts.sublist(1).join(' — ') : '';
|
||||
return DtcRow(code: code, description: desc);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/protocol/dtc_map.dart';
|
||||
import '../../../core/providers/bt_provider.dart';
|
||||
import '../../../core/providers/sensor_provider.dart';
|
||||
import '../../../core/providers/settings_provider.dart';
|
||||
import '../../../core/bluetooth/bt_poller.dart';
|
||||
import 'widgets/dtc_row.dart';
|
||||
|
||||
class DtcScreen extends ConsumerStatefulWidget {
|
||||
const DtcScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<DtcScreen> createState() => _DtcScreenState();
|
||||
}
|
||||
|
||||
class _DtcScreenState extends ConsumerState<DtcScreen> {
|
||||
bool _clearing = false;
|
||||
Uint8List? _lastRaw;
|
||||
|
||||
String _hex(Uint8List bytes) {
|
||||
final parts = <String>[];
|
||||
for (var i = 0; i < bytes.length; i++) {
|
||||
if (i > 0 && i % 16 == 0) parts.add('\n');
|
||||
parts.add(bytes[i].toRadixString(16).padLeft(2, '0'));
|
||||
}
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
void _showRawCapture() {
|
||||
final raw = _lastRaw;
|
||||
if (raw == null) return;
|
||||
final hex = _hex(raw);
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text('Raw capture (${raw.length} bytes)'),
|
||||
content: SizedBox(
|
||||
width: double.maxFinite,
|
||||
child: SingleChildScrollView(
|
||||
child: SelectableText(
|
||||
hex,
|
||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 11),
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
await Clipboard.setData(ClipboardData(text: hex));
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Copied')),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Text('Copy'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Close'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _confirmAndClear() async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Clear Trouble Codes'),
|
||||
content: const Text(
|
||||
'This sends a reset command to the ECU and erases all stored '
|
||||
'fault codes. This cannot be undone.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Clear',
|
||||
style: TextStyle(color: Colors.red)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
|
||||
setState(() => _clearing = true);
|
||||
final result = await ref.read(btProvider.notifier).clearDtcs();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_clearing = false;
|
||||
_lastRaw = result.raw;
|
||||
});
|
||||
|
||||
final String message;
|
||||
if (result.acknowledged) {
|
||||
message = 'Trouble codes cleared';
|
||||
} else if (result.raw.isEmpty) {
|
||||
message = 'No response from ECU — codes may not have cleared';
|
||||
} else {
|
||||
// The scan runs alongside normal polling now, so "not acknowledged"
|
||||
// can mean several seconds' worth of regular datalog traffic with no
|
||||
// ack byte pattern found in it — dumping all of that raw would be
|
||||
// unusable in a snackbar, so just report how much was seen. "View"
|
||||
// opens the full hex to check by hand for anything our narrow 3-byte
|
||||
// pattern match might have missed.
|
||||
message =
|
||||
'No ack seen — ${result.raw.length} bytes of other traffic during the window';
|
||||
}
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
action: (!result.acknowledged && result.raw.isNotEmpty)
|
||||
? SnackBarAction(label: 'View', onPressed: _showRawCapture)
|
||||
: null,
|
||||
duration: const Duration(seconds: 6),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = ref.watch(latestSensorProvider);
|
||||
final bt = ref.watch(btProvider);
|
||||
final ecuType = ref.watch(settingsProvider).ecuType;
|
||||
|
||||
final dtcMap =
|
||||
ecuType == EcuType.s300 ? s300DtcMap : kproDtcMap;
|
||||
final active = decodeDtcs(state.errBytes, dtcMap);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// Header bar
|
||||
Container(
|
||||
color: const Color(0xFF1A1A1A),
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
active.isEmpty
|
||||
? Icons.check_circle_outline
|
||||
: Icons.warning_amber_rounded,
|
||||
color: active.isEmpty ? Colors.green : Colors.amber,
|
||||
size: 18,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
active.isEmpty
|
||||
? 'No active fault codes'
|
||||
: '${active.length} active DTC${active.length > 1 ? "s" : ""}',
|
||||
style: TextStyle(
|
||||
color: active.isEmpty ? Colors.green : Colors.amber,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
ecuType == EcuType.s300 ? 'S300' : 'KPro',
|
||||
style:
|
||||
const TextStyle(color: Colors.grey, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1, color: Colors.white10),
|
||||
|
||||
// DTC list
|
||||
Expanded(
|
||||
child: active.isEmpty
|
||||
? const Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.check_circle_outline,
|
||||
size: 56, color: Colors.green),
|
||||
SizedBox(height: 12),
|
||||
Text('All clear — no fault codes',
|
||||
style: TextStyle(
|
||||
color: Colors.grey, fontSize: 15)),
|
||||
],
|
||||
),
|
||||
)
|
||||
: ListView.separated(
|
||||
itemCount: active.length,
|
||||
separatorBuilder: (_, __) =>
|
||||
const Divider(height: 1, color: Colors.white10),
|
||||
itemBuilder: (context, i) {
|
||||
final full = active[i]; // "P0130 — O2 Sensor (front)"
|
||||
final parts = full.split(' — ');
|
||||
final code = parts.first.trim();
|
||||
final desc =
|
||||
parts.length > 1 ? parts.sublist(1).join(' — ') : '';
|
||||
return DtcRow(code: code, description: desc);
|
||||
},
|
||||
),
|
||||
),
|
||||
SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.red,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
),
|
||||
onPressed: (bt.isConnected && !_clearing)
|
||||
? _confirmAndClear
|
||||
: null,
|
||||
child: Text(
|
||||
_clearing ? 'Clearing…' : 'CLEAR TROUBLE CODES',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,44 +1,44 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class DtcRow extends StatelessWidget {
|
||||
final String code;
|
||||
final String description;
|
||||
|
||||
const DtcRow({super.key, required this.code, required this.description});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.withAlpha(30),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: Colors.red.withAlpha(100)),
|
||||
),
|
||||
child: Text(
|
||||
code,
|
||||
style: const TextStyle(
|
||||
color: Colors.redAccent,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
description,
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class DtcRow extends StatelessWidget {
|
||||
final String code;
|
||||
final String description;
|
||||
|
||||
const DtcRow({super.key, required this.code, required this.description});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.withAlpha(30),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: Colors.red.withAlpha(100)),
|
||||
),
|
||||
child: Text(
|
||||
code,
|
||||
style: const TextStyle(
|
||||
color: Colors.redAccent,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
description,
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,136 +1,136 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/models/sensor_defs.dart';
|
||||
import '../../../core/providers/sensor_history_provider.dart';
|
||||
import '../../../core/providers/theme_provider.dart';
|
||||
import '../../../ui/theme/app_colors.dart';
|
||||
import 'widgets/graph_time_selector.dart';
|
||||
import 'widgets/sensor_graph_panel.dart';
|
||||
|
||||
final _graphWindowProvider = StateProvider<int>((ref) => 60);
|
||||
final _graphSensorsProvider = StateProvider<List<String>>(
|
||||
(ref) => ['rpm', 'map', 'tps'],
|
||||
);
|
||||
|
||||
class GraphScreen extends ConsumerWidget {
|
||||
const GraphScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final history = ref.watch(sensorHistoryProvider);
|
||||
final colors = AppColors.forTheme(ref.watch(themeProvider));
|
||||
final window = ref.watch(_graphWindowProvider);
|
||||
final sensors = ref.watch(_graphSensorsProvider);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// Time selector bar
|
||||
Container(
|
||||
color: const Color(0xFF1A1A1A),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
const Text('Window:',
|
||||
style: TextStyle(color: Colors.grey, fontSize: 12)),
|
||||
const SizedBox(width: 8),
|
||||
GraphTimeSelector(
|
||||
windowSeconds: window,
|
||||
onChanged: (s) =>
|
||||
ref.read(_graphWindowProvider.notifier).state = s,
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: Icon(Icons.add_chart, color: colors.accent, size: 20),
|
||||
tooltip: 'Add sensor',
|
||||
onPressed: () => _addSensor(context, ref, sensors),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Graph panels
|
||||
Expanded(
|
||||
child: sensors.isEmpty
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.show_chart,
|
||||
size: 48, color: colors.accent.withAlpha(80)),
|
||||
const SizedBox(height: 12),
|
||||
const Text('No sensors selected',
|
||||
style: TextStyle(color: Colors.grey)),
|
||||
const SizedBox(height: 8),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => _addSensor(context, ref, sensors),
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: const Text('Add sensor'),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
itemCount: sensors.length,
|
||||
itemBuilder: (context, i) => SensorGraphPanel(
|
||||
sensorId: sensors[i],
|
||||
history: history,
|
||||
windowSeconds: window,
|
||||
accentColor: colors.accent,
|
||||
onRemove: () {
|
||||
final next = List<String>.from(sensors)..removeAt(i);
|
||||
ref.read(_graphSensorsProvider.notifier).state = next;
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _addSensor(
|
||||
BuildContext context, WidgetRef ref, List<String> current) {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
backgroundColor: const Color(0xFF1A1A1A),
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
builder: (_) => ListView(
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Text('ADD SENSOR TO GRAPH',
|
||||
style: TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 12,
|
||||
letterSpacing: 2)),
|
||||
),
|
||||
const Divider(height: 1, color: Colors.white10),
|
||||
...sensorDefs.map((def) {
|
||||
final already = current.contains(def.id);
|
||||
return ListTile(
|
||||
title: Text(def.displayName,
|
||||
style: TextStyle(
|
||||
color: already ? Colors.grey : Colors.white)),
|
||||
subtitle: Text(def.unit,
|
||||
style: const TextStyle(
|
||||
color: Colors.grey, fontSize: 11)),
|
||||
trailing: already
|
||||
? const Icon(Icons.check,
|
||||
color: Colors.grey, size: 16)
|
||||
: null,
|
||||
onTap: already
|
||||
? null
|
||||
: () {
|
||||
ref.read(_graphSensorsProvider.notifier).state =
|
||||
[...current, def.id];
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/models/sensor_defs.dart';
|
||||
import '../../../core/providers/sensor_history_provider.dart';
|
||||
import '../../../core/providers/theme_provider.dart';
|
||||
import '../../../ui/theme/app_colors.dart';
|
||||
import 'widgets/graph_time_selector.dart';
|
||||
import 'widgets/sensor_graph_panel.dart';
|
||||
|
||||
final _graphWindowProvider = StateProvider<int>((ref) => 60);
|
||||
final _graphSensorsProvider = StateProvider<List<String>>(
|
||||
(ref) => ['rpm', 'map', 'tps'],
|
||||
);
|
||||
|
||||
class GraphScreen extends ConsumerWidget {
|
||||
const GraphScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final history = ref.watch(sensorHistoryProvider);
|
||||
final colors = AppColors.forTheme(ref.watch(themeProvider));
|
||||
final window = ref.watch(_graphWindowProvider);
|
||||
final sensors = ref.watch(_graphSensorsProvider);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// Time selector bar
|
||||
Container(
|
||||
color: const Color(0xFF1A1A1A),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
const Text('Window:',
|
||||
style: TextStyle(color: Colors.grey, fontSize: 12)),
|
||||
const SizedBox(width: 8),
|
||||
GraphTimeSelector(
|
||||
windowSeconds: window,
|
||||
onChanged: (s) =>
|
||||
ref.read(_graphWindowProvider.notifier).state = s,
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: Icon(Icons.add_chart, color: colors.accent, size: 20),
|
||||
tooltip: 'Add sensor',
|
||||
onPressed: () => _addSensor(context, ref, sensors),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Graph panels
|
||||
Expanded(
|
||||
child: sensors.isEmpty
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.show_chart,
|
||||
size: 48, color: colors.accent.withAlpha(80)),
|
||||
const SizedBox(height: 12),
|
||||
const Text('No sensors selected',
|
||||
style: TextStyle(color: Colors.grey)),
|
||||
const SizedBox(height: 8),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => _addSensor(context, ref, sensors),
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: const Text('Add sensor'),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
itemCount: sensors.length,
|
||||
itemBuilder: (context, i) => SensorGraphPanel(
|
||||
sensorId: sensors[i],
|
||||
history: history,
|
||||
windowSeconds: window,
|
||||
accentColor: colors.accent,
|
||||
onRemove: () {
|
||||
final next = List<String>.from(sensors)..removeAt(i);
|
||||
ref.read(_graphSensorsProvider.notifier).state = next;
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _addSensor(
|
||||
BuildContext context, WidgetRef ref, List<String> current) {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
backgroundColor: const Color(0xFF1A1A1A),
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
builder: (_) => ListView(
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Text('ADD SENSOR TO GRAPH',
|
||||
style: TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 12,
|
||||
letterSpacing: 2)),
|
||||
),
|
||||
const Divider(height: 1, color: Colors.white10),
|
||||
...sensorDefs.map((def) {
|
||||
final already = current.contains(def.id);
|
||||
return ListTile(
|
||||
title: Text(def.displayName,
|
||||
style: TextStyle(
|
||||
color: already ? Colors.grey : Colors.white)),
|
||||
subtitle: Text(def.unit,
|
||||
style: const TextStyle(
|
||||
color: Colors.grey, fontSize: 11)),
|
||||
trailing: already
|
||||
? const Icon(Icons.check,
|
||||
color: Colors.grey, size: 16)
|
||||
: null,
|
||||
onTap: already
|
||||
? null
|
||||
: () {
|
||||
ref.read(_graphSensorsProvider.notifier).state =
|
||||
[...current, def.id];
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,49 +1,49 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class GraphTimeSelector extends StatelessWidget {
|
||||
final int windowSeconds;
|
||||
final void Function(int seconds) onChanged;
|
||||
|
||||
static const _options = [30, 60, 120, 300];
|
||||
|
||||
const GraphTimeSelector({
|
||||
super.key,
|
||||
required this.windowSeconds,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final accent = Theme.of(context).colorScheme.primary;
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: _options.map((s) {
|
||||
final selected = s == windowSeconds;
|
||||
return GestureDetector(
|
||||
onTap: () => onChanged(s),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 120),
|
||||
margin: const EdgeInsets.only(left: 4),
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? accent.withAlpha(30) : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: selected ? accent : Colors.white24,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
s >= 60 ? '${s ~/ 60}m' : '${s}s',
|
||||
style: TextStyle(
|
||||
color: selected ? accent : Colors.white54,
|
||||
fontSize: 12,
|
||||
fontWeight: selected ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class GraphTimeSelector extends StatelessWidget {
|
||||
final int windowSeconds;
|
||||
final void Function(int seconds) onChanged;
|
||||
|
||||
static const _options = [30, 60, 120, 300];
|
||||
|
||||
const GraphTimeSelector({
|
||||
super.key,
|
||||
required this.windowSeconds,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final accent = Theme.of(context).colorScheme.primary;
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: _options.map((s) {
|
||||
final selected = s == windowSeconds;
|
||||
return GestureDetector(
|
||||
onTap: () => onChanged(s),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 120),
|
||||
margin: const EdgeInsets.only(left: 4),
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? accent.withAlpha(30) : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: selected ? accent : Colors.white24,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
s >= 60 ? '${s ~/ 60}m' : '${s}s',
|
||||
style: TextStyle(
|
||||
color: selected ? accent : Colors.white54,
|
||||
fontSize: 12,
|
||||
fontWeight: selected ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,133 +1,133 @@
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../core/models/sensor_defs.dart';
|
||||
import '../../../../core/providers/sensor_history_provider.dart';
|
||||
|
||||
class SensorGraphPanel extends StatelessWidget {
|
||||
final String sensorId;
|
||||
final SensorHistory history;
|
||||
final int windowSeconds;
|
||||
final Color accentColor;
|
||||
final VoidCallback onRemove;
|
||||
|
||||
const SensorGraphPanel({
|
||||
super.key,
|
||||
required this.sensorId,
|
||||
required this.history,
|
||||
required this.windowSeconds,
|
||||
required this.accentColor,
|
||||
required this.onRemove,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final def = sensorDefs.firstWhere(
|
||||
(d) => d.id == sensorId,
|
||||
orElse: () => sensorDefs.first,
|
||||
);
|
||||
final allSpots = history.get(sensorId);
|
||||
|
||||
// Slice to window
|
||||
final spots = _window(allSpots, windowSeconds.toDouble());
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
padding: const EdgeInsets.fromLTRB(8, 10, 12, 8),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF1A1A1A),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
def.displayName.toUpperCase(),
|
||||
style: TextStyle(
|
||||
color: accentColor,
|
||||
fontSize: 11,
|
||||
letterSpacing: 1.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
def.unit,
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 11),
|
||||
),
|
||||
const Spacer(),
|
||||
if (spots.isNotEmpty)
|
||||
Text(
|
||||
_fmt(spots.last.y),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
GestureDetector(
|
||||
onTap: onRemove,
|
||||
child: const Icon(Icons.close,
|
||||
size: 16, color: Colors.white38),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Chart
|
||||
SizedBox(
|
||||
height: 90,
|
||||
child: spots.length < 2
|
||||
? const Center(
|
||||
child: Text('Waiting for data…',
|
||||
style: TextStyle(color: Colors.white30,
|
||||
fontSize: 12)))
|
||||
: LineChart(
|
||||
LineChartData(
|
||||
gridData: FlGridData(
|
||||
show: true,
|
||||
drawVerticalLine: false,
|
||||
getDrawingHorizontalLine: (_) => FlLine(
|
||||
color: Colors.white10,
|
||||
strokeWidth: 1,
|
||||
),
|
||||
),
|
||||
borderData: FlBorderData(show: false),
|
||||
titlesData: const FlTitlesData(show: false),
|
||||
minY: def.min.toDouble(),
|
||||
maxY: def.max.toDouble(),
|
||||
lineBarsData: [
|
||||
LineChartBarData(
|
||||
spots: spots,
|
||||
isCurved: true,
|
||||
curveSmoothness: 0.2,
|
||||
color: accentColor,
|
||||
barWidth: 1.5,
|
||||
dotData: const FlDotData(show: false),
|
||||
belowBarData: BarAreaData(
|
||||
show: true,
|
||||
color: accentColor.withAlpha(25),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
duration: Duration.zero,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<FlSpot> _window(List<FlSpot> spots, double windowSec) {
|
||||
if (spots.isEmpty) return spots;
|
||||
final maxX = spots.last.x;
|
||||
final minX = maxX - windowSec;
|
||||
return spots.where((s) => s.x >= minX).toList();
|
||||
}
|
||||
|
||||
String _fmt(double v) =>
|
||||
v.abs() >= 100 ? v.toStringAsFixed(1) : v.toStringAsFixed(2);
|
||||
}
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../core/models/sensor_defs.dart';
|
||||
import '../../../../core/providers/sensor_history_provider.dart';
|
||||
|
||||
class SensorGraphPanel extends StatelessWidget {
|
||||
final String sensorId;
|
||||
final SensorHistory history;
|
||||
final int windowSeconds;
|
||||
final Color accentColor;
|
||||
final VoidCallback onRemove;
|
||||
|
||||
const SensorGraphPanel({
|
||||
super.key,
|
||||
required this.sensorId,
|
||||
required this.history,
|
||||
required this.windowSeconds,
|
||||
required this.accentColor,
|
||||
required this.onRemove,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final def = sensorDefs.firstWhere(
|
||||
(d) => d.id == sensorId,
|
||||
orElse: () => sensorDefs.first,
|
||||
);
|
||||
final allSpots = history.get(sensorId);
|
||||
|
||||
// Slice to window
|
||||
final spots = _window(allSpots, windowSeconds.toDouble());
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
padding: const EdgeInsets.fromLTRB(8, 10, 12, 8),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF1A1A1A),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
def.displayName.toUpperCase(),
|
||||
style: TextStyle(
|
||||
color: accentColor,
|
||||
fontSize: 11,
|
||||
letterSpacing: 1.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
def.unit,
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 11),
|
||||
),
|
||||
const Spacer(),
|
||||
if (spots.isNotEmpty)
|
||||
Text(
|
||||
_fmt(spots.last.y),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
GestureDetector(
|
||||
onTap: onRemove,
|
||||
child: const Icon(Icons.close,
|
||||
size: 16, color: Colors.white38),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Chart
|
||||
SizedBox(
|
||||
height: 90,
|
||||
child: spots.length < 2
|
||||
? const Center(
|
||||
child: Text('Waiting for data…',
|
||||
style: TextStyle(color: Colors.white30,
|
||||
fontSize: 12)))
|
||||
: LineChart(
|
||||
LineChartData(
|
||||
gridData: FlGridData(
|
||||
show: true,
|
||||
drawVerticalLine: false,
|
||||
getDrawingHorizontalLine: (_) => FlLine(
|
||||
color: Colors.white10,
|
||||
strokeWidth: 1,
|
||||
),
|
||||
),
|
||||
borderData: FlBorderData(show: false),
|
||||
titlesData: const FlTitlesData(show: false),
|
||||
minY: def.min.toDouble(),
|
||||
maxY: def.max.toDouble(),
|
||||
lineBarsData: [
|
||||
LineChartBarData(
|
||||
spots: spots,
|
||||
isCurved: true,
|
||||
curveSmoothness: 0.2,
|
||||
color: accentColor,
|
||||
barWidth: 1.5,
|
||||
dotData: const FlDotData(show: false),
|
||||
belowBarData: BarAreaData(
|
||||
show: true,
|
||||
color: accentColor.withAlpha(25),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
duration: Duration.zero,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<FlSpot> _window(List<FlSpot> spots, double windowSec) {
|
||||
if (spots.isEmpty) return spots;
|
||||
final maxX = spots.last.x;
|
||||
final minX = maxX - windowSec;
|
||||
return spots.where((s) => s.x >= minX).toList();
|
||||
}
|
||||
|
||||
String _fmt(double v) =>
|
||||
v.abs() >= 100 ? v.toStringAsFixed(1) : v.toStringAsFixed(2);
|
||||
}
|
||||
|
||||
@ -1,31 +1,31 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/models/sensor_defs.dart';
|
||||
import '../../../core/providers/sensor_provider.dart';
|
||||
import '../../../core/providers/theme_provider.dart';
|
||||
import '../../../ui/theme/app_colors.dart';
|
||||
import 'widgets/sensor_row.dart';
|
||||
|
||||
class SensorListScreen extends ConsumerWidget {
|
||||
const SensorListScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final state = ref.watch(latestSensorProvider);
|
||||
final colors = AppColors.forTheme(ref.watch(themeProvider));
|
||||
|
||||
return ListView.separated(
|
||||
itemCount: sensorDefs.length,
|
||||
separatorBuilder: (_, __) =>
|
||||
const Divider(height: 1, color: Colors.white10),
|
||||
itemBuilder: (context, i) {
|
||||
final def = sensorDefs[i];
|
||||
return SensorRow(
|
||||
def: def,
|
||||
value: sensorValue(state, def.id),
|
||||
accentColor: colors.accent,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/models/sensor_defs.dart';
|
||||
import '../../../core/providers/sensor_provider.dart';
|
||||
import '../../../core/providers/theme_provider.dart';
|
||||
import '../../../ui/theme/app_colors.dart';
|
||||
import 'widgets/sensor_row.dart';
|
||||
|
||||
class SensorListScreen extends ConsumerWidget {
|
||||
const SensorListScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final state = ref.watch(latestSensorProvider);
|
||||
final colors = AppColors.forTheme(ref.watch(themeProvider));
|
||||
|
||||
return ListView.separated(
|
||||
itemCount: sensorDefs.length,
|
||||
separatorBuilder: (_, __) =>
|
||||
const Divider(height: 1, color: Colors.white10),
|
||||
itemBuilder: (context, i) {
|
||||
final def = sensorDefs[i];
|
||||
return SensorRow(
|
||||
def: def,
|
||||
value: sensorValue(state, def.id),
|
||||
accentColor: colors.accent,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,130 +1,130 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../core/models/sensor_def.dart';
|
||||
import '../../../../core/protocol/sensor_state.dart';
|
||||
|
||||
class SensorRow extends StatelessWidget {
|
||||
final SensorDef def;
|
||||
final double value;
|
||||
final Color accentColor;
|
||||
|
||||
const SensorRow({
|
||||
super.key,
|
||||
required this.def,
|
||||
required this.value,
|
||||
required this.accentColor,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final normalized =
|
||||
((value - def.min) / (def.max - def.min)).clamp(0.0, 1.0);
|
||||
final isHigh = normalized > 0.8;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
|
||||
child: Row(
|
||||
children: [
|
||||
// Label
|
||||
SizedBox(
|
||||
width: 52,
|
||||
child: Text(
|
||||
def.displayName,
|
||||
style: const TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 12,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Bar gauge
|
||||
Expanded(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(height: 14, color: Colors.white10),
|
||||
FractionallySizedBox(
|
||||
widthFactor: normalized,
|
||||
child: Container(
|
||||
height: 14,
|
||||
decoration: BoxDecoration(
|
||||
color: isHigh
|
||||
? Colors.red.withAlpha(200)
|
||||
: accentColor.withAlpha(180),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
// Value + unit
|
||||
SizedBox(
|
||||
width: 80,
|
||||
child: RichText(
|
||||
textAlign: TextAlign.right,
|
||||
text: TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: _fmt(value),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: ' ${def.unit}',
|
||||
style: TextStyle(
|
||||
color: accentColor,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _fmt(double v) {
|
||||
if (def.id == 'rpm' || def.id == 'gear') return v.toStringAsFixed(0);
|
||||
if (v.abs() >= 100) return v.toStringAsFixed(1);
|
||||
return v.toStringAsFixed(2);
|
||||
}
|
||||
}
|
||||
|
||||
double sensorValue(SensorState s, String id) {
|
||||
switch (id) {
|
||||
case 'rpm': return s.rpm;
|
||||
case 'vss': return s.vss;
|
||||
case 'map': return s.map;
|
||||
case 'tps': return s.tps;
|
||||
case 'inj': return s.inj;
|
||||
case 'ign': return s.ign;
|
||||
case 'ect': return s.ect;
|
||||
case 'iat': return s.iat;
|
||||
case 'bat': return s.bat;
|
||||
case 'o2': return s.o2;
|
||||
case 'gear': return s.gear.toDouble();
|
||||
case 'eth': return s.eth;
|
||||
case 'pa': return s.pa;
|
||||
case 'afr': return s.afr;
|
||||
case 'strim': return s.strim;
|
||||
case 'ltrim': return s.ltrim;
|
||||
case 'ain0': return s.ain0;
|
||||
case 'ain1': return s.ain1;
|
||||
case 'ain2': return s.ain2;
|
||||
case 'ain3': return s.ain3;
|
||||
case 'ain4': return s.ain4;
|
||||
case 'ain5': return s.ain5;
|
||||
case 'ain6': return s.ain6;
|
||||
case 'ain7': return s.ain7;
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../core/models/sensor_def.dart';
|
||||
import '../../../../core/protocol/sensor_state.dart';
|
||||
|
||||
class SensorRow extends StatelessWidget {
|
||||
final SensorDef def;
|
||||
final double value;
|
||||
final Color accentColor;
|
||||
|
||||
const SensorRow({
|
||||
super.key,
|
||||
required this.def,
|
||||
required this.value,
|
||||
required this.accentColor,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final normalized =
|
||||
((value - def.min) / (def.max - def.min)).clamp(0.0, 1.0);
|
||||
final isHigh = normalized > 0.8;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
|
||||
child: Row(
|
||||
children: [
|
||||
// Label
|
||||
SizedBox(
|
||||
width: 52,
|
||||
child: Text(
|
||||
def.displayName,
|
||||
style: const TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 12,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Bar gauge
|
||||
Expanded(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(height: 14, color: Colors.white10),
|
||||
FractionallySizedBox(
|
||||
widthFactor: normalized,
|
||||
child: Container(
|
||||
height: 14,
|
||||
decoration: BoxDecoration(
|
||||
color: isHigh
|
||||
? Colors.red.withAlpha(200)
|
||||
: accentColor.withAlpha(180),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
// Value + unit
|
||||
SizedBox(
|
||||
width: 80,
|
||||
child: RichText(
|
||||
textAlign: TextAlign.right,
|
||||
text: TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: _fmt(value),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: ' ${def.unit}',
|
||||
style: TextStyle(
|
||||
color: accentColor,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _fmt(double v) {
|
||||
if (def.id == 'rpm' || def.id == 'gear') return v.toStringAsFixed(0);
|
||||
if (v.abs() >= 100) return v.toStringAsFixed(1);
|
||||
return v.toStringAsFixed(2);
|
||||
}
|
||||
}
|
||||
|
||||
double sensorValue(SensorState s, String id) {
|
||||
switch (id) {
|
||||
case 'rpm': return s.rpm;
|
||||
case 'vss': return s.vss;
|
||||
case 'map': return s.map;
|
||||
case 'tps': return s.tps;
|
||||
case 'inj': return s.inj;
|
||||
case 'ign': return s.ign;
|
||||
case 'ect': return s.ect;
|
||||
case 'iat': return s.iat;
|
||||
case 'bat': return s.bat;
|
||||
case 'o2': return s.o2;
|
||||
case 'gear': return s.gear.toDouble();
|
||||
case 'eth': return s.eth;
|
||||
case 'pa': return s.pa;
|
||||
case 'afr': return s.afr;
|
||||
case 'strim': return s.strim;
|
||||
case 'ltrim': return s.ltrim;
|
||||
case 'ain0': return s.ain0;
|
||||
case 'ain1': return s.ain1;
|
||||
case 'ain2': return s.ain2;
|
||||
case 'ain3': return s.ain3;
|
||||
case 'ain4': return s.ain4;
|
||||
case 'ain5': return s.ain5;
|
||||
case 'ain6': return s.ain6;
|
||||
case 'ain7': return s.ain7;
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,151 +1,151 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/bluetooth/bt_poller.dart';
|
||||
import '../../../core/providers/bt_provider.dart';
|
||||
import '../../../core/providers/settings_provider.dart';
|
||||
import '../../../core/providers/theme_provider.dart';
|
||||
import '../../theme/app_colors.dart';
|
||||
import '../bt_picker/bt_picker_screen.dart';
|
||||
|
||||
class SettingsScreen extends ConsumerWidget {
|
||||
const SettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final settings = ref.watch(settingsProvider);
|
||||
final themeVariant = ref.watch(themeProvider);
|
||||
final bt = ref.watch(btProvider);
|
||||
final colors = AppColors.forTheme(themeVariant);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Settings')),
|
||||
body: ListView(
|
||||
children: [
|
||||
// ── Connection ───────────────────────────────────────────
|
||||
_SectionHeader('CONNECTION'),
|
||||
ListTile(
|
||||
leading: Icon(Icons.bluetooth, color: colors.accent),
|
||||
title: const Text('Bluetooth Device'),
|
||||
subtitle: Text(
|
||||
bt.isConnected ? 'Connected: ${bt.deviceName}' : 'Not connected',
|
||||
style:
|
||||
TextStyle(color: bt.isConnected ? Colors.green : Colors.grey),
|
||||
),
|
||||
trailing: bt.isConnected
|
||||
? TextButton(
|
||||
onPressed: () async {
|
||||
await ref.read(btProvider.notifier).disconnect();
|
||||
},
|
||||
child: const Text('Disconnect',
|
||||
style: TextStyle(color: Colors.red)),
|
||||
)
|
||||
: const Icon(Icons.arrow_forward_ios,
|
||||
size: 14, color: Colors.grey),
|
||||
onTap: bt.isConnected
|
||||
? null
|
||||
: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const BtPickerScreen()),
|
||||
),
|
||||
),
|
||||
const Divider(height: 1, color: Colors.white10),
|
||||
|
||||
// ── ECU Protocol ─────────────────────────────────────────
|
||||
_SectionHeader('ECU PROTOCOL'),
|
||||
RadioListTile<EcuType>(
|
||||
title: const Text('S300'),
|
||||
subtitle: const Text('Honda S300 ECU',
|
||||
style: TextStyle(color: Colors.grey, fontSize: 12)),
|
||||
value: EcuType.s300,
|
||||
groupValue: settings.ecuType,
|
||||
activeColor: colors.accent,
|
||||
onChanged: (v) {
|
||||
if (v != null) ref.read(settingsProvider.notifier).setEcuType(v);
|
||||
},
|
||||
),
|
||||
RadioListTile<EcuType>(
|
||||
title: const Text('KPro'),
|
||||
subtitle: const Text('Hondata KPro ECU',
|
||||
style: TextStyle(color: Colors.grey, fontSize: 12)),
|
||||
value: EcuType.kpro,
|
||||
groupValue: settings.ecuType,
|
||||
activeColor: colors.accent,
|
||||
onChanged: (v) {
|
||||
if (v != null) ref.read(settingsProvider.notifier).setEcuType(v);
|
||||
},
|
||||
),
|
||||
const Divider(height: 1, color: Colors.white10),
|
||||
|
||||
// ── Polling Interval ─────────────────────────────────────
|
||||
_SectionHeader('POLLING INTERVAL'),
|
||||
...[20, 50, 100, 200].map((ms) => RadioListTile<int>(
|
||||
title: Text('${ms}ms (~${(1000 / ms).round()} Hz)'),
|
||||
value: ms,
|
||||
groupValue: settings.pollingInterval.inMilliseconds,
|
||||
activeColor: colors.accent,
|
||||
onChanged: (v) {
|
||||
if (v != null) {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setPollingInterval(Duration(milliseconds: v));
|
||||
}
|
||||
},
|
||||
)),
|
||||
const Divider(height: 1, color: Colors.white10),
|
||||
|
||||
// ── Theme ────────────────────────────────────────────────
|
||||
_SectionHeader('THEME'),
|
||||
...AppThemeVariant.values.map((v) {
|
||||
final label = switch (v) {
|
||||
AppThemeVariant.redDark => 'Red — Dark',
|
||||
AppThemeVariant.redLight => 'Red — Light',
|
||||
AppThemeVariant.greenDark => 'Green — Dark',
|
||||
AppThemeVariant.greenLight => 'Green — Light',
|
||||
};
|
||||
return RadioListTile<AppThemeVariant>(
|
||||
title: Text(label),
|
||||
value: v,
|
||||
groupValue: themeVariant,
|
||||
activeColor: AppColors.forTheme(v).accent,
|
||||
onChanged: (val) {
|
||||
if (val != null) ref.read(themeProvider.notifier).set(val);
|
||||
},
|
||||
);
|
||||
}),
|
||||
const Divider(height: 1, color: Colors.white10),
|
||||
|
||||
// ── App info ─────────────────────────────────────────────
|
||||
_SectionHeader('ABOUT'),
|
||||
const ListTile(
|
||||
title: Text('HV BT Dashboard'),
|
||||
subtitle: Text('v1.4.0 — Phase 4',
|
||||
style: TextStyle(color: Colors.grey, fontSize: 12)),
|
||||
leading: Icon(Icons.info_outline, color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
final String title;
|
||||
const _SectionHeader(this.title);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 20, 16, 6),
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 11,
|
||||
letterSpacing: 1.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/bluetooth/bt_poller.dart';
|
||||
import '../../../core/providers/bt_provider.dart';
|
||||
import '../../../core/providers/settings_provider.dart';
|
||||
import '../../../core/providers/theme_provider.dart';
|
||||
import '../../theme/app_colors.dart';
|
||||
import '../bt_picker/bt_picker_screen.dart';
|
||||
|
||||
class SettingsScreen extends ConsumerWidget {
|
||||
const SettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final settings = ref.watch(settingsProvider);
|
||||
final themeVariant = ref.watch(themeProvider);
|
||||
final bt = ref.watch(btProvider);
|
||||
final colors = AppColors.forTheme(themeVariant);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Settings')),
|
||||
body: ListView(
|
||||
children: [
|
||||
// ── Connection ───────────────────────────────────────────
|
||||
_SectionHeader('CONNECTION'),
|
||||
ListTile(
|
||||
leading: Icon(Icons.bluetooth, color: colors.accent),
|
||||
title: const Text('Bluetooth Device'),
|
||||
subtitle: Text(
|
||||
bt.isConnected ? 'Connected: ${bt.deviceName}' : 'Not connected',
|
||||
style:
|
||||
TextStyle(color: bt.isConnected ? Colors.green : Colors.grey),
|
||||
),
|
||||
trailing: bt.isConnected
|
||||
? TextButton(
|
||||
onPressed: () async {
|
||||
await ref.read(btProvider.notifier).disconnect();
|
||||
},
|
||||
child: const Text('Disconnect',
|
||||
style: TextStyle(color: Colors.red)),
|
||||
)
|
||||
: const Icon(Icons.arrow_forward_ios,
|
||||
size: 14, color: Colors.grey),
|
||||
onTap: bt.isConnected
|
||||
? null
|
||||
: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const BtPickerScreen()),
|
||||
),
|
||||
),
|
||||
const Divider(height: 1, color: Colors.white10),
|
||||
|
||||
// ── ECU Protocol ─────────────────────────────────────────
|
||||
_SectionHeader('ECU PROTOCOL'),
|
||||
RadioListTile<EcuType>(
|
||||
title: const Text('S300'),
|
||||
subtitle: const Text('Honda S300 ECU',
|
||||
style: TextStyle(color: Colors.grey, fontSize: 12)),
|
||||
value: EcuType.s300,
|
||||
groupValue: settings.ecuType,
|
||||
activeColor: colors.accent,
|
||||
onChanged: (v) {
|
||||
if (v != null) ref.read(settingsProvider.notifier).setEcuType(v);
|
||||
},
|
||||
),
|
||||
RadioListTile<EcuType>(
|
||||
title: const Text('KPro'),
|
||||
subtitle: const Text('Hondata KPro ECU',
|
||||
style: TextStyle(color: Colors.grey, fontSize: 12)),
|
||||
value: EcuType.kpro,
|
||||
groupValue: settings.ecuType,
|
||||
activeColor: colors.accent,
|
||||
onChanged: (v) {
|
||||
if (v != null) ref.read(settingsProvider.notifier).setEcuType(v);
|
||||
},
|
||||
),
|
||||
const Divider(height: 1, color: Colors.white10),
|
||||
|
||||
// ── Polling Interval ─────────────────────────────────────
|
||||
_SectionHeader('POLLING INTERVAL'),
|
||||
...[20, 50, 100, 200].map((ms) => RadioListTile<int>(
|
||||
title: Text('${ms}ms (~${(1000 / ms).round()} Hz)'),
|
||||
value: ms,
|
||||
groupValue: settings.pollingInterval.inMilliseconds,
|
||||
activeColor: colors.accent,
|
||||
onChanged: (v) {
|
||||
if (v != null) {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setPollingInterval(Duration(milliseconds: v));
|
||||
}
|
||||
},
|
||||
)),
|
||||
const Divider(height: 1, color: Colors.white10),
|
||||
|
||||
// ── Theme ────────────────────────────────────────────────
|
||||
_SectionHeader('THEME'),
|
||||
...AppThemeVariant.values.map((v) {
|
||||
final label = switch (v) {
|
||||
AppThemeVariant.redDark => 'Red — Dark',
|
||||
AppThemeVariant.redLight => 'Red — Light',
|
||||
AppThemeVariant.greenDark => 'Green — Dark',
|
||||
AppThemeVariant.greenLight => 'Green — Light',
|
||||
};
|
||||
return RadioListTile<AppThemeVariant>(
|
||||
title: Text(label),
|
||||
value: v,
|
||||
groupValue: themeVariant,
|
||||
activeColor: AppColors.forTheme(v).accent,
|
||||
onChanged: (val) {
|
||||
if (val != null) ref.read(themeProvider.notifier).set(val);
|
||||
},
|
||||
);
|
||||
}),
|
||||
const Divider(height: 1, color: Colors.white10),
|
||||
|
||||
// ── App info ─────────────────────────────────────────────
|
||||
_SectionHeader('ABOUT'),
|
||||
const ListTile(
|
||||
title: Text('HV BT Dashboard'),
|
||||
subtitle: Text('v1.4.0 — Phase 4',
|
||||
style: TextStyle(color: Colors.grey, fontSize: 12)),
|
||||
leading: Icon(Icons.info_outline, color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
final String title;
|
||||
const _SectionHeader(this.title);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 20, 16, 6),
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 11,
|
||||
letterSpacing: 1.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,65 +1,65 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../core/providers/theme_provider.dart';
|
||||
|
||||
class AppColors {
|
||||
final Color accent;
|
||||
final Color background;
|
||||
final Color surface;
|
||||
final Color surfaceVariant;
|
||||
final Color onSurface;
|
||||
final Color onSurfaceMuted;
|
||||
|
||||
const AppColors._({
|
||||
required this.accent,
|
||||
required this.background,
|
||||
required this.surface,
|
||||
required this.surfaceVariant,
|
||||
required this.onSurface,
|
||||
required this.onSurfaceMuted,
|
||||
});
|
||||
|
||||
static const redDark = AppColors._(
|
||||
accent: Color(0xFFFF4444),
|
||||
background: Color(0xFF0A0A0A),
|
||||
surface: Color(0xFF1A1A1A),
|
||||
surfaceVariant: Color(0xFF242424),
|
||||
onSurface: Colors.white,
|
||||
onSurfaceMuted: Colors.grey,
|
||||
);
|
||||
|
||||
static const redLight = AppColors._(
|
||||
accent: Color(0xFFCC0000),
|
||||
background: Color(0xFFF2F2F2),
|
||||
surface: Color(0xFFFFFFFF),
|
||||
surfaceVariant: Color(0xFFEAEAEA),
|
||||
onSurface: Color(0xFF111111),
|
||||
onSurfaceMuted: Color(0xFF777777),
|
||||
);
|
||||
|
||||
static const greenDark = AppColors._(
|
||||
accent: Color(0xFF00E676),
|
||||
background: Color(0xFF0A0A0A),
|
||||
surface: Color(0xFF1A1A1A),
|
||||
surfaceVariant: Color(0xFF242424),
|
||||
onSurface: Colors.white,
|
||||
onSurfaceMuted: Colors.grey,
|
||||
);
|
||||
|
||||
static const greenLight = AppColors._(
|
||||
accent: Color(0xFF00875A),
|
||||
background: Color(0xFFF2F2F2),
|
||||
surface: Color(0xFFFFFFFF),
|
||||
surfaceVariant: Color(0xFFEAEAEA),
|
||||
onSurface: Color(0xFF111111),
|
||||
onSurfaceMuted: Color(0xFF777777),
|
||||
);
|
||||
|
||||
static AppColors forTheme(AppThemeVariant v) {
|
||||
switch (v) {
|
||||
case AppThemeVariant.redDark: return redDark;
|
||||
case AppThemeVariant.redLight: return redLight;
|
||||
case AppThemeVariant.greenDark: return greenDark;
|
||||
case AppThemeVariant.greenLight: return greenLight;
|
||||
}
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../core/providers/theme_provider.dart';
|
||||
|
||||
class AppColors {
|
||||
final Color accent;
|
||||
final Color background;
|
||||
final Color surface;
|
||||
final Color surfaceVariant;
|
||||
final Color onSurface;
|
||||
final Color onSurfaceMuted;
|
||||
|
||||
const AppColors._({
|
||||
required this.accent,
|
||||
required this.background,
|
||||
required this.surface,
|
||||
required this.surfaceVariant,
|
||||
required this.onSurface,
|
||||
required this.onSurfaceMuted,
|
||||
});
|
||||
|
||||
static const redDark = AppColors._(
|
||||
accent: Color(0xFFFF4444),
|
||||
background: Color(0xFF0A0A0A),
|
||||
surface: Color(0xFF1A1A1A),
|
||||
surfaceVariant: Color(0xFF242424),
|
||||
onSurface: Colors.white,
|
||||
onSurfaceMuted: Colors.grey,
|
||||
);
|
||||
|
||||
static const redLight = AppColors._(
|
||||
accent: Color(0xFFCC0000),
|
||||
background: Color(0xFFF2F2F2),
|
||||
surface: Color(0xFFFFFFFF),
|
||||
surfaceVariant: Color(0xFFEAEAEA),
|
||||
onSurface: Color(0xFF111111),
|
||||
onSurfaceMuted: Color(0xFF777777),
|
||||
);
|
||||
|
||||
static const greenDark = AppColors._(
|
||||
accent: Color(0xFF00E676),
|
||||
background: Color(0xFF0A0A0A),
|
||||
surface: Color(0xFF1A1A1A),
|
||||
surfaceVariant: Color(0xFF242424),
|
||||
onSurface: Colors.white,
|
||||
onSurfaceMuted: Colors.grey,
|
||||
);
|
||||
|
||||
static const greenLight = AppColors._(
|
||||
accent: Color(0xFF00875A),
|
||||
background: Color(0xFFF2F2F2),
|
||||
surface: Color(0xFFFFFFFF),
|
||||
surfaceVariant: Color(0xFFEAEAEA),
|
||||
onSurface: Color(0xFF111111),
|
||||
onSurfaceMuted: Color(0xFF777777),
|
||||
);
|
||||
|
||||
static AppColors forTheme(AppThemeVariant v) {
|
||||
switch (v) {
|
||||
case AppThemeVariant.redDark: return redDark;
|
||||
case AppThemeVariant.redLight: return redLight;
|
||||
case AppThemeVariant.greenDark: return greenDark;
|
||||
case AppThemeVariant.greenLight: return greenLight;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,55 +1,55 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../core/providers/theme_provider.dart';
|
||||
import 'app_colors.dart';
|
||||
|
||||
class AppTheme {
|
||||
static ThemeData build(AppThemeVariant variant) {
|
||||
final colors = AppColors.forTheme(variant);
|
||||
final isDark = variant.isDark;
|
||||
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: isDark ? Brightness.dark : Brightness.light,
|
||||
scaffoldBackgroundColor: colors.background,
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: colors.accent,
|
||||
brightness: isDark ? Brightness.dark : Brightness.light,
|
||||
).copyWith(
|
||||
primary: colors.accent,
|
||||
surface: colors.surface,
|
||||
),
|
||||
appBarTheme: AppBarTheme(
|
||||
backgroundColor: colors.surface,
|
||||
foregroundColor: colors.onSurface,
|
||||
elevation: 0,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
),
|
||||
bottomNavigationBarTheme: BottomNavigationBarThemeData(
|
||||
backgroundColor: colors.surface,
|
||||
selectedItemColor: colors.accent,
|
||||
unselectedItemColor: colors.onSurfaceMuted,
|
||||
type: BottomNavigationBarType.fixed,
|
||||
elevation: 8,
|
||||
),
|
||||
cardTheme: CardThemeData(
|
||||
color: colors.surface,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
dividerTheme: DividerThemeData(
|
||||
color: colors.surfaceVariant,
|
||||
thickness: 1,
|
||||
),
|
||||
floatingActionButtonTheme: FloatingActionButtonThemeData(
|
||||
backgroundColor: colors.accent,
|
||||
foregroundColor: isDark ? Colors.black : Colors.white,
|
||||
),
|
||||
textTheme: TextTheme(
|
||||
bodyMedium: TextStyle(color: colors.onSurface),
|
||||
bodySmall: TextStyle(color: colors.onSurfaceMuted),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../core/providers/theme_provider.dart';
|
||||
import 'app_colors.dart';
|
||||
|
||||
class AppTheme {
|
||||
static ThemeData build(AppThemeVariant variant) {
|
||||
final colors = AppColors.forTheme(variant);
|
||||
final isDark = variant.isDark;
|
||||
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: isDark ? Brightness.dark : Brightness.light,
|
||||
scaffoldBackgroundColor: colors.background,
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: colors.accent,
|
||||
brightness: isDark ? Brightness.dark : Brightness.light,
|
||||
).copyWith(
|
||||
primary: colors.accent,
|
||||
surface: colors.surface,
|
||||
),
|
||||
appBarTheme: AppBarTheme(
|
||||
backgroundColor: colors.surface,
|
||||
foregroundColor: colors.onSurface,
|
||||
elevation: 0,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
),
|
||||
bottomNavigationBarTheme: BottomNavigationBarThemeData(
|
||||
backgroundColor: colors.surface,
|
||||
selectedItemColor: colors.accent,
|
||||
unselectedItemColor: colors.onSurfaceMuted,
|
||||
type: BottomNavigationBarType.fixed,
|
||||
elevation: 8,
|
||||
),
|
||||
cardTheme: CardThemeData(
|
||||
color: colors.surface,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
dividerTheme: DividerThemeData(
|
||||
color: colors.surfaceVariant,
|
||||
thickness: 1,
|
||||
),
|
||||
floatingActionButtonTheme: FloatingActionButtonThemeData(
|
||||
backgroundColor: colors.accent,
|
||||
foregroundColor: isDark ? Colors.black : Colors.white,
|
||||
),
|
||||
textTheme: TextTheme(
|
||||
bodyMedium: TextStyle(color: colors.onSurface),
|
||||
bodySmall: TextStyle(color: colors.onSurfaceMuted),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,38 +1,38 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../core/providers/bt_provider.dart';
|
||||
|
||||
class BtStatusChip extends ConsumerWidget {
|
||||
const BtStatusChip({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final bt = ref.watch(btProvider);
|
||||
|
||||
final (color, icon, label) = switch (bt.status) {
|
||||
BtStatus.connected => (Colors.green, Icons.bluetooth_connected, bt.deviceName ?? 'Connected'),
|
||||
BtStatus.connecting => (Colors.orange, Icons.bluetooth_searching, 'Connecting…'),
|
||||
BtStatus.error => (Colors.red, Icons.bluetooth_disabled, 'Error'),
|
||||
BtStatus.disconnected=> (Colors.grey, Icons.bluetooth, 'Disconnected'),
|
||||
};
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 14, color: color),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../core/providers/bt_provider.dart';
|
||||
|
||||
class BtStatusChip extends ConsumerWidget {
|
||||
const BtStatusChip({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final bt = ref.watch(btProvider);
|
||||
|
||||
final (color, icon, label) = switch (bt.status) {
|
||||
BtStatus.connected => (Colors.green, Icons.bluetooth_connected, bt.deviceName ?? 'Connected'),
|
||||
BtStatus.connecting => (Colors.orange, Icons.bluetooth_searching, 'Connecting…'),
|
||||
BtStatus.error => (Colors.red, Icons.bluetooth_disabled, 'Error'),
|
||||
BtStatus.disconnected=> (Colors.grey, Icons.bluetooth, 'Disconnected'),
|
||||
};
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 14, color: color),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,92 +1,92 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../screens/dashboard/dashboard_screen.dart';
|
||||
import '../screens/sensor_list/sensor_list_screen.dart';
|
||||
import '../screens/graph/graph_screen.dart';
|
||||
import '../screens/dtc/dtc_screen.dart';
|
||||
import '../screens/datalog/datalog_list_screen.dart';
|
||||
import '../screens/debug/raw_frame_debug_screen.dart';
|
||||
import '../screens/settings/settings_screen.dart';
|
||||
import 'bt_status_chip.dart';
|
||||
import 'record_fab.dart';
|
||||
|
||||
final _navIndexProvider = StateProvider<int>((ref) => 0);
|
||||
|
||||
class MainNav extends ConsumerWidget {
|
||||
const MainNav({super.key});
|
||||
|
||||
static const _screens = [
|
||||
DashboardScreen(),
|
||||
SensorListScreen(),
|
||||
GraphScreen(),
|
||||
DtcScreen(),
|
||||
];
|
||||
|
||||
static const _items = [
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.speed),
|
||||
label: 'Dashboard',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.list_alt),
|
||||
label: 'Sensors',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.show_chart),
|
||||
label: 'Graph',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.warning_amber_outlined),
|
||||
label: 'DTC',
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final index = ref.watch(_navIndexProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('HV BT Dashboard'),
|
||||
actions: [
|
||||
const BtStatusChip(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.folder_open_outlined),
|
||||
tooltip: 'Saved Sessions',
|
||||
onPressed: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const DatalogListScreen()),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.bug_report_outlined),
|
||||
tooltip: 'ECU Frame Debug',
|
||||
onPressed: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const RawFrameDebugScreen()),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.settings_outlined),
|
||||
tooltip: 'Settings',
|
||||
onPressed: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const SettingsScreen()),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: IndexedStack(
|
||||
index: index,
|
||||
children: _screens,
|
||||
),
|
||||
bottomNavigationBar: BottomNavigationBar(
|
||||
currentIndex: index,
|
||||
items: _items,
|
||||
onTap: (i) => ref.read(_navIndexProvider.notifier).state = i,
|
||||
),
|
||||
floatingActionButton: const RecordFab(),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../screens/dashboard/dashboard_screen.dart';
|
||||
import '../screens/sensor_list/sensor_list_screen.dart';
|
||||
import '../screens/graph/graph_screen.dart';
|
||||
import '../screens/dtc/dtc_screen.dart';
|
||||
import '../screens/datalog/datalog_list_screen.dart';
|
||||
import '../screens/debug/raw_frame_debug_screen.dart';
|
||||
import '../screens/settings/settings_screen.dart';
|
||||
import 'bt_status_chip.dart';
|
||||
import 'record_fab.dart';
|
||||
|
||||
final _navIndexProvider = StateProvider<int>((ref) => 0);
|
||||
|
||||
class MainNav extends ConsumerWidget {
|
||||
const MainNav({super.key});
|
||||
|
||||
static const _screens = [
|
||||
DashboardScreen(),
|
||||
SensorListScreen(),
|
||||
GraphScreen(),
|
||||
DtcScreen(),
|
||||
];
|
||||
|
||||
static const _items = [
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.speed),
|
||||
label: 'Dashboard',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.list_alt),
|
||||
label: 'Sensors',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.show_chart),
|
||||
label: 'Graph',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.warning_amber_outlined),
|
||||
label: 'DTC',
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final index = ref.watch(_navIndexProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('HV BT Dashboard'),
|
||||
actions: [
|
||||
const BtStatusChip(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.folder_open_outlined),
|
||||
tooltip: 'Saved Sessions',
|
||||
onPressed: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const DatalogListScreen()),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.bug_report_outlined),
|
||||
tooltip: 'ECU Frame Debug',
|
||||
onPressed: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const RawFrameDebugScreen()),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.settings_outlined),
|
||||
tooltip: 'Settings',
|
||||
onPressed: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const SettingsScreen()),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: IndexedStack(
|
||||
index: index,
|
||||
children: _screens,
|
||||
),
|
||||
bottomNavigationBar: BottomNavigationBar(
|
||||
currentIndex: index,
|
||||
items: _items,
|
||||
onTap: (i) => ref.read(_navIndexProvider.notifier).state = i,
|
||||
),
|
||||
floatingActionButton: const RecordFab(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,48 +1,48 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../core/providers/bt_provider.dart';
|
||||
import '../../core/providers/datalog_provider.dart';
|
||||
|
||||
class RecordFab extends ConsumerWidget {
|
||||
const RecordFab({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final recording = ref.watch(recordingProvider);
|
||||
final bt = ref.watch(btProvider);
|
||||
|
||||
// Only show when BT is connected
|
||||
if (!bt.isConnected) return const SizedBox.shrink();
|
||||
|
||||
final isRecording = recording.isRecording;
|
||||
|
||||
return FloatingActionButton(
|
||||
heroTag: 'record_fab',
|
||||
backgroundColor: isRecording ? Colors.red : Theme.of(context).colorScheme.primary,
|
||||
onPressed: () async {
|
||||
if (isRecording) {
|
||||
await ref.read(recordingProvider.notifier).stopRecording();
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Session saved — ${recording.frameCount} frames',
|
||||
),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
await ref.read(recordingProvider.notifier).startRecording();
|
||||
}
|
||||
},
|
||||
tooltip: isRecording ? 'Stop recording' : 'Start recording',
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: isRecording
|
||||
? const Icon(Icons.stop, key: ValueKey('stop'))
|
||||
: const Icon(Icons.fiber_manual_record, key: ValueKey('rec')),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../core/providers/bt_provider.dart';
|
||||
import '../../core/providers/datalog_provider.dart';
|
||||
|
||||
class RecordFab extends ConsumerWidget {
|
||||
const RecordFab({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final recording = ref.watch(recordingProvider);
|
||||
final bt = ref.watch(btProvider);
|
||||
|
||||
// Only show when BT is connected
|
||||
if (!bt.isConnected) return const SizedBox.shrink();
|
||||
|
||||
final isRecording = recording.isRecording;
|
||||
|
||||
return FloatingActionButton(
|
||||
heroTag: 'record_fab',
|
||||
backgroundColor: isRecording ? Colors.red : Theme.of(context).colorScheme.primary,
|
||||
onPressed: () async {
|
||||
if (isRecording) {
|
||||
await ref.read(recordingProvider.notifier).stopRecording();
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Session saved — ${recording.frameCount} frames',
|
||||
),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
await ref.read(recordingProvider.notifier).startRecording();
|
||||
}
|
||||
},
|
||||
tooltip: isRecording ? 'Stop recording' : 'Start recording',
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: isRecording
|
||||
? const Icon(Icons.stop, key: ValueKey('stop'))
|
||||
: const Icon(Icons.fiber_manual_record, key: ValueKey('rec')),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
1252
pubspec.lock
1252
pubspec.lock
File diff suppressed because it is too large
Load Diff
60
pubspec.yaml
60
pubspec.yaml
@ -1,30 +1,30 @@
|
||||
name: hvbt_dash
|
||||
description: HV BT Automotive ECU Dashboard
|
||||
|
||||
publish_to: 'none'
|
||||
|
||||
version: 1.3.0+4
|
||||
|
||||
environment:
|
||||
sdk: '>=3.0.0 <4.0.0'
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
flutter_riverpod: ^2.5.0
|
||||
flutter_bluetooth_serial: ^0.4.0
|
||||
fl_chart: ^0.68.0
|
||||
sqflite: ^2.3.0
|
||||
shared_preferences: ^2.2.0
|
||||
path_provider: ^2.1.0
|
||||
intl: ^0.19.0
|
||||
path: ^1.9.0
|
||||
permission_handler: ^11.0.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^3.0.0
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
name: hvbt_dash
|
||||
description: HV BT Automotive ECU Dashboard
|
||||
|
||||
publish_to: 'none'
|
||||
|
||||
version: 1.3.0+4
|
||||
|
||||
environment:
|
||||
sdk: '>=3.0.0 <4.0.0'
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
flutter_riverpod: ^2.5.0
|
||||
flutter_bluetooth_serial: ^0.4.0
|
||||
fl_chart: ^0.68.0
|
||||
sqflite: ^2.3.0
|
||||
shared_preferences: ^2.2.0
|
||||
path_provider: ^2.1.0
|
||||
intl: ^0.19.0
|
||||
path: ^1.9.0
|
||||
permission_handler: ^11.0.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^3.0.0
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
|
||||
@ -1,69 +1,69 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hvbt_dash/core/protocol/dtc_map.dart';
|
||||
|
||||
void main() {
|
||||
group('S300 DTC map', () {
|
||||
test('contains expected number of entries', () {
|
||||
expect(s300DtcMap.length, equals(32)); // 4 bytes × 8 bits
|
||||
});
|
||||
|
||||
test('P0130 maps to correct description for ERR00 bit0', () {
|
||||
expect(s300DtcMap['ERR00_bit0'], equals('P0130 — O2 Sensor (front)'));
|
||||
});
|
||||
|
||||
test('P0505 maps for ERR00 bit2', () {
|
||||
expect(s300DtcMap['ERR00_bit2'], equals('P0505 — Idle Air Control Valve'));
|
||||
});
|
||||
|
||||
test('P1607 maps for ERR03 bit7', () {
|
||||
expect(s300DtcMap['ERR03_bit7'], equals('P1607 — PCM Internal Circuit'));
|
||||
});
|
||||
});
|
||||
|
||||
group('KPro DTC map', () {
|
||||
test('contains entries for 18 bytes (ERR00..ERR17)', () {
|
||||
// Every ERR byte has 8 bits = 144 entries total
|
||||
expect(kproDtcMap.length, equals(144));
|
||||
});
|
||||
|
||||
test('P0130 maps for ERR00 bit0', () {
|
||||
expect(kproDtcMap['ERR00_bit0'], equals('P0130 — O2 Sensor (front)'));
|
||||
});
|
||||
|
||||
test('ERR17 entries present', () {
|
||||
expect(kproDtcMap.containsKey('ERR17_bit0'), isTrue);
|
||||
expect(kproDtcMap.containsKey('ERR17_bit7'), isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('decodeDtcs helper', () {
|
||||
test('returns empty list when no errors', () {
|
||||
final result = decodeDtcs([0, 0, 0, 0], s300DtcMap);
|
||||
expect(result, isEmpty);
|
||||
});
|
||||
|
||||
test('decodes ERR00 bit0 = P0130', () {
|
||||
final result = decodeDtcs([0x01, 0, 0, 0], s300DtcMap);
|
||||
expect(result, contains('P0130 — O2 Sensor (front)'));
|
||||
});
|
||||
|
||||
test('decodes multiple bits in one byte', () {
|
||||
// ERR00 bits 0 and 1
|
||||
final result = decodeDtcs([0x03, 0, 0, 0], s300DtcMap);
|
||||
expect(result.length, equals(2));
|
||||
});
|
||||
|
||||
test('decodes bits across multiple bytes', () {
|
||||
// ERR00 bit0 + ERR01 bit0
|
||||
final result = decodeDtcs([0x01, 0x01, 0, 0], s300DtcMap);
|
||||
expect(result.length, equals(2));
|
||||
});
|
||||
|
||||
test('skips bits not in map gracefully', () {
|
||||
// Use a small dtcMap with only one entry, but set all bits
|
||||
final result = decodeDtcs([0xFF, 0, 0, 0], {'ERR00_bit0': 'P0130 — test'});
|
||||
// Only the one mapped bit should appear
|
||||
expect(result.length, equals(1));
|
||||
});
|
||||
});
|
||||
}
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hvbt_dash/core/protocol/dtc_map.dart';
|
||||
|
||||
void main() {
|
||||
group('S300 DTC map', () {
|
||||
test('contains expected number of entries', () {
|
||||
expect(s300DtcMap.length, equals(32)); // 4 bytes × 8 bits
|
||||
});
|
||||
|
||||
test('P0130 maps to correct description for ERR00 bit0', () {
|
||||
expect(s300DtcMap['ERR00_bit0'], equals('P0130 — O2 Sensor (front)'));
|
||||
});
|
||||
|
||||
test('P0505 maps for ERR00 bit2', () {
|
||||
expect(s300DtcMap['ERR00_bit2'], equals('P0505 — Idle Air Control Valve'));
|
||||
});
|
||||
|
||||
test('P1607 maps for ERR03 bit7', () {
|
||||
expect(s300DtcMap['ERR03_bit7'], equals('P1607 — PCM Internal Circuit'));
|
||||
});
|
||||
});
|
||||
|
||||
group('KPro DTC map', () {
|
||||
test('contains entries for 18 bytes (ERR00..ERR17)', () {
|
||||
// Every ERR byte has 8 bits = 144 entries total
|
||||
expect(kproDtcMap.length, equals(144));
|
||||
});
|
||||
|
||||
test('P0130 maps for ERR00 bit0', () {
|
||||
expect(kproDtcMap['ERR00_bit0'], equals('P0130 — O2 Sensor (front)'));
|
||||
});
|
||||
|
||||
test('ERR17 entries present', () {
|
||||
expect(kproDtcMap.containsKey('ERR17_bit0'), isTrue);
|
||||
expect(kproDtcMap.containsKey('ERR17_bit7'), isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('decodeDtcs helper', () {
|
||||
test('returns empty list when no errors', () {
|
||||
final result = decodeDtcs([0, 0, 0, 0], s300DtcMap);
|
||||
expect(result, isEmpty);
|
||||
});
|
||||
|
||||
test('decodes ERR00 bit0 = P0130', () {
|
||||
final result = decodeDtcs([0x01, 0, 0, 0], s300DtcMap);
|
||||
expect(result, contains('P0130 — O2 Sensor (front)'));
|
||||
});
|
||||
|
||||
test('decodes multiple bits in one byte', () {
|
||||
// ERR00 bits 0 and 1
|
||||
final result = decodeDtcs([0x03, 0, 0, 0], s300DtcMap);
|
||||
expect(result.length, equals(2));
|
||||
});
|
||||
|
||||
test('decodes bits across multiple bytes', () {
|
||||
// ERR00 bit0 + ERR01 bit0
|
||||
final result = decodeDtcs([0x01, 0x01, 0, 0], s300DtcMap);
|
||||
expect(result.length, equals(2));
|
||||
});
|
||||
|
||||
test('skips bits not in map gracefully', () {
|
||||
// Use a small dtcMap with only one entry, but set all bits
|
||||
final result = decodeDtcs([0xFF, 0, 0, 0], {'ERR00_bit0': 'P0130 — test'});
|
||||
// Only the one mapped bit should appear
|
||||
expect(result.length, equals(1));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,133 +1,133 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hvbt_dash/core/protocol/kpro_parser.dart';
|
||||
import 'package:hvbt_dash/core/protocol/temp_table.dart';
|
||||
|
||||
/// Builds a zeroed 128-byte KPro frame and stamps the NEG8 checksum at byte 127.
|
||||
Uint8List _buildKProFrame({
|
||||
int rpm = 0,
|
||||
int vss = 0,
|
||||
int tps = 0,
|
||||
int map1 = 0,
|
||||
int map2 = 0,
|
||||
int ign = 0,
|
||||
int ect = 0x80, // tempXlt[0x80]=73 → 33°C
|
||||
int bat = 0,
|
||||
int sw1 = 0,
|
||||
int sw2 = 0,
|
||||
int krtrd = 0,
|
||||
}) {
|
||||
final frame = Uint8List(128);
|
||||
// Header
|
||||
frame[0] = 0x1B;
|
||||
frame[1] = 0x01;
|
||||
// RPM: bytes 2..3
|
||||
frame[2] = (rpm >> 8) & 0xFF;
|
||||
frame[3] = rpm & 0xFF;
|
||||
// VSS: byte 4
|
||||
frame[4] = vss & 0xFF;
|
||||
// TPS: byte 5
|
||||
frame[5] = tps & 0xFF;
|
||||
// MAP1: byte 6
|
||||
frame[6] = map1 & 0xFF;
|
||||
// MAP2: bytes 33..34
|
||||
frame[33] = (map2 >> 8) & 0xFF;
|
||||
frame[34] = map2 & 0xFF;
|
||||
// IGN: byte 13
|
||||
frame[13] = ign;
|
||||
// KRtrd: byte 21
|
||||
frame[21] = krtrd;
|
||||
// SW1: byte 31, SW2: byte 32
|
||||
frame[31] = sw1;
|
||||
frame[32] = sw2;
|
||||
// ECT: byte 49
|
||||
frame[49] = ect;
|
||||
// BAT: byte 52
|
||||
frame[52] = bat;
|
||||
|
||||
// Stamp NEG8 checksum at byte 127
|
||||
int neg8 = 0;
|
||||
for (int i = 0; i < 127; i++) {
|
||||
neg8 = (neg8 - frame[i]) & 0xFF;
|
||||
}
|
||||
frame[127] = neg8;
|
||||
return frame;
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('KPro parser', () {
|
||||
test('parses RPM = raw / 4', () {
|
||||
// rpm raw = 4000 → 1000.0 revs
|
||||
final frame = _buildKProFrame(rpm: 4000);
|
||||
final state = parseKPro(frame);
|
||||
expect(state.rpm, equals(1000.0));
|
||||
});
|
||||
|
||||
test('parses VSS directly', () {
|
||||
final frame = _buildKProFrame(vss: 120);
|
||||
final state = parseKPro(frame);
|
||||
expect(state.vss, equals(120.0));
|
||||
});
|
||||
|
||||
test('parses TPS directly', () {
|
||||
final frame = _buildKProFrame(tps: 75);
|
||||
final state = parseKPro(frame);
|
||||
expect(state.tps, equals(75.0));
|
||||
});
|
||||
|
||||
test('parses MAP from MAP1 when MAP2 = 0', () {
|
||||
final frame = _buildKProFrame(map1: 100, map2: 0);
|
||||
final state = parseKPro(frame);
|
||||
expect(state.map, equals(100.0));
|
||||
});
|
||||
|
||||
test('parses MAP from MAP2 / 10 when MAP2 != 0', () {
|
||||
// map2 raw = 1000 → 100.0 kPa
|
||||
final frame = _buildKProFrame(map1: 50, map2: 1000);
|
||||
final state = parseKPro(frame);
|
||||
expect(state.map, equals(100.0));
|
||||
});
|
||||
|
||||
test('parses ECT using temp lookup table', () {
|
||||
// ect raw = 0x80 = 128 → tempXlt[128]=73 → 33°C
|
||||
final frame = _buildKProFrame(ect: 0x80);
|
||||
final state = parseKPro(frame);
|
||||
expect(state.ect, equals((tempXlt[0x80] - 40).toDouble()));
|
||||
});
|
||||
|
||||
test('parses BAT = raw / 10', () {
|
||||
// bat raw = 130 → 13.0 V
|
||||
final frame = _buildKProFrame(bat: 130);
|
||||
final state = parseKPro(frame);
|
||||
expect(state.bat, equals(13.0));
|
||||
});
|
||||
|
||||
test('MIL flag from SW2 bit2', () {
|
||||
final frame = _buildKProFrame(sw2: 0x04); // bit2
|
||||
final state = parseKPro(frame);
|
||||
expect(state.mil, isTrue);
|
||||
});
|
||||
|
||||
test('knock flag when krtrd > 0', () {
|
||||
final frame = _buildKProFrame(krtrd: 3);
|
||||
final state = parseKPro(frame);
|
||||
expect(state.knock, isTrue);
|
||||
});
|
||||
|
||||
test('errBytes has 18 entries', () {
|
||||
final frame = _buildKProFrame();
|
||||
final state = parseKPro(frame);
|
||||
expect(state.errBytes.length, equals(18));
|
||||
});
|
||||
|
||||
test('throws on wrong frame length', () {
|
||||
expect(() => parseKPro(Uint8List(64)), throwsArgumentError);
|
||||
});
|
||||
|
||||
test('throws on bad checksum', () {
|
||||
final frame = _buildKProFrame(rpm: 4000);
|
||||
frame[127] = 0x00; // corrupt
|
||||
expect(() => parseKPro(frame), throwsArgumentError);
|
||||
});
|
||||
});
|
||||
}
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hvbt_dash/core/protocol/kpro_parser.dart';
|
||||
import 'package:hvbt_dash/core/protocol/temp_table.dart';
|
||||
|
||||
/// Builds a zeroed 128-byte KPro frame and stamps the NEG8 checksum at byte 127.
|
||||
Uint8List _buildKProFrame({
|
||||
int rpm = 0,
|
||||
int vss = 0,
|
||||
int tps = 0,
|
||||
int map1 = 0,
|
||||
int map2 = 0,
|
||||
int ign = 0,
|
||||
int ect = 0x80, // tempXlt[0x80]=73 → 33°C
|
||||
int bat = 0,
|
||||
int sw1 = 0,
|
||||
int sw2 = 0,
|
||||
int krtrd = 0,
|
||||
}) {
|
||||
final frame = Uint8List(128);
|
||||
// Header
|
||||
frame[0] = 0x1B;
|
||||
frame[1] = 0x01;
|
||||
// RPM: bytes 2..3
|
||||
frame[2] = (rpm >> 8) & 0xFF;
|
||||
frame[3] = rpm & 0xFF;
|
||||
// VSS: byte 4
|
||||
frame[4] = vss & 0xFF;
|
||||
// TPS: byte 5
|
||||
frame[5] = tps & 0xFF;
|
||||
// MAP1: byte 6
|
||||
frame[6] = map1 & 0xFF;
|
||||
// MAP2: bytes 33..34
|
||||
frame[33] = (map2 >> 8) & 0xFF;
|
||||
frame[34] = map2 & 0xFF;
|
||||
// IGN: byte 13
|
||||
frame[13] = ign;
|
||||
// KRtrd: byte 21
|
||||
frame[21] = krtrd;
|
||||
// SW1: byte 31, SW2: byte 32
|
||||
frame[31] = sw1;
|
||||
frame[32] = sw2;
|
||||
// ECT: byte 49
|
||||
frame[49] = ect;
|
||||
// BAT: byte 52
|
||||
frame[52] = bat;
|
||||
|
||||
// Stamp NEG8 checksum at byte 127
|
||||
int neg8 = 0;
|
||||
for (int i = 0; i < 127; i++) {
|
||||
neg8 = (neg8 - frame[i]) & 0xFF;
|
||||
}
|
||||
frame[127] = neg8;
|
||||
return frame;
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('KPro parser', () {
|
||||
test('parses RPM = raw / 4', () {
|
||||
// rpm raw = 4000 → 1000.0 revs
|
||||
final frame = _buildKProFrame(rpm: 4000);
|
||||
final state = parseKPro(frame);
|
||||
expect(state.rpm, equals(1000.0));
|
||||
});
|
||||
|
||||
test('parses VSS directly', () {
|
||||
final frame = _buildKProFrame(vss: 120);
|
||||
final state = parseKPro(frame);
|
||||
expect(state.vss, equals(120.0));
|
||||
});
|
||||
|
||||
test('parses TPS directly', () {
|
||||
final frame = _buildKProFrame(tps: 75);
|
||||
final state = parseKPro(frame);
|
||||
expect(state.tps, equals(75.0));
|
||||
});
|
||||
|
||||
test('parses MAP from MAP1 when MAP2 = 0', () {
|
||||
final frame = _buildKProFrame(map1: 100, map2: 0);
|
||||
final state = parseKPro(frame);
|
||||
expect(state.map, equals(100.0));
|
||||
});
|
||||
|
||||
test('parses MAP from MAP2 / 10 when MAP2 != 0', () {
|
||||
// map2 raw = 1000 → 100.0 kPa
|
||||
final frame = _buildKProFrame(map1: 50, map2: 1000);
|
||||
final state = parseKPro(frame);
|
||||
expect(state.map, equals(100.0));
|
||||
});
|
||||
|
||||
test('parses ECT using temp lookup table', () {
|
||||
// ect raw = 0x80 = 128 → tempXlt[128]=73 → 33°C
|
||||
final frame = _buildKProFrame(ect: 0x80);
|
||||
final state = parseKPro(frame);
|
||||
expect(state.ect, equals((tempXlt[0x80] - 40).toDouble()));
|
||||
});
|
||||
|
||||
test('parses BAT = raw / 10', () {
|
||||
// bat raw = 130 → 13.0 V
|
||||
final frame = _buildKProFrame(bat: 130);
|
||||
final state = parseKPro(frame);
|
||||
expect(state.bat, equals(13.0));
|
||||
});
|
||||
|
||||
test('MIL flag from SW2 bit2', () {
|
||||
final frame = _buildKProFrame(sw2: 0x04); // bit2
|
||||
final state = parseKPro(frame);
|
||||
expect(state.mil, isTrue);
|
||||
});
|
||||
|
||||
test('knock flag when krtrd > 0', () {
|
||||
final frame = _buildKProFrame(krtrd: 3);
|
||||
final state = parseKPro(frame);
|
||||
expect(state.knock, isTrue);
|
||||
});
|
||||
|
||||
test('errBytes has 18 entries', () {
|
||||
final frame = _buildKProFrame();
|
||||
final state = parseKPro(frame);
|
||||
expect(state.errBytes.length, equals(18));
|
||||
});
|
||||
|
||||
test('throws on wrong frame length', () {
|
||||
expect(() => parseKPro(Uint8List(64)), throwsArgumentError);
|
||||
});
|
||||
|
||||
test('throws on bad checksum', () {
|
||||
final frame = _buildKProFrame(rpm: 4000);
|
||||
frame[127] = 0x00; // corrupt
|
||||
expect(() => parseKPro(frame), throwsArgumentError);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,38 +1,38 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hvbt_dash/core/protocol/neg8.dart';
|
||||
|
||||
void main() {
|
||||
group('NEG8 checksum', () {
|
||||
test('S300 request header [0x1B, 0x00] produces 0xE5', () {
|
||||
// NEG8 of [0x1B, 0x00] = (0 - 0x1B - 0x00) & 0xFF = 0xE5
|
||||
final buf = Uint8List.fromList([0x1B, 0x00]);
|
||||
expect(calculateNeg8(buf), equals(0xE5));
|
||||
});
|
||||
|
||||
test('KPro request header [0x1B, 0x01] produces 0xE4', () {
|
||||
final buf = Uint8List.fromList([0x1B, 0x01]);
|
||||
expect(calculateNeg8(buf), equals(0xE4));
|
||||
});
|
||||
|
||||
test('validateFrame passes for correctly checksummed frame', () {
|
||||
// Build a 4-byte frame where last byte = NEG8 of first 3
|
||||
final data = Uint8List.fromList([0x1B, 0x00, 0x00, 0xE5]);
|
||||
expect(validateFrame(data), isTrue);
|
||||
});
|
||||
|
||||
test('validateFrame fails for corrupted frame', () {
|
||||
final data = Uint8List.fromList([0x1B, 0x00, 0x00, 0xFF]);
|
||||
expect(validateFrame(data), isFalse);
|
||||
});
|
||||
|
||||
test('validateFrame returns false for empty frame', () {
|
||||
expect(validateFrame(Uint8List(0)), isFalse);
|
||||
});
|
||||
|
||||
test('single byte 0x00 validates as [0x00, 0x00]', () {
|
||||
// NEG8([0x00]) = 0x00, so frame [0x00, 0x00] should pass
|
||||
expect(validateFrame(Uint8List.fromList([0x00, 0x00])), isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hvbt_dash/core/protocol/neg8.dart';
|
||||
|
||||
void main() {
|
||||
group('NEG8 checksum', () {
|
||||
test('S300 request header [0x1B, 0x00] produces 0xE5', () {
|
||||
// NEG8 of [0x1B, 0x00] = (0 - 0x1B - 0x00) & 0xFF = 0xE5
|
||||
final buf = Uint8List.fromList([0x1B, 0x00]);
|
||||
expect(calculateNeg8(buf), equals(0xE5));
|
||||
});
|
||||
|
||||
test('KPro request header [0x1B, 0x01] produces 0xE4', () {
|
||||
final buf = Uint8List.fromList([0x1B, 0x01]);
|
||||
expect(calculateNeg8(buf), equals(0xE4));
|
||||
});
|
||||
|
||||
test('validateFrame passes for correctly checksummed frame', () {
|
||||
// Build a 4-byte frame where last byte = NEG8 of first 3
|
||||
final data = Uint8List.fromList([0x1B, 0x00, 0x00, 0xE5]);
|
||||
expect(validateFrame(data), isTrue);
|
||||
});
|
||||
|
||||
test('validateFrame fails for corrupted frame', () {
|
||||
final data = Uint8List.fromList([0x1B, 0x00, 0x00, 0xFF]);
|
||||
expect(validateFrame(data), isFalse);
|
||||
});
|
||||
|
||||
test('validateFrame returns false for empty frame', () {
|
||||
expect(validateFrame(Uint8List(0)), isFalse);
|
||||
});
|
||||
|
||||
test('single byte 0x00 validates as [0x00, 0x00]', () {
|
||||
// NEG8([0x00]) = 0x00, so frame [0x00, 0x00] should pass
|
||||
expect(validateFrame(Uint8List.fromList([0x00, 0x00])), isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,214 +1,264 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hvbt_dash/core/protocol/neg8.dart';
|
||||
import 'package:hvbt_dash/core/protocol/s300_parser.dart';
|
||||
import 'package:hvbt_dash/core/protocol/temp_table.dart';
|
||||
|
||||
/// Builds a zeroed 128-byte S300 frame and stamps the NEG8 checksum at byte 127.
|
||||
Uint8List _buildS300Frame({
|
||||
int rpm = 0,
|
||||
int vss = 0,
|
||||
int mapRaw = 0,
|
||||
int tps = 0,
|
||||
int inj = 0,
|
||||
int ign = 0,
|
||||
int ect = 0x80, // tempXlt[0x80]=73 → 73-40=33°C
|
||||
int bat = 0,
|
||||
int sw1 = 0,
|
||||
int sw2 = 0,
|
||||
int sw3 = 0,
|
||||
int krtrd = 0,
|
||||
int gear = 0,
|
||||
}) {
|
||||
final frame = Uint8List(128);
|
||||
// Header
|
||||
frame[0] = 0x1B;
|
||||
frame[1] = 0x00;
|
||||
// RPM: bytes +3..4, little-endian, raw
|
||||
frame[3] = rpm & 0xFF;
|
||||
frame[4] = (rpm >> 8) & 0xFF;
|
||||
// VSS: bytes +5..6, little-endian
|
||||
frame[5] = vss & 0xFF;
|
||||
frame[6] = (vss >> 8) & 0xFF;
|
||||
// MAP: bytes +7..8, little-endian
|
||||
frame[7] = mapRaw & 0xFF;
|
||||
frame[8] = (mapRaw >> 8) & 0xFF;
|
||||
// TPS: byte +9
|
||||
frame[9] = tps;
|
||||
// INJ: bytes +10..11, BE
|
||||
frame[10] = (inj >> 8) & 0xFF;
|
||||
frame[11] = inj & 0xFF;
|
||||
// IGN: byte +12
|
||||
frame[12] = ign;
|
||||
// KRtrd: byte +13
|
||||
frame[13] = krtrd;
|
||||
// SW bitmaps
|
||||
frame[0x11] = sw1;
|
||||
frame[0x12] = sw2;
|
||||
frame[0x13] = sw3;
|
||||
// Gear: decimal offset 27
|
||||
frame[27] = gear;
|
||||
// ECT: byte +2D
|
||||
frame[0x2D] = ect;
|
||||
// BAT: byte +30
|
||||
frame[0x30] = bat;
|
||||
|
||||
// Stamp NEG8 checksum at byte 127
|
||||
int neg8 = 0;
|
||||
for (int i = 0; i < 127; i++) {
|
||||
neg8 = (neg8 - frame[i]) & 0xFF;
|
||||
}
|
||||
frame[127] = neg8;
|
||||
return frame;
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('S300 parser', () {
|
||||
test('parses RPM correctly', () {
|
||||
// RPM raw = 3000
|
||||
final frame = _buildS300Frame(rpm: 3000);
|
||||
final state = parseS300(frame);
|
||||
expect(state.rpm, equals(3000.0));
|
||||
});
|
||||
|
||||
test('parses VSS = 0 when raw < 893', () {
|
||||
final frame = _buildS300Frame(vss: 500);
|
||||
final state = parseS300(frame);
|
||||
expect(state.vss, equals(0.0));
|
||||
});
|
||||
|
||||
test('parses VSS correctly when raw >= 893', () {
|
||||
// vss = 228480 / 1000 = 228.48 km/h
|
||||
final frame = _buildS300Frame(vss: 1000);
|
||||
final state = parseS300(frame);
|
||||
expect(state.vss, closeTo(228.48, 0.01));
|
||||
});
|
||||
|
||||
test('parses MAP as raw/10 kPa', () {
|
||||
// mapRaw = 1148 -> 114.8 kPa
|
||||
final frame = _buildS300Frame(mapRaw: 1148);
|
||||
final state = parseS300(frame);
|
||||
expect(state.map, closeTo(114.8, 0.01));
|
||||
});
|
||||
|
||||
test('parses TPS = 0 when raw = 0', () {
|
||||
final frame = _buildS300Frame(tps: 0);
|
||||
final state = parseS300(frame);
|
||||
expect(state.tps, equals(0.0));
|
||||
});
|
||||
|
||||
test('parses TPS = 100 when raw = 255', () {
|
||||
final frame = _buildS300Frame(tps: 255);
|
||||
final state = parseS300(frame);
|
||||
expect(state.tps, equals(100.0));
|
||||
});
|
||||
|
||||
test('parses TPS correctly when raw >= 25', () {
|
||||
// tps raw=127 -> 127*100/255 ≈ 49.8% (confirmed against live capture)
|
||||
final frame = _buildS300Frame(tps: 127);
|
||||
final state = parseS300(frame);
|
||||
expect(state.tps, closeTo(49.8, 0.1));
|
||||
});
|
||||
|
||||
test('parses Gear from decimal offset 27', () {
|
||||
final frame = _buildS300Frame(gear: 5);
|
||||
final state = parseS300(frame);
|
||||
expect(state.gear, equals(5));
|
||||
});
|
||||
|
||||
test('parses ECT using temp lookup table', () {
|
||||
// ect raw = 0x80 = 128 → tempXlt[128]=73 → 73-40=33°C
|
||||
final frame = _buildS300Frame(ect: 0x80);
|
||||
final state = parseS300(frame);
|
||||
expect(state.ect, equals((tempXlt[0x80] - 40).toDouble()));
|
||||
});
|
||||
|
||||
test('parses BAT = raw * 26 / 270', () {
|
||||
// bat raw = 135 → 135*26/270 = 13.0 V
|
||||
final frame = _buildS300Frame(bat: 135);
|
||||
final state = parseS300(frame);
|
||||
expect(state.bat, closeTo(13.0, 0.01));
|
||||
});
|
||||
|
||||
test('IGN decode: (raw + 120) / 2', () {
|
||||
// ign raw = 164 -> (164 + 120) / 2 = 142.0
|
||||
final frame = _buildS300Frame(ign: 164);
|
||||
final state = parseS300(frame);
|
||||
expect(state.ign, equals(142.0));
|
||||
});
|
||||
|
||||
test('MIL flag set from SW2 bit5', () {
|
||||
final frame = _buildS300Frame(sw2: 0x20); // bit5 set
|
||||
final state = parseS300(frame);
|
||||
expect(state.mil, isTrue);
|
||||
});
|
||||
|
||||
test('knock flag set when krtrd > 0', () {
|
||||
final frame = _buildS300Frame(krtrd: 5);
|
||||
final state = parseS300(frame);
|
||||
expect(state.knock, isTrue);
|
||||
});
|
||||
|
||||
test('knock flag clear when krtrd = 0', () {
|
||||
final frame = _buildS300Frame(krtrd: 0);
|
||||
final state = parseS300(frame);
|
||||
expect(state.knock, isFalse);
|
||||
});
|
||||
|
||||
test('throws on wrong frame length', () {
|
||||
expect(
|
||||
() => parseS300(Uint8List(64)),
|
||||
throwsArgumentError,
|
||||
);
|
||||
});
|
||||
|
||||
test('throws on bad checksum', () {
|
||||
final frame = _buildS300Frame(rpm: 1000);
|
||||
frame[127] = 0x00; // corrupt checksum
|
||||
expect(() => parseS300(frame), throwsArgumentError);
|
||||
});
|
||||
|
||||
test('can parse bad checksum frame when validation is disabled', () {
|
||||
final frame = _buildS300Frame(rpm: 1000);
|
||||
frame[127] = 0x00; // corrupt checksum
|
||||
final state = parseS300(frame, validateChecksum: false);
|
||||
expect(state.rpm, equals(1000.0));
|
||||
});
|
||||
|
||||
test('decodes RPM=0 for a frame matching the old "keep-alive" byte prefix', () {
|
||||
// A fixed 17-byte prefix here was previously treated as a keep-alive
|
||||
// signature to skip outright. Live testing showed it over-matched
|
||||
// badly: with RPM actively changing, nearly every frame was being
|
||||
// classified as a keep-alive and discarded, freezing the dashboard.
|
||||
// Every checksum-valid frame is decoded normally now regardless of
|
||||
// its leading bytes — see sensor_provider.dart.
|
||||
final frame = Uint8List(128);
|
||||
final prefix = [
|
||||
0x1B,
|
||||
0x00,
|
||||
0x14,
|
||||
0x00,
|
||||
0x00,
|
||||
0xFF,
|
||||
0xFF,
|
||||
0xF2,
|
||||
0x03,
|
||||
0x18,
|
||||
0x00,
|
||||
0x00,
|
||||
0x10,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0xC4,
|
||||
];
|
||||
for (var i = 0; i < prefix.length; i++) {
|
||||
frame[i] = prefix[i];
|
||||
}
|
||||
frame[127] = calculateNeg8(frame.sublist(0, 127));
|
||||
|
||||
final state = parseS300(frame);
|
||||
expect(state.rpm, equals(0.0));
|
||||
});
|
||||
});
|
||||
}
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hvbt_dash/core/protocol/neg8.dart';
|
||||
import 'package:hvbt_dash/core/protocol/s300_parser.dart';
|
||||
import 'package:hvbt_dash/core/protocol/temp_table.dart';
|
||||
|
||||
/// Builds a zeroed 128-byte S300 frame and stamps the NEG8 checksum at byte 127.
|
||||
Uint8List _buildS300Frame({
|
||||
int rpm = 0,
|
||||
int vss = 0,
|
||||
int mapRaw = 0,
|
||||
int tps = 0,
|
||||
int inj = 0,
|
||||
int ign = 0,
|
||||
int ect = 0x80, // tempXlt[0x80]=73 → 73-40=33°C
|
||||
int iat = 0x80,
|
||||
int bat = 0,
|
||||
int sw1 = 0,
|
||||
int sw2 = 0,
|
||||
int sw3 = 0,
|
||||
int krtrd = 0,
|
||||
int gear = 0,
|
||||
}) {
|
||||
final frame = Uint8List(128);
|
||||
// Header
|
||||
frame[0] = 0x1B;
|
||||
frame[1] = 0x00;
|
||||
// RPM: bytes +3..4, little-endian, raw
|
||||
frame[3] = rpm & 0xFF;
|
||||
frame[4] = (rpm >> 8) & 0xFF;
|
||||
// VSS: bytes +5..6, little-endian
|
||||
frame[5] = vss & 0xFF;
|
||||
frame[6] = (vss >> 8) & 0xFF;
|
||||
// MAP: bytes +7..8, little-endian
|
||||
frame[7] = mapRaw & 0xFF;
|
||||
frame[8] = (mapRaw >> 8) & 0xFF;
|
||||
// TPS: byte +9
|
||||
frame[9] = tps;
|
||||
// INJ: bytes +10..11, BE
|
||||
frame[10] = (inj >> 8) & 0xFF;
|
||||
frame[11] = inj & 0xFF;
|
||||
// IGN: byte +12
|
||||
frame[12] = ign;
|
||||
// KRtrd: byte +13
|
||||
frame[13] = krtrd;
|
||||
// SW bitmaps
|
||||
frame[0x11] = sw1;
|
||||
frame[0x12] = sw2;
|
||||
frame[0x13] = sw3;
|
||||
// Gear: decimal offset 27
|
||||
frame[27] = gear;
|
||||
// ECT: decimal offset 33
|
||||
frame[33] = ect;
|
||||
// IAT: decimal offset 34
|
||||
frame[34] = iat;
|
||||
// BAT: decimal offset 36
|
||||
frame[36] = bat;
|
||||
|
||||
// Stamp NEG8 checksum at byte 127
|
||||
int neg8 = 0;
|
||||
for (int i = 0; i < 127; i++) {
|
||||
neg8 = (neg8 - frame[i]) & 0xFF;
|
||||
}
|
||||
frame[127] = neg8;
|
||||
return frame;
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('S300 parser', () {
|
||||
test('parses RPM correctly', () {
|
||||
// RPM raw = 3000
|
||||
final frame = _buildS300Frame(rpm: 3000);
|
||||
final state = parseS300(frame);
|
||||
expect(state.rpm, equals(3000.0));
|
||||
});
|
||||
|
||||
test('parses VSS = 0 when raw < 893', () {
|
||||
final frame = _buildS300Frame(vss: 500);
|
||||
final state = parseS300(frame);
|
||||
expect(state.vss, equals(0.0));
|
||||
});
|
||||
|
||||
test('parses VSS correctly when raw >= 893', () {
|
||||
// vss = 228480 / 1000 = 228.48 km/h
|
||||
final frame = _buildS300Frame(vss: 1000);
|
||||
final state = parseS300(frame);
|
||||
expect(state.vss, closeTo(228.48, 0.01));
|
||||
});
|
||||
|
||||
test('parses MAP as raw/10 kPa', () {
|
||||
// mapRaw = 1148 -> 114.8 kPa
|
||||
final frame = _buildS300Frame(mapRaw: 1148);
|
||||
final state = parseS300(frame);
|
||||
expect(state.map, closeTo(114.8, 0.01));
|
||||
});
|
||||
|
||||
test('parses TPS = 0 when raw = 0', () {
|
||||
final frame = _buildS300Frame(tps: 0);
|
||||
final state = parseS300(frame);
|
||||
expect(state.tps, equals(0.0));
|
||||
});
|
||||
|
||||
test('parses TPS = 100 when raw = 255', () {
|
||||
final frame = _buildS300Frame(tps: 255);
|
||||
final state = parseS300(frame);
|
||||
expect(state.tps, equals(100.0));
|
||||
});
|
||||
|
||||
test('parses TPS correctly when raw >= 25', () {
|
||||
// tps raw=127 -> 127*100/255 ≈ 49.8% (confirmed against live capture)
|
||||
final frame = _buildS300Frame(tps: 127);
|
||||
final state = parseS300(frame);
|
||||
expect(state.tps, closeTo(49.8, 0.1));
|
||||
});
|
||||
|
||||
test('parses Gear from decimal offset 27', () {
|
||||
final frame = _buildS300Frame(gear: 5);
|
||||
final state = parseS300(frame);
|
||||
expect(state.gear, equals(5));
|
||||
});
|
||||
|
||||
test('parses ECT using temp lookup table', () {
|
||||
// Confirmed via live capture: raw=0xD2(210) matched the reference
|
||||
// tool's 28.4°F exactly at the same instant (RPM cross-checked).
|
||||
// ect raw = 0xD2 = 210 → tempXlt[210]=38 → 38-40=-2°C
|
||||
final frame = _buildS300Frame(ect: 0xD2);
|
||||
final state = parseS300(frame);
|
||||
expect(state.ect, equals((tempXlt[0xD2] - 40).toDouble()));
|
||||
});
|
||||
|
||||
test('parses IAT using temp lookup table', () {
|
||||
// Confirmed via the same live capture: raw=0xCA(202) was within
|
||||
// ~1.3°F of the reference tool's 36.1°F reading.
|
||||
// iat raw = 0xCA = 202 → tempXlt[202]=43 → 43-40=3°C
|
||||
final frame = _buildS300Frame(iat: 0xCA);
|
||||
final state = parseS300(frame);
|
||||
expect(state.iat, equals((tempXlt[0xCA] - 40).toDouble()));
|
||||
});
|
||||
|
||||
test('parses BAT = raw * 26 / 270', () {
|
||||
// bat raw = 135 → 135*26/270 = 13.0 V
|
||||
final frame = _buildS300Frame(bat: 135);
|
||||
final state = parseS300(frame);
|
||||
expect(state.bat, closeTo(13.0, 0.01));
|
||||
});
|
||||
|
||||
test('IGN decode: (raw + 120) / 2', () {
|
||||
// ign raw = 164 -> (164 + 120) / 2 = 142.0
|
||||
final frame = _buildS300Frame(ign: 164);
|
||||
final state = parseS300(frame);
|
||||
expect(state.ign, equals(142.0));
|
||||
});
|
||||
|
||||
test('MIL flag set from SW2 bit5', () {
|
||||
final frame = _buildS300Frame(sw2: 0x20); // bit5 set
|
||||
final state = parseS300(frame);
|
||||
expect(state.mil, isTrue);
|
||||
});
|
||||
|
||||
test('fuel pump flag is active-low: set when SW2 bit2 is CLEAR', () {
|
||||
// SW2 bit 2 is the "Fuel" line in the reference tool's own channel
|
||||
// list — confirmed live to be the fuel PUMP relay status, not fuel
|
||||
// cut, but also confirmed live to be active-low: an extended session
|
||||
// held RPM above the ~300 threshold where the pump should be on, and
|
||||
// this bit read 0 for the entire session. Separately confirmed the
|
||||
// app showed "on" below ~200 rpm and "off" above it — exactly
|
||||
// inverted from a naive `!= 0` read, hence bit CLEAR = pump on.
|
||||
final frame = _buildS300Frame(sw2: 0x38); // bit2 clear, other bits set
|
||||
final state = parseS300(frame);
|
||||
expect(state.fuelPump, isTrue);
|
||||
});
|
||||
|
||||
test('fuel pump flag clear (pump off) when SW2 bit2 is set', () {
|
||||
final frame = _buildS300Frame(sw2: 0x04); // bit2 set
|
||||
final state = parseS300(frame);
|
||||
expect(state.fuelPump, isFalse);
|
||||
});
|
||||
|
||||
test('knock flag set when krtrd > 0', () {
|
||||
final frame = _buildS300Frame(krtrd: 5);
|
||||
final state = parseS300(frame);
|
||||
expect(state.knock, isTrue);
|
||||
});
|
||||
|
||||
test('knock flag clear when krtrd = 0', () {
|
||||
final frame = _buildS300Frame(krtrd: 0);
|
||||
final state = parseS300(frame);
|
||||
expect(state.knock, isFalse);
|
||||
});
|
||||
|
||||
test('throws on wrong frame length', () {
|
||||
expect(
|
||||
() => parseS300(Uint8List(64)),
|
||||
throwsArgumentError,
|
||||
);
|
||||
});
|
||||
|
||||
test('throws on bad checksum', () {
|
||||
final frame = _buildS300Frame(rpm: 1000);
|
||||
frame[127] = 0x00; // corrupt checksum
|
||||
expect(() => parseS300(frame), throwsArgumentError);
|
||||
});
|
||||
|
||||
test('can parse bad checksum frame when validation is disabled', () {
|
||||
final frame = _buildS300Frame(rpm: 1000);
|
||||
frame[127] = 0x00; // corrupt checksum
|
||||
final state = parseS300(frame, validateChecksum: false);
|
||||
expect(state.rpm, equals(1000.0));
|
||||
});
|
||||
|
||||
test('decodes RPM=0 for a frame matching the fixed event-frame template', () {
|
||||
// parseS300 itself doesn't special-case this template — it just reads
|
||||
// the normal offsets, which land on the template's fixed bytes.
|
||||
// Holding the last known-good value across this frame is the caller's
|
||||
// job (sensor_provider.dart), driven by isS300TemplateFrame below.
|
||||
final frame = _templateFrame();
|
||||
final state = parseS300(frame);
|
||||
expect(state.rpm, equals(0.0));
|
||||
});
|
||||
|
||||
test('isS300TemplateFrame detects the fixed event-frame byte pattern', () {
|
||||
// Verified via live A/B capture: this exact bytes-0-16 pattern shows
|
||||
// up whenever SW2 (byte 18, outside this span) reports fuel cut
|
||||
// active, corroborated independently by the same frame's INJ pulse
|
||||
// width reading 0 and TPS reading a closed-throttle value.
|
||||
expect(isS300TemplateFrame(_templateFrame()), isTrue);
|
||||
});
|
||||
|
||||
test('isS300TemplateFrame is false for a normal live-reading frame', () {
|
||||
final frame = _buildS300Frame(rpm: 3000, tps: 127);
|
||||
expect(isS300TemplateFrame(frame), isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Builds a 128-byte frame stamped with the S300's fixed event-frame
|
||||
/// template in bytes 0-16, with a valid NEG8 checksum.
|
||||
Uint8List _templateFrame() {
|
||||
final frame = Uint8List(128);
|
||||
final prefix = [
|
||||
0x1B,
|
||||
0x00,
|
||||
0x14,
|
||||
0x00,
|
||||
0x00,
|
||||
0xFF,
|
||||
0xFF,
|
||||
0xF2,
|
||||
0x03,
|
||||
0x18,
|
||||
0x00,
|
||||
0x00,
|
||||
0x10,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0xC4,
|
||||
];
|
||||
for (var i = 0; i < prefix.length; i++) {
|
||||
frame[i] = prefix[i];
|
||||
}
|
||||
frame[127] = calculateNeg8(frame.sublist(0, 127));
|
||||
return frame;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user