Compare commits
7 Commits
backup/sna
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82b1d822f9 | ||
|
|
6b8d069ef4 | ||
|
|
e4bcfb57a6 | ||
|
|
ba79775fbc | ||
|
|
2bd937aca5 | ||
|
|
9a56baef61 | ||
|
|
e9b1c0f8fd |
3
.gitignore
vendored
3
.gitignore
vendored
@ -26,3 +26,6 @@ android/replay_pid*.log
|
|||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# Client/reference documents (kept locally, not in the repo)
|
||||||
|
/*.pdf
|
||||||
|
|||||||
@ -1,198 +0,0 @@
|
|||||||
kotlin version: 2.2.20
|
|
||||||
error message: Daemon compilation failed: null
|
|
||||||
java.lang.Exception
|
|
||||||
at org.jetbrains.kotlin.daemon.common.CompileService$CallResult$Error.get(CompileService.kt:69)
|
|
||||||
at org.jetbrains.kotlin.daemon.common.CompileService$CallResult$Error.get(CompileService.kt:65)
|
|
||||||
at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.compileWithDaemon(GradleKotlinCompilerWork.kt:240)
|
|
||||||
at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.compileWithDaemonOrFallbackImpl(GradleKotlinCompilerWork.kt:159)
|
|
||||||
at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.run(GradleKotlinCompilerWork.kt:111)
|
|
||||||
at org.jetbrains.kotlin.compilerRunner.GradleCompilerRunnerWithWorkers$GradleKotlinCompilerWorkAction.execute(GradleCompilerRunnerWithWorkers.kt:74)
|
|
||||||
at org.gradle.workers.internal.DefaultWorkerServer.execute(DefaultWorkerServer.java:63)
|
|
||||||
at org.gradle.workers.internal.NoIsolationWorkerFactory$1$1.create(NoIsolationWorkerFactory.java:66)
|
|
||||||
at org.gradle.workers.internal.NoIsolationWorkerFactory$1$1.create(NoIsolationWorkerFactory.java:62)
|
|
||||||
at org.gradle.internal.classloader.ClassLoaderUtils.executeInClassloader(ClassLoaderUtils.java:100)
|
|
||||||
at org.gradle.workers.internal.NoIsolationWorkerFactory$1.lambda$execute$0(NoIsolationWorkerFactory.java:62)
|
|
||||||
at org.gradle.workers.internal.AbstractWorker$1.call(AbstractWorker.java:44)
|
|
||||||
at org.gradle.workers.internal.AbstractWorker$1.call(AbstractWorker.java:41)
|
|
||||||
at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:210)
|
|
||||||
at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:205)
|
|
||||||
at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:67)
|
|
||||||
at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:60)
|
|
||||||
at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:167)
|
|
||||||
at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:60)
|
|
||||||
at org.gradle.internal.operations.DefaultBuildOperationRunner.call(DefaultBuildOperationRunner.java:54)
|
|
||||||
at org.gradle.workers.internal.AbstractWorker.executeWrappedInBuildOperation(AbstractWorker.java:41)
|
|
||||||
at org.gradle.workers.internal.NoIsolationWorkerFactory$1.execute(NoIsolationWorkerFactory.java:59)
|
|
||||||
at org.gradle.workers.internal.DefaultWorkerExecutor.lambda$submitWork$0(DefaultWorkerExecutor.java:174)
|
|
||||||
at java.base/java.util.concurrent.FutureTask.run(Unknown Source)
|
|
||||||
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.runExecution(DefaultConditionalExecutionQueue.java:194)
|
|
||||||
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.access$700(DefaultConditionalExecutionQueue.java:127)
|
|
||||||
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner$1.run(DefaultConditionalExecutionQueue.java:169)
|
|
||||||
at org.gradle.internal.Factories$1.create(Factories.java:31)
|
|
||||||
at org.gradle.internal.work.DefaultWorkerLeaseService.withLocks(DefaultWorkerLeaseService.java:263)
|
|
||||||
at org.gradle.internal.work.DefaultWorkerLeaseService.runAsWorkerThread(DefaultWorkerLeaseService.java:127)
|
|
||||||
at org.gradle.internal.work.DefaultWorkerLeaseService.runAsWorkerThread(DefaultWorkerLeaseService.java:132)
|
|
||||||
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.runBatch(DefaultConditionalExecutionQueue.java:164)
|
|
||||||
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.run(DefaultConditionalExecutionQueue.java:133)
|
|
||||||
at java.base/java.util.concurrent.Executors$RunnableAdapter.call(Unknown Source)
|
|
||||||
at java.base/java.util.concurrent.FutureTask.run(Unknown Source)
|
|
||||||
at org.gradle.internal.concurrent.ExecutorPolicy$CatchAndRecordFailures.onExecute(ExecutorPolicy.java:64)
|
|
||||||
at org.gradle.internal.concurrent.AbstractManagedExecutor$1.run(AbstractManagedExecutor.java:48)
|
|
||||||
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source)
|
|
||||||
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source)
|
|
||||||
at java.base/java.lang.Thread.run(Unknown Source)
|
|
||||||
Caused by: java.lang.AssertionError: java.lang.Exception: Could not close incremental caches in D:\2026\BT-CAR-App\hvbt_dash\build\shared_preferences_android\kotlin\compileReleaseKotlin\cacheable\caches-jvm\jvm\kotlin: class-fq-name-to-source.tab, source-to-classes.tab, internal-name-to-source.tab
|
|
||||||
at org.jetbrains.kotlin.com.google.common.io.Closer.close(Closer.java:218)
|
|
||||||
at org.jetbrains.kotlin.incremental.IncrementalCachesManager.close(IncrementalCachesManager.kt:55)
|
|
||||||
at kotlin.io.CloseableKt.closeFinally(Closeable.kt:46)
|
|
||||||
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compileNonIncrementally(IncrementalCompilerRunner.kt:293)
|
|
||||||
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compile(IncrementalCompilerRunner.kt:128)
|
|
||||||
at org.jetbrains.kotlin.daemon.CompileServiceImplBase.execIncrementalCompiler(CompileServiceImpl.kt:684)
|
|
||||||
at org.jetbrains.kotlin.daemon.CompileServiceImplBase.access$execIncrementalCompiler(CompileServiceImpl.kt:94)
|
|
||||||
at org.jetbrains.kotlin.daemon.CompileServiceImpl.compile(CompileServiceImpl.kt:1810)
|
|
||||||
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(Unknown Source)
|
|
||||||
at java.base/java.lang.reflect.Method.invoke(Unknown Source)
|
|
||||||
at java.rmi/sun.rmi.server.UnicastServerRef.dispatch(Unknown Source)
|
|
||||||
at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source)
|
|
||||||
at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source)
|
|
||||||
at java.base/java.security.AccessController.doPrivileged(Unknown Source)
|
|
||||||
at java.rmi/sun.rmi.transport.Transport.serviceCall(Unknown Source)
|
|
||||||
at java.rmi/sun.rmi.transport.tcp.TCPTransport.handleMessages(Unknown Source)
|
|
||||||
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run0(Unknown Source)
|
|
||||||
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.lambda$run$0(Unknown Source)
|
|
||||||
at java.base/java.security.AccessController.doPrivileged(Unknown Source)
|
|
||||||
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run(Unknown Source)
|
|
||||||
... 3 more
|
|
||||||
Caused by: java.lang.Exception: Could not close incremental caches in D:\2026\BT-CAR-App\hvbt_dash\build\shared_preferences_android\kotlin\compileReleaseKotlin\cacheable\caches-jvm\jvm\kotlin: class-fq-name-to-source.tab, source-to-classes.tab, internal-name-to-source.tab
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.forEachMapSafe(BasicMapsOwner.kt:95)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.close(BasicMapsOwner.kt:53)
|
|
||||||
at org.jetbrains.kotlin.com.google.common.io.Closer.close(Closer.java:205)
|
|
||||||
... 22 more
|
|
||||||
Suppressed: java.lang.IllegalArgumentException: this and base files have different roots: C:\Users\mohan\AppData\Local\Pub\Cache\hosted\pub.dev\shared_preferences_android-2.4.23\android\src\main\kotlin\io\flutter\plugins\sharedpreferences\MessagesAsync.g.kt and D:\2026\BT-CAR-App\hvbt_dash\android.
|
|
||||||
at kotlin.io.FilesKt__UtilsKt.toRelativeString(Utils.kt:117)
|
|
||||||
at kotlin.io.FilesKt__UtilsKt.relativeTo(Utils.kt:128)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.RelocatableFileToPathConverter.toPath(RelocatableFileToPathConverter.kt:24)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.FileDescriptor.save(FileToPathConverter.kt:33)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.FileDescriptor.save(FileToPathConverter.kt:30)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.doPut(PersistentMapImpl.java:447)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.put(PersistentMapImpl.java:426)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.put(PersistentHashMap.java:106)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.LazyStorage.set(LazyStorage.kt:80)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.applyChanges(InMemoryStorage.kt:108)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.close(InMemoryStorage.kt:136)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.PersistentStorageWrapper.close(PersistentStorage.kt:124)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.forEachMapSafe(BasicMapsOwner.kt:87)
|
|
||||||
... 24 more
|
|
||||||
Suppressed: java.lang.IllegalArgumentException: this and base files have different roots: C:\Users\mohan\AppData\Local\Pub\Cache\hosted\pub.dev\shared_preferences_android-2.4.23\android\src\main\kotlin\io\flutter\plugins\sharedpreferences\MessagesAsync.g.kt and D:\2026\BT-CAR-App\hvbt_dash\android.
|
|
||||||
at kotlin.io.FilesKt__UtilsKt.toRelativeString(Utils.kt:117)
|
|
||||||
at kotlin.io.FilesKt__UtilsKt.relativeTo(Utils.kt:128)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.RelocatableFileToPathConverter.toPath(RelocatableFileToPathConverter.kt:24)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.FileDescriptor.getHashCode(FileToPathConverter.kt:50)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.FileDescriptor.getHashCode(FileToPathConverter.kt:30)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.containers.LinkedCustomHashMap.hashKey(LinkedCustomHashMap.java:109)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.containers.LinkedCustomHashMap.remove(LinkedCustomHashMap.java:153)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.containers.SLRUMap.remove(SLRUMap.java:89)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.flushAppendCache(PersistentMapImpl.java:1007)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.doPut(PersistentMapImpl.java:455)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.put(PersistentMapImpl.java:426)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.put(PersistentHashMap.java:106)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.LazyStorage.set(LazyStorage.kt:80)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.applyChanges(InMemoryStorage.kt:108)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.AppendableInMemoryStorage.applyChanges(InMemoryStorage.kt:179)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.close(InMemoryStorage.kt:136)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.AppendableSetBasicMap.close(BasicMap.kt:157)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.forEachMapSafe(BasicMapsOwner.kt:87)
|
|
||||||
... 24 more
|
|
||||||
Suppressed: java.lang.IllegalArgumentException: this and base files have different roots: C:\Users\mohan\AppData\Local\Pub\Cache\hosted\pub.dev\shared_preferences_android-2.4.23\android\src\main\kotlin\io\flutter\plugins\sharedpreferences\MessagesAsync.g.kt and D:\2026\BT-CAR-App\hvbt_dash\android.
|
|
||||||
at kotlin.io.FilesKt__UtilsKt.toRelativeString(Utils.kt:117)
|
|
||||||
at kotlin.io.FilesKt__UtilsKt.relativeTo(Utils.kt:128)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.RelocatableFileToPathConverter.toPath(RelocatableFileToPathConverter.kt:24)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.FileDescriptor.save(FileToPathConverter.kt:33)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.FileDescriptor.save(FileToPathConverter.kt:30)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.AppendableCollectionExternalizer.save(LazyStorage.kt:151)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.AppendableCollectionExternalizer.save(LazyStorage.kt:142)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.doPut(PersistentMapImpl.java:447)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.put(PersistentMapImpl.java:426)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.put(PersistentHashMap.java:106)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.LazyStorage.set(LazyStorage.kt:80)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.applyChanges(InMemoryStorage.kt:108)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.AppendableInMemoryStorage.applyChanges(InMemoryStorage.kt:179)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.close(InMemoryStorage.kt:136)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.PersistentStorageWrapper.close(PersistentStorage.kt:124)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.forEachMapSafe(BasicMapsOwner.kt:87)
|
|
||||||
... 24 more
|
|
||||||
Suppressed: java.lang.Exception: Could not close incremental caches in D:\2026\BT-CAR-App\hvbt_dash\build\shared_preferences_android\kotlin\compileReleaseKotlin\cacheable\caches-jvm\lookups: id-to-file.tab, file-to-id.tab
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.forEachMapSafe(BasicMapsOwner.kt:95)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.close(BasicMapsOwner.kt:53)
|
|
||||||
at org.jetbrains.kotlin.incremental.LookupStorage.close(LookupStorage.kt:155)
|
|
||||||
... 23 more
|
|
||||||
Suppressed: java.lang.IllegalArgumentException: this and base files have different roots: C:\Users\mohan\AppData\Local\Pub\Cache\hosted\pub.dev\shared_preferences_android-2.4.23\android\src\main\kotlin\io\flutter\plugins\sharedpreferences\MessagesAsync.g.kt and D:\2026\BT-CAR-App\hvbt_dash\android.
|
|
||||||
at kotlin.io.FilesKt__UtilsKt.toRelativeString(Utils.kt:117)
|
|
||||||
at kotlin.io.FilesKt__UtilsKt.relativeTo(Utils.kt:128)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.RelocatableFileToPathConverter.toPath(RelocatableFileToPathConverter.kt:24)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.LegacyFileExternalizer.save(IdToFileMap.kt:51)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.LegacyFileExternalizer.save(IdToFileMap.kt:48)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.doPut(PersistentMapImpl.java:447)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.put(PersistentMapImpl.java:426)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.put(PersistentHashMap.java:106)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.LazyStorage.set(LazyStorage.kt:80)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.applyChanges(InMemoryStorage.kt:108)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.close(InMemoryStorage.kt:136)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.PersistentStorageWrapper.close(PersistentStorage.kt:124)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.forEachMapSafe(BasicMapsOwner.kt:87)
|
|
||||||
... 25 more
|
|
||||||
Suppressed: java.lang.IllegalArgumentException: this and base files have different roots: C:\Users\mohan\AppData\Local\Pub\Cache\hosted\pub.dev\shared_preferences_android-2.4.23\android\src\main\kotlin\io\flutter\plugins\sharedpreferences\MessagesAsync.g.kt and D:\2026\BT-CAR-App\hvbt_dash\android.
|
|
||||||
at kotlin.io.FilesKt__UtilsKt.toRelativeString(Utils.kt:117)
|
|
||||||
at kotlin.io.FilesKt__UtilsKt.relativeTo(Utils.kt:128)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.RelocatableFileToPathConverter.toPath(RelocatableFileToPathConverter.kt:24)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.FileDescriptor.getHashCode(FileToPathConverter.kt:50)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.FileDescriptor.getHashCode(FileToPathConverter.kt:30)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.containers.LinkedCustomHashMap.hashKey(LinkedCustomHashMap.java:109)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.containers.LinkedCustomHashMap.remove(LinkedCustomHashMap.java:153)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.containers.SLRUMap.remove(SLRUMap.java:89)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.flushAppendCache(PersistentMapImpl.java:1007)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.doPut(PersistentMapImpl.java:455)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.put(PersistentMapImpl.java:426)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.put(PersistentHashMap.java:106)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.LazyStorage.set(LazyStorage.kt:80)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.applyChanges(InMemoryStorage.kt:108)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.close(InMemoryStorage.kt:136)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.PersistentStorageWrapper.close(PersistentStorage.kt:124)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.forEachMapSafe(BasicMapsOwner.kt:87)
|
|
||||||
... 25 more
|
|
||||||
Suppressed: java.lang.Exception: Could not close incremental caches in D:\2026\BT-CAR-App\hvbt_dash\build\shared_preferences_android\kotlin\compileReleaseKotlin\cacheable\caches-jvm\inputs: source-to-output.tab
|
|
||||||
... 25 more
|
|
||||||
Suppressed: java.lang.IllegalArgumentException: this and base files have different roots: C:\Users\mohan\AppData\Local\Pub\Cache\hosted\pub.dev\shared_preferences_android-2.4.23\android\src\main\kotlin\io\flutter\plugins\sharedpreferences\MessagesAsync.g.kt and D:\2026\BT-CAR-App\hvbt_dash\android.
|
|
||||||
at kotlin.io.FilesKt__UtilsKt.toRelativeString(Utils.kt:117)
|
|
||||||
at kotlin.io.FilesKt__UtilsKt.relativeTo(Utils.kt:128)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.RelocatableFileToPathConverter.toPath(RelocatableFileToPathConverter.kt:24)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.FileDescriptor.getHashCode(FileToPathConverter.kt:50)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.FileDescriptor.getHashCode(FileToPathConverter.kt:30)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.containers.LinkedCustomHashMap.hashKey(LinkedCustomHashMap.java:109)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.containers.LinkedCustomHashMap.remove(LinkedCustomHashMap.java:153)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.containers.SLRUMap.remove(SLRUMap.java:89)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.flushAppendCache(PersistentMapImpl.java:1007)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.doPut(PersistentMapImpl.java:455)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.put(PersistentMapImpl.java:426)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.put(PersistentHashMap.java:106)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.LazyStorage.set(LazyStorage.kt:80)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.applyChanges(InMemoryStorage.kt:108)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.AppendableInMemoryStorage.applyChanges(InMemoryStorage.kt:179)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.close(InMemoryStorage.kt:136)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.AppendableSetBasicMap.close(BasicMap.kt:157)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.forEachMapSafe(BasicMapsOwner.kt:87)
|
|
||||||
... 24 more
|
|
||||||
|
|
||||||
|
|
||||||
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
209
docs/FIX_2026-10-12.md
Normal file
209
docs/FIX_2026-10-12.md
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
# HV BT Dashboard — Fix pass 2026-10-12 (client feedback round)
|
||||||
|
|
||||||
|
**Status:** `flutter analyze` (0 errors) and `flutter test` pass. APKs not yet
|
||||||
|
built; the bench checklist in §6.2 must pass before anything is sent to the
|
||||||
|
client.
|
||||||
|
|
||||||
|
**History:** the earlier uncommitted work and this pass are two commits on top
|
||||||
|
of `77a47eb`: first the earlier work (fuel pump, ECT/IAT/BAT, ECU-type
|
||||||
|
persistence), then this rework. To see this pass alone: `git show HEAD~1..HEAD`
|
||||||
|
(or `git diff HEAD~2 HEAD~1` for the earlier work). The original line-ending-heavy
|
||||||
|
snapshot commit is kept on branch `backup/snapshot-fb87e79`.
|
||||||
|
|
||||||
|
Review follow-ups applied in this pass: a reconnect in flight is discarded if
|
||||||
|
the user disconnects meanwhile; default-frame detection also requires bytes
|
||||||
|
32-36; the friendly error widget applies to release builds only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Client feedback
|
||||||
|
|
||||||
|
1. Phone 1 — app crashes (all permissions allowed).
|
||||||
|
2. Phone 2 — connects to S300, shows nothing correct.
|
||||||
|
3. Phone 3 — some data correct, some obviously wrong; RPM gauge steps and freezes.
|
||||||
|
4. "Too many requests from the app — use a timeout for the answer before sending a
|
||||||
|
new request; it affects our device MCU usage."
|
||||||
|
|
||||||
|
## 2. Root causes found
|
||||||
|
|
||||||
|
### 2.1 "Template frames" are the adapter's DEFAULT frame
|
||||||
|
The frozen frame the team has been working around (bytes 0-16
|
||||||
|
`1B 00 14 00 00 FF FF F2 03 18 00 00 10 00 00 00 C4`, bytes 32-36
|
||||||
|
`93 A0 A0 DC 7D`) is **byte-for-byte the example row printed in
|
||||||
|
`hvbt_protocol.pdf`**. It is the adapter's built-in default buffer, returned when
|
||||||
|
it has no fresh ECU data. Combined with the client's remark about MCU load, the
|
||||||
|
most likely cause is that the app asks again before the adapter has finished
|
||||||
|
reading the ECU. Holding "last good values" during these frames is what made the
|
||||||
|
RPM gauge freeze and then jump, and a session that only ever gets default frames
|
||||||
|
(seen live in HANDOFF.md) is exactly phone 2.
|
||||||
|
|
||||||
|
### 2.2 The poller flooded the adapter
|
||||||
|
Old `bt_poller.dart`: a 50 ms periodic timer, plus an *extra* request whenever a
|
||||||
|
reply took longer than 200 ms (no waiting for the outstanding one), plus a request
|
||||||
|
that was never cleared after a bad-checksum reply (re-fired after 200 ms). Settings
|
||||||
|
even offered 20 ms (50 req/s).
|
||||||
|
|
||||||
|
### 2.3 Wrong byte offsets
|
||||||
|
The PDF's column labels (`+26 +27 +28 +29 +2A … +2F +30 +31`) are **not hex**.
|
||||||
|
Real offsets are plain column counting 0..127. Proof: the four fields already
|
||||||
|
confirmed live (Gear 27, ECT 33, IAT 34, BAT 36) all match column counting, and
|
||||||
|
the default frame decodes to sensible defaults with it (MAP 101.0 kPa, ECT/IAT
|
||||||
|
20 °C, BAT 12.0 V, trims 0 %, gear 1). The code read several fields as hex:
|
||||||
|
|
||||||
|
| Field | Old byte | New byte | Old symptom |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Short trim | 41 (0x29) | 29 | showed ethanol byte |
|
||||||
|
| Long trim | 43 (0x2B) | 31 | wrong |
|
||||||
|
| PA (baro) | 44 (0x2C) | 32 | wrong |
|
||||||
|
| ERR00–ERR03 (DTCs) | 49–52 | 37–40 | phantom DTCs (e.g. SW6=0x60 → P0505/P0350) |
|
||||||
|
| Ethanol | 56 (0x38) | 41 | wrong |
|
||||||
|
| SW5 (FANC) | 67 (0x43) | 49 | always 0 (reserved area) |
|
||||||
|
| AF | 52 (0x34) | 55 | wrong |
|
||||||
|
|
||||||
|
Plus byte-order: INJ and AIN were read big-endian; the adapter is little-endian
|
||||||
|
(RPM/VSS/MAP confirmed live; KPro table labels MAP2 / AFCMD as L/H pairs).
|
||||||
|
|
||||||
|
### 2.4 Phone 1 crash — not proven yet
|
||||||
|
A Dart exception cannot close a Flutter app, so it is a native crash. Main
|
||||||
|
suspects: `flutter_bluetooth_serial 0.4.0` (unmaintained since ~2021, predates
|
||||||
|
Android 12 Bluetooth permissions, has a known "Reply already submitted" crash on a
|
||||||
|
failed connect) and the old picker screen calling `getBondedDevices` before the
|
||||||
|
Bluetooth permission was granted. **Need from client: phone model + Android
|
||||||
|
version, and a logcat of the crash.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Changes, file by file
|
||||||
|
|
||||||
|
### Bluetooth / polling
|
||||||
|
- **`lib/core/bluetooth/bt_poller.dart` — rewritten.**
|
||||||
|
- Exactly one request outstanding. Next request only after a complete,
|
||||||
|
checksum-valid 128-byte reply **or** a 500 ms timeout.
|
||||||
|
- After a timeout: 150 ms quiet period + receive buffer flush, so a late reply
|
||||||
|
is never taken as the answer to the next request.
|
||||||
|
- Pacing: one request per polling interval (default **100 ms = 10 Hz**, minimum
|
||||||
|
80 ms enforced), never less than 20 ms after a reply.
|
||||||
|
- Frame sync now requires the 2-byte header (`1B 00` S300 / `1B 01` KPro) + NEG8.
|
||||||
|
- 6 consecutive timeouts → `onLinkStalled` callback.
|
||||||
|
- DTC-reset and protocol-probe commands go through the same lock, so they never
|
||||||
|
overlap a datalog request (`sendCommandAwaitReply`, `sendCommandCollect`).
|
||||||
|
- Stats: requestsSent, validFrames, droppedFrames, timeouts, lastResponseTime.
|
||||||
|
- **`lib/core/bluetooth/bt_service.dart`** — connect guarded against double
|
||||||
|
attempts + 15 s timeout; write throws when not connected (so the poller counts
|
||||||
|
it); close wrapped in timeout/try; logging.
|
||||||
|
- **`lib/core/providers/bt_provider.dart` — rewritten.**
|
||||||
|
- New `BtStatus.reconnecting`; new `EcuDataStatus` (none / live /
|
||||||
|
adapterDefaults / noResponse).
|
||||||
|
- **Adapter default frames are filtered out here** — they never reach the
|
||||||
|
gauges, graph or datalog; they are counted and shown on the Debug screen.
|
||||||
|
- Auto-reconnect: on BT link loss (up to 5 attempts, 1-5 s backoff); on 6
|
||||||
|
consecutive timeouts or >8 s of default-only frames, at most once per 45 s.
|
||||||
|
- Settings changes (poll interval) apply to a running poller.
|
||||||
|
- `clearDtcs()` / `probeOfficialProtocol()` reimplemented via the poller lock;
|
||||||
|
same return types as before.
|
||||||
|
|
||||||
|
### Decoding
|
||||||
|
- **`lib/core/protocol/s300_parser.dart`** — offsets/formulas per §2.3; full offset
|
||||||
|
map in the file header; `isS300DefaultFrame()` (old name
|
||||||
|
`isS300TemplateFrame()` kept as an alias); fan output = SW5 bit0 FANC only
|
||||||
|
(SW2 bit7 is ALTC = alternator control).
|
||||||
|
- **`lib/core/protocol/kpro_parser.dart`** — RPM, MAP2, AIN read little-endian.
|
||||||
|
⚠ KPro RPM endianness must be checked on the bench (2000 rpm must read 2000).
|
||||||
|
- **`lib/core/providers/sensor_provider.dart`** — removed hold-last-value logic and
|
||||||
|
the TEMP debug `print`s; decode errors go to the app log.
|
||||||
|
- **`lib/core/models/sensor_defs.dart`** — O2, AFR, AIN0-7 shown as `raw` (scales
|
||||||
|
not verified) instead of fake V/λ ranges.
|
||||||
|
|
||||||
|
### UI / robustness
|
||||||
|
- **`lib/main.dart`** — `runZonedGuarded` + `FlutterError.onError` +
|
||||||
|
`PlatformDispatcher.onError` → app log; friendly error widget.
|
||||||
|
- **`lib/core/logging/app_log.dart` (new)** — 400-line in-memory log.
|
||||||
|
- **`lib/ui/screens/debug/raw_frame_debug_screen.dart`** — "Copy app log" button;
|
||||||
|
new link stats (requests, live vs default frames, timeouts, reply time,
|
||||||
|
reconnects); raw-candidate rows use the corrected offsets.
|
||||||
|
- **`lib/ui/widgets/data_status_banner.dart` (new)** + `main_nav.dart` — banner
|
||||||
|
under the app bar when data is not live ("Adapter connected, but no data from
|
||||||
|
the ECU", "Adapter is not responding", "Reconnecting…").
|
||||||
|
- **`lib/ui/widgets/bt_status_chip.dart`** — reconnecting state.
|
||||||
|
- **`lib/ui/screens/bt_picker/bt_picker_screen.dart`** — requests permissions
|
||||||
|
**before** touching the paired-device list; only BLUETOOTH_CONNECT is required
|
||||||
|
(scan/location optional); try/catch around plugin calls; double-tap guard.
|
||||||
|
- **`lib/ui/screens/dashboard/widgets/analog_gauge.dart`** — 110 ms linear needle
|
||||||
|
animation (was 150 ms ease-out) so the needle glides between 10 Hz readings.
|
||||||
|
- **`lib/core/providers/settings_provider.dart` + settings screen** — interval
|
||||||
|
options 100/150/200/300 ms (removed 20 and 50), default 100, persisted.
|
||||||
|
- **`lib/core/providers/sensor_history_provider.dart`** — graph now actually
|
||||||
|
redraws live (`state = state` was swallowed by StateNotifier).
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
- `test/s300_parser_test.dart` — INJ builder LE; new tests for trims, DTC bytes,
|
||||||
|
no phantom DTC from SW6, FANC, Eth/AF/PA offsets, INJ/AIN LE, and decoding the
|
||||||
|
full PDF default frame.
|
||||||
|
- `test/kpro_parser_test.dart` — builder writes RPM and MAP2 little-endian.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Still UNVERIFIED (need one bench check against the USB tool)
|
||||||
|
| Item | Current assumption |
|
||||||
|
|---|---|
|
||||||
|
| INJ scale | raw (LE) / 1000 → ms (assumes µs) |
|
||||||
|
| IGN formula | PDF `(raw+120)/2` — one earlier live check disagreed |
|
||||||
|
| PA formula | `raw*0.716 - 5` kPa (default 0x93 → 100.3 kPa) |
|
||||||
|
| O2, AF, AIN | shown raw |
|
||||||
|
| KPro RPM byte order | little-endian |
|
||||||
|
| DTC clear ack | sent between polls now; ack still unconfirmed |
|
||||||
|
|
||||||
|
## 5. Questions for the client (device maker)
|
||||||
|
1. Max response time of the adapter to `1B 00 E5`? (sets our 500 ms timeout)
|
||||||
|
2. Recommended request interval? (we use 100 ms; DLrate byte = 0x14 = 20 — is that
|
||||||
|
20 Hz?)
|
||||||
|
3. Confirm: the PDF example frame = "no fresh ECU data" default frame?
|
||||||
|
4. Phone models + Android versions; logcat from the crashing phone.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Verification checklist
|
||||||
|
|
||||||
|
### 6.1 Static (must all pass before building)
|
||||||
|
- [ ] `flutter pub get`
|
||||||
|
- [ ] `flutter analyze` — no errors (fix any compile errors in changed files only)
|
||||||
|
- [ ] `flutter test` — all tests green
|
||||||
|
- [ ] `flutter build apk --release` (and `--debug` for the bench)
|
||||||
|
- [ ] Release build: if it crashes on connect but debug doesn't, add R8 keep rule
|
||||||
|
`-keep class io.github.edufolly.flutterbluetoothserial.** { *; }`
|
||||||
|
|
||||||
|
### 6.2 Bench (S300 + USB reference tool)
|
||||||
|
- [ ] Debug screen: *Requests sent* rises ≈10/s, never faster; *Timeouts* ≈ 0.
|
||||||
|
- [ ] *Adapter default frames* stays ≈ 0 while the ECU is on (main proof the
|
||||||
|
flooding was the cause). If it is still high → report to client with log.
|
||||||
|
- [ ] RPM sweep up/down: needle moves smoothly, no freezes/steps.
|
||||||
|
- [ ] Compare against USB tool: RPM, MAP, TPS, ECT, IAT, BAT, gear (already
|
||||||
|
confirmed) + trims, PA, INJ, IGN, ETH (new).
|
||||||
|
- [ ] DTC tab: no phantom codes; a forced fault shows the right code.
|
||||||
|
- [ ] Unplug adapter power → banner "Reconnecting…", then error; replug +
|
||||||
|
reconnect works.
|
||||||
|
- [ ] Ignition off with adapter powered → banner "no data from the ECU".
|
||||||
|
- [ ] Debug → "Copy app log" pastes a readable log.
|
||||||
|
- [ ] Fresh install on Android 12+: permission prompt first, device list after,
|
||||||
|
no crash. Deny "Nearby devices" → clear message, no crash.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Prompt for Claude Code (paste as-is)
|
||||||
|
|
||||||
|
```
|
||||||
|
Read docs/FIX_2026-10-12.md fully. Then:
|
||||||
|
1. Run `git diff fb87e79 --stat` and review every changed file against the doc.
|
||||||
|
Flag anything in the diff that the doc does not describe, and any logic bug
|
||||||
|
(especially in lib/core/bluetooth/bt_poller.dart and
|
||||||
|
lib/core/providers/bt_provider.dart: one request outstanding at a time,
|
||||||
|
timeout handling, reconnect loop, stream/timer cleanup on disconnect).
|
||||||
|
2. Check the byte-offset table in lib/core/protocol/s300_parser.dart against
|
||||||
|
hvbt_protocol.pdf using plain column counting (0..127), as the doc explains.
|
||||||
|
3. Run `flutter pub get`, `flutter analyze`, `flutter test`. Fix compile/analyzer
|
||||||
|
errors and failing tests with minimal changes; do not change protocol logic
|
||||||
|
or offsets without telling me why.
|
||||||
|
4. Build: `flutter build apk --debug` and `flutter build apk --release`.
|
||||||
|
5. Report: what you fixed, analyzer/test results, APK paths, and any concerns.
|
||||||
|
Do not commit until I say so.
|
||||||
|
```
|
||||||
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.
|
||||||
@ -1,160 +1,320 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import '../logging/app_log.dart';
|
||||||
import '../protocol/neg8.dart';
|
import '../protocol/neg8.dart';
|
||||||
import 'bt_service.dart';
|
import 'bt_service.dart';
|
||||||
|
|
||||||
enum EcuType { s300, kpro }
|
enum EcuType { s300, kpro }
|
||||||
|
|
||||||
/// Sends the ECU request byte every [pollingInterval] ms,
|
/// ECU type implied by the adapter's Bluetooth name, or null if the name
|
||||||
/// accumulates raw bytes into 128-byte frames, validates NEG8,
|
/// doesn't say. HV adapters advertise it, e.g. "S300[94]-SPP",
|
||||||
/// and emits the newest usable frame on [frameStream].
|
/// "HVS300-SPP[94]"; a KPro adapter is expected to contain "KPRO"/"K-PRO".
|
||||||
|
EcuType? ecuTypeFromDeviceName(String? name) {
|
||||||
|
if (name == null) return null;
|
||||||
|
final n = name.toUpperCase().replaceAll('-', '');
|
||||||
|
final s300 = n.contains('S300');
|
||||||
|
final kpro = n.contains('KPRO');
|
||||||
|
if (s300 == kpro) return null; // neither, or ambiguous
|
||||||
|
return s300 ? EcuType.s300 : EcuType.kpro;
|
||||||
|
}
|
||||||
|
|
||||||
|
typedef _Matcher = Uint8List? Function(List<int> rxBuf);
|
||||||
|
|
||||||
|
class _Pending {
|
||||||
|
_Pending(this.match);
|
||||||
|
final _Matcher match;
|
||||||
|
final Completer<Uint8List?> completer = Completer<Uint8List?>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strict request/response poller for the HV BT adapter.
|
||||||
|
///
|
||||||
|
/// Rules (agreed with the device maker's feedback — the adapter MCU must not
|
||||||
|
/// be flooded):
|
||||||
|
/// * Exactly ONE request is outstanding at any time. A new request is only
|
||||||
|
/// sent after the previous one was answered with a complete, checksum-valid
|
||||||
|
/// 128-byte frame, or after [responseTimeout] expired.
|
||||||
|
/// * After a timeout the link is left quiet for [quietAfterTimeout] and the
|
||||||
|
/// receive buffer is flushed, so a late reply can't be mistaken for the
|
||||||
|
/// answer to the next request.
|
||||||
|
/// * Requests are paced: at most one every [pollingInterval] (measured from
|
||||||
|
/// request to request) and never less than [minGap] after a reply.
|
||||||
|
/// * After [stallThreshold] consecutive timeouts, [onLinkStalled] fires so the
|
||||||
|
/// owner can reconnect.
|
||||||
|
///
|
||||||
|
/// Other commands (DTC reset, protocol probe) go through the same lock, so they
|
||||||
|
/// never interleave with a datalog request.
|
||||||
class BtPoller {
|
class BtPoller {
|
||||||
|
BtPoller(
|
||||||
|
this._service, {
|
||||||
|
this.ecuType = EcuType.s300,
|
||||||
|
Duration pollingInterval = defaultInterval,
|
||||||
|
this.responseTimeout = const Duration(milliseconds: 500),
|
||||||
|
this.onLinkStalled,
|
||||||
|
}) : _pollingInterval = _sanitize(pollingInterval);
|
||||||
|
|
||||||
|
static const Duration defaultInterval = Duration(milliseconds: 100);
|
||||||
|
static const Duration minInterval = Duration(milliseconds: 80);
|
||||||
|
static const Duration minGap = Duration(milliseconds: 20);
|
||||||
|
static const Duration quietAfterTimeout = Duration(milliseconds: 150);
|
||||||
|
static const int stallThreshold = 6;
|
||||||
|
static const int frameLength = 128;
|
||||||
|
static const int _maxBuffer = 4096;
|
||||||
|
|
||||||
|
// S300 request: [0x1B, 0x00, NEG8] / KPro request: [0x1B, 0x01, NEG8]
|
||||||
|
static final Uint8List _s300Request = Uint8List.fromList([0x1B, 0x00, 0xE5]);
|
||||||
|
static final Uint8List _kproRequest = Uint8List.fromList([0x1B, 0x01, 0xE4]);
|
||||||
|
|
||||||
final BtService _service;
|
final BtService _service;
|
||||||
EcuType ecuType;
|
EcuType ecuType;
|
||||||
final Duration pollingInterval;
|
final Duration responseTimeout;
|
||||||
|
final void Function()? onLinkStalled;
|
||||||
|
|
||||||
// S300 request: [0x1B, 0x00, 0xE5]
|
Duration _pollingInterval;
|
||||||
static final Uint8List _s300Request = Uint8List.fromList([0x1B, 0x00, 0xE5]);
|
Duration get pollingInterval => _pollingInterval;
|
||||||
// KPro request: [0x1B, 0x01, 0xE4]
|
set pollingInterval(Duration d) => _pollingInterval = _sanitize(d);
|
||||||
static final Uint8List _kproRequest = Uint8List.fromList([0x1B, 0x01, 0xE4]);
|
|
||||||
|
static Duration _sanitize(Duration d) => d < minInterval ? minInterval : d;
|
||||||
|
|
||||||
final StreamController<Uint8List> _frameController =
|
final StreamController<Uint8List> _frameController =
|
||||||
StreamController<Uint8List>.broadcast();
|
StreamController<Uint8List>.broadcast();
|
||||||
|
|
||||||
|
/// Checksum-valid 128-byte frames, one per answered request.
|
||||||
Stream<Uint8List> get frameStream => _frameController.stream;
|
Stream<Uint8List> get frameStream => _frameController.stream;
|
||||||
|
|
||||||
final List<int> _rxBuf = [];
|
final List<int> _rxBuf = [];
|
||||||
Timer? _pollTimer;
|
|
||||||
StreamSubscription<Uint8List>? _rxSub;
|
StreamSubscription<Uint8List>? _rxSub;
|
||||||
|
_Pending? _pending;
|
||||||
|
bool _running = false;
|
||||||
|
int _generation = 0;
|
||||||
|
|
||||||
int droppedFrames = 0;
|
// Serialises every exchange on the link (polls and one-off commands).
|
||||||
|
Future<void> _lockTail = Future<void>.value();
|
||||||
|
|
||||||
|
// ── Stats (read by the debug screen) ──────────────────────────────────────
|
||||||
|
int requestsSent = 0;
|
||||||
int validFrames = 0;
|
int validFrames = 0;
|
||||||
|
int droppedFrames = 0; // checksum failures
|
||||||
|
int timeouts = 0;
|
||||||
|
int consecutiveTimeouts = 0;
|
||||||
|
Duration? lastResponseTime;
|
||||||
|
|
||||||
// Tracks whether a request is still awaiting its response, so the poller
|
bool get isRunning => _running;
|
||||||
// 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() {
|
void start() {
|
||||||
|
if (_running) return;
|
||||||
|
_running = true;
|
||||||
|
final gen = ++_generation;
|
||||||
_rxBuf.clear();
|
_rxBuf.clear();
|
||||||
_awaitingResponse = false;
|
consecutiveTimeouts = 0;
|
||||||
_lastRequestSentAt = null;
|
|
||||||
|
|
||||||
// Listen to raw bytes from BT and accumulate into 128-byte frames
|
|
||||||
_rxSub = _service.rawStream.listen(
|
_rxSub = _service.rawStream.listen(
|
||||||
(Uint8List chunk) {
|
_onRx,
|
||||||
_rxBuf.addAll(chunk);
|
|
||||||
_processBuffer();
|
|
||||||
},
|
|
||||||
onError: (Object e) {
|
onError: (Object e) {
|
||||||
_frameController.addError(e);
|
AppLog.w('BT stream error: $e');
|
||||||
stop();
|
if (!_frameController.isClosed) _frameController.addError(e);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
AppLog.i('Poller started (${ecuType.name}, every '
|
||||||
// Check every pollingInterval whether it's time to send the next
|
'${_pollingInterval.inMilliseconds} ms, timeout '
|
||||||
// request — but only actually send once the prior one has been
|
'${responseTimeout.inMilliseconds} ms)');
|
||||||
// answered (or has clearly been lost).
|
unawaited(_loop(gen));
|
||||||
_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() {
|
void stop() {
|
||||||
_pollTimer?.cancel();
|
if (!_running) return;
|
||||||
_pollTimer = null;
|
_running = false;
|
||||||
|
_generation++;
|
||||||
_rxSub?.cancel();
|
_rxSub?.cancel();
|
||||||
_rxSub = null;
|
_rxSub = null;
|
||||||
|
final p = _pending;
|
||||||
|
if (p != null && !p.completer.isCompleted) p.completer.complete(null);
|
||||||
|
_pending = null;
|
||||||
_rxBuf.clear();
|
_rxBuf.clear();
|
||||||
|
AppLog.i('Poller stopped (sent=$requestsSent ok=$validFrames '
|
||||||
|
'timeouts=$timeouts badChecksum=$droppedFrames)');
|
||||||
}
|
}
|
||||||
|
|
||||||
void dispose() {
|
void dispose() {
|
||||||
stop();
|
stop();
|
||||||
_frameController.close();
|
_frameController.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Main loop ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Future<void> _loop(int gen) async {
|
||||||
|
bool alive() => _running && gen == _generation;
|
||||||
|
|
||||||
|
while (alive()) {
|
||||||
|
final started = DateTime.now();
|
||||||
|
|
||||||
|
await _withLink(() async {
|
||||||
|
if (!alive() || !_service.isConnected) return;
|
||||||
|
final req = ecuType == EcuType.s300 ? _s300Request : _kproRequest;
|
||||||
|
final frame = await _transact(req, _matchDatalogFrame, responseTimeout);
|
||||||
|
if (!alive()) return;
|
||||||
|
|
||||||
|
if (frame != null) {
|
||||||
|
validFrames++;
|
||||||
|
consecutiveTimeouts = 0;
|
||||||
|
lastResponseTime = DateTime.now().difference(started);
|
||||||
|
if (!_frameController.isClosed) _frameController.add(frame);
|
||||||
|
} else {
|
||||||
|
timeouts++;
|
||||||
|
consecutiveTimeouts++;
|
||||||
|
if (consecutiveTimeouts == 1 || consecutiveTimeouts % 10 == 0) {
|
||||||
|
AppLog.w('No reply within ${responseTimeout.inMilliseconds} ms '
|
||||||
|
'(consecutive=$consecutiveTimeouts, total=$timeouts)');
|
||||||
|
}
|
||||||
|
// Let any late reply arrive and be discarded before the next request.
|
||||||
|
await Future<void>.delayed(quietAfterTimeout);
|
||||||
|
_rxBuf.clear();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!alive()) break;
|
||||||
|
|
||||||
|
if (consecutiveTimeouts >= stallThreshold) {
|
||||||
|
consecutiveTimeouts = 0;
|
||||||
|
AppLog.w('Link stalled: $stallThreshold requests in a row unanswered');
|
||||||
|
onLinkStalled?.call();
|
||||||
|
}
|
||||||
|
|
||||||
|
final elapsed = DateTime.now().difference(started);
|
||||||
|
var wait = _pollingInterval - elapsed;
|
||||||
|
if (wait < minGap) wait = minGap;
|
||||||
|
await Future<void>.delayed(wait);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── One-off commands ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Sends [command] (between two polls, never overlapping one) and waits up
|
||||||
|
/// to [timeout] for [expected] to appear in the reply bytes.
|
||||||
|
Future<({bool acknowledged, Uint8List raw})> sendCommandAwaitReply(
|
||||||
|
Uint8List command,
|
||||||
|
Uint8List expected, {
|
||||||
|
Duration timeout = const Duration(seconds: 2),
|
||||||
|
}) {
|
||||||
|
return _withLink(() async {
|
||||||
|
await Future<void>.delayed(quietAfterTimeout);
|
||||||
|
_rxBuf.clear();
|
||||||
|
final match = await _transact(
|
||||||
|
command,
|
||||||
|
(buf) => _indexOf(buf, expected) >= 0 ? expected : null,
|
||||||
|
timeout,
|
||||||
|
);
|
||||||
|
final raw = Uint8List.fromList(_rxBuf);
|
||||||
|
_rxBuf.clear();
|
||||||
|
return (acknowledged: match != null, raw: raw);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends [command] and returns every byte received during [window].
|
||||||
|
Future<Uint8List> sendCommandCollect(Uint8List command, Duration window) {
|
||||||
|
return _withLink(() async {
|
||||||
|
await Future<void>.delayed(quietAfterTimeout);
|
||||||
|
_rxBuf.clear();
|
||||||
|
await _transact(command, (_) => null, window);
|
||||||
|
final raw = Uint8List.fromList(_rxBuf);
|
||||||
|
_rxBuf.clear();
|
||||||
|
return raw;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Internals ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Future<T> _withLink<T>(Future<T> Function() action) {
|
||||||
|
final previous = _lockTail;
|
||||||
|
final release = Completer<void>();
|
||||||
|
_lockTail = release.future;
|
||||||
|
return previous.then((_) => action()).whenComplete(release.complete);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes [request] and waits for [match] to succeed or [timeout]. Must be
|
||||||
|
/// called while holding the link lock.
|
||||||
|
Future<Uint8List?> _transact(
|
||||||
|
Uint8List request,
|
||||||
|
_Matcher match,
|
||||||
|
Duration timeout,
|
||||||
|
) async {
|
||||||
|
_rxBuf.clear();
|
||||||
|
final pending = _Pending(match);
|
||||||
|
_pending = pending;
|
||||||
|
try {
|
||||||
|
await _service.write(request).timeout(const Duration(seconds: 1));
|
||||||
|
requestsSent++;
|
||||||
|
} catch (e) {
|
||||||
|
AppLog.w('BT write failed: $e');
|
||||||
|
if (identical(_pending, pending)) _pending = null;
|
||||||
|
// Don't spin on a broken link.
|
||||||
|
await Future<void>.delayed(quietAfterTimeout);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
_tryComplete();
|
||||||
|
try {
|
||||||
|
return await pending.completer.future.timeout(timeout);
|
||||||
|
} on TimeoutException {
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
if (identical(_pending, pending)) _pending = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onRx(Uint8List chunk) {
|
||||||
|
if (_pending == null) {
|
||||||
|
// Nobody is waiting: stray/late bytes. Drop them.
|
||||||
|
_rxBuf.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_rxBuf.addAll(chunk);
|
||||||
|
if (_rxBuf.length > _maxBuffer) {
|
||||||
|
_rxBuf.removeRange(0, _rxBuf.length - _maxBuffer);
|
||||||
|
}
|
||||||
|
_tryComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _tryComplete() {
|
||||||
|
final p = _pending;
|
||||||
|
if (p == null || p.completer.isCompleted) return;
|
||||||
|
final result = p.match(_rxBuf);
|
||||||
|
if (result != null) p.completer.complete(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finds a complete, checksum-valid 128-byte datalog frame starting with
|
||||||
|
/// the expected 2-byte header. Consumes bytes it has ruled out.
|
||||||
|
Uint8List? _matchDatalogFrame(List<int> buf) {
|
||||||
|
final id2 = ecuType == EcuType.s300 ? 0x00 : 0x01;
|
||||||
|
while (true) {
|
||||||
|
var i = 0;
|
||||||
|
while (i + 1 < buf.length && !(buf[i] == 0x1B && buf[i + 1] == id2)) {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
if (buf.length - i < frameLength) {
|
||||||
|
if (i > 0) buf.removeRange(0, i);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final frame = Uint8List.fromList(buf.sublist(i, i + frameLength));
|
||||||
|
if (validateFrame(frame)) {
|
||||||
|
buf.removeRange(0, i + frameLength);
|
||||||
|
return frame;
|
||||||
|
}
|
||||||
|
// Bad checksum: either a false header inside data or a corrupted frame.
|
||||||
|
droppedFrames++;
|
||||||
|
buf.removeRange(0, i + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static int _indexOf(List<int> buf, List<int> pattern) {
|
||||||
|
for (var i = 0; i + pattern.length <= buf.length; i++) {
|
||||||
|
var ok = true;
|
||||||
|
for (var j = 0; j < pattern.length; j++) {
|
||||||
|
if (buf[i + j] != pattern[j]) {
|
||||||
|
ok = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ok) return i;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,13 +3,27 @@ import 'dart:typed_data';
|
|||||||
|
|
||||||
import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart';
|
import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart';
|
||||||
|
|
||||||
|
import '../logging/app_log.dart';
|
||||||
|
|
||||||
/// Wraps flutter_bluetooth_serial for SPP/RFCOMM Classic BT.
|
/// Wraps flutter_bluetooth_serial for SPP/RFCOMM Classic BT.
|
||||||
|
///
|
||||||
|
/// Every platform call is wrapped in a timeout and try/catch: the plugin is
|
||||||
|
/// old and some Android builds throw (or never answer) on connect/close.
|
||||||
class BtService {
|
class BtService {
|
||||||
BluetoothConnection? _connection;
|
BluetoothConnection? _connection;
|
||||||
|
StreamSubscription<Uint8List>? _inputSub;
|
||||||
|
bool _connecting = false;
|
||||||
|
|
||||||
final StreamController<Uint8List> _rawFrameController =
|
final StreamController<Uint8List> _rawFrameController =
|
||||||
StreamController<Uint8List>.broadcast();
|
StreamController<Uint8List>.broadcast();
|
||||||
|
|
||||||
bool get isConnected => _connection?.isConnected ?? false;
|
bool get isConnected {
|
||||||
|
try {
|
||||||
|
return _connection?.isConnected ?? false;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Stream<Uint8List> get rawStream => _rawFrameController.stream;
|
Stream<Uint8List> get rawStream => _rawFrameController.stream;
|
||||||
|
|
||||||
@ -18,35 +32,64 @@ class BtService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> connect(BluetoothDevice device) async {
|
Future<void> connect(BluetoothDevice device) async {
|
||||||
if (_connection != null) {
|
if (_connecting) {
|
||||||
await disconnect();
|
throw StateError('A connection attempt is already in progress');
|
||||||
|
}
|
||||||
|
_connecting = true;
|
||||||
|
try {
|
||||||
|
if (_connection != null) {
|
||||||
|
await disconnect();
|
||||||
|
}
|
||||||
|
AppLog.i('Connecting to ${device.name ?? '?'} (${device.address})');
|
||||||
|
final conn = await BluetoothConnection.toAddress(device.address)
|
||||||
|
.timeout(const Duration(seconds: 15));
|
||||||
|
_connection = conn;
|
||||||
|
_inputSub = conn.input?.listen(
|
||||||
|
(Uint8List data) {
|
||||||
|
if (!_rawFrameController.isClosed) _rawFrameController.add(data);
|
||||||
|
},
|
||||||
|
onDone: () {
|
||||||
|
AppLog.w('BT input stream closed by remote/OS');
|
||||||
|
if (!_rawFrameController.isClosed && identical(_connection, conn)) {
|
||||||
|
_rawFrameController.addError('BT connection closed');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (Object e) {
|
||||||
|
AppLog.w('BT input error: $e');
|
||||||
|
if (!_rawFrameController.isClosed) _rawFrameController.addError(e);
|
||||||
|
},
|
||||||
|
cancelOnError: false,
|
||||||
|
);
|
||||||
|
AppLog.i('Connected');
|
||||||
|
} on TimeoutException {
|
||||||
|
AppLog.w('Connect timed out');
|
||||||
|
throw Exception('Connection timed out — is the adapter powered and '
|
||||||
|
'not connected to another phone?');
|
||||||
|
} finally {
|
||||||
|
_connecting = false;
|
||||||
}
|
}
|
||||||
_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 {
|
Future<void> write(Uint8List data) async {
|
||||||
if (_connection?.isConnected ?? false) {
|
final conn = _connection;
|
||||||
_connection!.output.add(data);
|
if (conn == null || !isConnected) {
|
||||||
await _connection!.output.allSent;
|
throw StateError('Not connected');
|
||||||
}
|
}
|
||||||
|
conn.output.add(data);
|
||||||
|
await conn.output.allSent;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> disconnect() async {
|
Future<void> disconnect() async {
|
||||||
await _connection?.close();
|
final conn = _connection;
|
||||||
_connection = null;
|
_connection = null;
|
||||||
|
await _inputSub?.cancel();
|
||||||
|
_inputSub = null;
|
||||||
|
if (conn == null) return;
|
||||||
|
try {
|
||||||
|
await conn.close().timeout(const Duration(seconds: 3));
|
||||||
|
} catch (e) {
|
||||||
|
AppLog.w('BT close error (ignored): $e');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
|||||||
93
lib/core/bluetooth/rpm_glitch_filter.dart
Normal file
93
lib/core/bluetooth/rpm_glitch_filter.dart
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
import 'dart:collection';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
/// Drops frames whose RPM jumps further than an engine can in the time since
|
||||||
|
/// the last accepted frame — the same thing the USB tool appears to do.
|
||||||
|
///
|
||||||
|
/// Seen on the KPro bench with a crank-signal fault (P0336): roughly every
|
||||||
|
/// third frame the ECU reported ~250-330 rpm (with matching long injection
|
||||||
|
/// and low-RPM knock threshold) between the real readings, sometimes several
|
||||||
|
/// in a row, so the dial flickered. The USB tool shows a steady value there.
|
||||||
|
///
|
||||||
|
/// A real change still gets through by majority: once [confirmVotes] of the
|
||||||
|
/// last [window] readings agree (within 20 %) on a value, it is accepted even
|
||||||
|
/// if glitch frames are mixed in. (A "consecutive frames" rule never
|
||||||
|
/// confirmed a real change while glitches kept interrupting it, which froze
|
||||||
|
/// the dial.) [onConfirmed] reports each such accepted jump.
|
||||||
|
class RpmGlitchFilter {
|
||||||
|
RpmGlitchFilter({
|
||||||
|
this.baseDelta = 300,
|
||||||
|
this.maxRate = 4000,
|
||||||
|
this.window = 10,
|
||||||
|
this.confirmVotes = 6,
|
||||||
|
this.onConfirmed,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Allowed jump regardless of elapsed time (rpm).
|
||||||
|
final double baseDelta;
|
||||||
|
|
||||||
|
/// Allowed rate of change (rpm per second).
|
||||||
|
final double maxRate;
|
||||||
|
|
||||||
|
/// Number of recent readings kept for the majority vote.
|
||||||
|
final int window;
|
||||||
|
|
||||||
|
/// Readings out of [window] that must agree to accept a new value.
|
||||||
|
final int confirmVotes;
|
||||||
|
|
||||||
|
final void Function(double from, double to)? onConfirmed;
|
||||||
|
|
||||||
|
double? _lastRpm;
|
||||||
|
DateTime? _lastAt;
|
||||||
|
final _recent = ListQueue<double>();
|
||||||
|
|
||||||
|
/// Frames dropped so far.
|
||||||
|
int dropped = 0;
|
||||||
|
|
||||||
|
void reset() {
|
||||||
|
_lastRpm = null;
|
||||||
|
_lastAt = null;
|
||||||
|
_recent.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// RPM in a raw frame: KPro (`1B 01`) bytes 2-3 LE / 4, S300 bytes 3-4 LE.
|
||||||
|
static double rpmOf(Uint8List frame) => frame[1] == 0x01
|
||||||
|
? (frame[2] | (frame[3] << 8)) / 4.0
|
||||||
|
: (frame[3] | (frame[4] << 8)).toDouble();
|
||||||
|
|
||||||
|
bool _agree(double a, double b) =>
|
||||||
|
(a - b).abs() <= 0.2 * math.max(a.abs(), b.abs()) + baseDelta;
|
||||||
|
|
||||||
|
/// True if [frame] should be shown/recorded, false if it is a glitch.
|
||||||
|
bool accept(Uint8List frame, DateTime at) {
|
||||||
|
final rpm = rpmOf(frame);
|
||||||
|
_recent.addLast(rpm);
|
||||||
|
while (_recent.length > window) {
|
||||||
|
_recent.removeFirst();
|
||||||
|
}
|
||||||
|
|
||||||
|
final last = _lastRpm;
|
||||||
|
if (last == null) return _take(rpm, at);
|
||||||
|
|
||||||
|
// Capped: while frames are being dropped the last accepted one ages, and
|
||||||
|
// an uncapped allowance would eventually let a glitch through. Larger
|
||||||
|
// real changes are taken by the majority vote below.
|
||||||
|
final dt = math.min(at.difference(_lastAt!).inMicroseconds / 1e6, 0.25);
|
||||||
|
if ((rpm - last).abs() <= baseDelta + maxRate * dt) return _take(rpm, at);
|
||||||
|
|
||||||
|
final votes = _recent.where((r) => _agree(r, rpm)).length;
|
||||||
|
if (votes >= confirmVotes) {
|
||||||
|
onConfirmed?.call(last, rpm);
|
||||||
|
return _take(rpm, at);
|
||||||
|
}
|
||||||
|
dropped++;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _take(double rpm, DateTime at) {
|
||||||
|
_lastRpm = rpm;
|
||||||
|
_lastAt = at;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -65,9 +65,12 @@ class DatalogPlayer {
|
|||||||
_status = PlaybackStatus.idle;
|
_status = PlaybackStatus.idle;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Jump to a specific frame index.
|
/// Jump to a specific frame index. The frame there is emitted at once so
|
||||||
|
/// the displays follow the slider even while paused.
|
||||||
void seek(int index) {
|
void seek(int index) {
|
||||||
|
if (_frames.isEmpty) return;
|
||||||
_position = index.clamp(0, _frames.length - 1);
|
_position = index.clamp(0, _frames.length - 1);
|
||||||
|
_controller.add(_frames[_position].data);
|
||||||
onProgress?.call(_position, _frames.length);
|
onProgress?.call(_position, _frames.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
42
lib/core/logging/app_log.dart
Normal file
42
lib/core/logging/app_log.dart
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import 'dart:collection';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
/// Small in-memory log the client can copy from the Debug screen and send
|
||||||
|
/// back with a bug report. Keeps the newest [_capacity] lines only.
|
||||||
|
class AppLog {
|
||||||
|
AppLog._();
|
||||||
|
|
||||||
|
static const int _capacity = 400;
|
||||||
|
static final ListQueue<String> _lines = ListQueue<String>();
|
||||||
|
|
||||||
|
static void i(String msg) => _add('I', msg);
|
||||||
|
static void w(String msg) => _add('W', msg);
|
||||||
|
static void e(String msg, [Object? error, StackTrace? stack]) {
|
||||||
|
final b = StringBuffer(msg);
|
||||||
|
if (error != null) b.write(' | $error');
|
||||||
|
if (stack != null) {
|
||||||
|
// First few frames are enough to locate a crash.
|
||||||
|
final frames = stack.toString().split('\n').take(8).join('\n ');
|
||||||
|
b.write('\n $frames');
|
||||||
|
}
|
||||||
|
_add('E', b.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _add(String level, String msg) {
|
||||||
|
final t = DateTime.now();
|
||||||
|
String two(int v) => v.toString().padLeft(2, '0');
|
||||||
|
final ts = '${two(t.hour)}:${two(t.minute)}:${two(t.second)}.'
|
||||||
|
'${t.millisecond.toString().padLeft(3, '0')}';
|
||||||
|
final line = '$ts $level $msg';
|
||||||
|
_lines.addLast(line);
|
||||||
|
while (_lines.length > _capacity) {
|
||||||
|
_lines.removeFirst();
|
||||||
|
}
|
||||||
|
debugPrint(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String dump() => _lines.join('\n');
|
||||||
|
|
||||||
|
static int get length => _lines.length;
|
||||||
|
}
|
||||||
@ -1,7 +1,14 @@
|
|||||||
|
import '../protocol/sensor_state.dart';
|
||||||
|
|
||||||
/// Definition of a boolean flag sensor shown as indicator pills.
|
/// Definition of a boolean flag sensor shown as indicator pills.
|
||||||
class FlagDef {
|
class FlagDef {
|
||||||
final String id;
|
final String id;
|
||||||
final String displayName;
|
final String displayName;
|
||||||
|
final bool Function(SensorState s) read;
|
||||||
|
|
||||||
const FlagDef({required this.id, required this.displayName});
|
const FlagDef({
|
||||||
|
required this.id,
|
||||||
|
required this.displayName,
|
||||||
|
required this.read,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,8 @@ class SensorDef {
|
|||||||
final String unit;
|
final String unit;
|
||||||
final double min;
|
final double min;
|
||||||
final double max;
|
final double max;
|
||||||
|
/// Decimal places shown for this sensor in its base unit.
|
||||||
|
final int decimals;
|
||||||
|
|
||||||
const SensorDef({
|
const SensorDef({
|
||||||
required this.id,
|
required this.id,
|
||||||
@ -12,5 +14,6 @@ class SensorDef {
|
|||||||
required this.unit,
|
required this.unit,
|
||||||
required this.min,
|
required this.min,
|
||||||
required this.max,
|
required this.max,
|
||||||
|
this.decimals = 1,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,22 +1,27 @@
|
|||||||
|
import '../protocol/sensor_state.dart';
|
||||||
import 'sensor_def.dart';
|
import 'sensor_def.dart';
|
||||||
import 'flag_def.dart';
|
import 'flag_def.dart';
|
||||||
|
|
||||||
/// Master list of all analog sensor definitions.
|
/// Master list of all analog sensor definitions. Units, ranges and decimals
|
||||||
|
/// are the base (metric) ones; see units.dart for the displayed unit.
|
||||||
const List<SensorDef> sensorDefs = [
|
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(
|
SensorDef(
|
||||||
id: 'map',
|
id: 'rpm', displayName: 'RPM', unit: 'rpm', min: 0, max: 9000, decimals: 0),
|
||||||
displayName: 'Manifold pressure',
|
SensorDef(
|
||||||
unit: 'kPa',
|
id: 'vss', displayName: 'Speed', unit: 'km/h', min: 0, max: 280, decimals: 0),
|
||||||
|
SensorDef(
|
||||||
|
id: 'map', displayName: 'Manifold pressure', unit: 'kPa', min: 0, max: 300),
|
||||||
|
SensorDef(
|
||||||
|
id: 'tps', displayName: 'Throttle position', unit: '%', min: 0, max: 100),
|
||||||
|
SensorDef(
|
||||||
|
id: 'inj',
|
||||||
|
displayName: 'Injector duration',
|
||||||
|
unit: 'ms',
|
||||||
min: 0,
|
min: 0,
|
||||||
max: 300),
|
max: 20,
|
||||||
|
decimals: 2),
|
||||||
SensorDef(
|
SensorDef(
|
||||||
id: 'tps', displayName: 'Throttle pedal', unit: '%', min: 0, max: 100),
|
id: 'ign', displayName: 'Timing advance', unit: '°', min: -20, max: 60),
|
||||||
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(
|
SensorDef(
|
||||||
id: 'ect', displayName: 'Coolant temp', unit: '°C', min: -40, max: 150),
|
id: 'ect', displayName: 'Coolant temp', unit: '°C', min: -40, max: 150),
|
||||||
SensorDef(
|
SensorDef(
|
||||||
@ -27,41 +32,105 @@ const List<SensorDef> sensorDefs = [
|
|||||||
max: 150),
|
max: 150),
|
||||||
SensorDef(
|
SensorDef(
|
||||||
id: 'bat', displayName: 'Battery voltage', unit: 'V', min: 0, max: 20),
|
id: 'bat', displayName: 'Battery voltage', unit: 'V', min: 0, max: 20),
|
||||||
SensorDef(id: 'o2', displayName: 'O2 sensor', unit: 'V', min: 0, max: 1.5),
|
|
||||||
SensorDef(id: 'gear', displayName: 'Gear', unit: '—', min: 1, max: 6),
|
|
||||||
SensorDef(id: 'eth', displayName: 'Ethanol', unit: '%', min: 0, max: 100),
|
|
||||||
SensorDef(
|
SensorDef(
|
||||||
id: 'pa', displayName: 'Baro pressure', unit: 'kPa', min: 0, max: 120),
|
id: 'o2', displayName: 'O2 sensor', unit: 'V', min: 0, max: 5, decimals: 2),
|
||||||
SensorDef(id: 'afr', displayName: 'AFR', unit: 'λ', min: 0.5, max: 2.0),
|
// Derived: injector duration × RPM (USB tool: 16.67 ms @ 3210 rpm = 44 %).
|
||||||
|
SensorDef(
|
||||||
|
id: 'duty', displayName: 'Injector duty', unit: '%', min: 0, max: 100, decimals: 0),
|
||||||
|
// AFR/AIN scales not yet bench-verified: shown as raw values.
|
||||||
|
SensorDef(
|
||||||
|
id: 'gear', displayName: 'Gear', unit: '', min: 0, max: 6, decimals: 0),
|
||||||
|
SensorDef(
|
||||||
|
id: 'eth', displayName: 'Ethanol', unit: '%', min: 0, max: 100, decimals: 0),
|
||||||
|
SensorDef(
|
||||||
|
id: 'pa', displayName: 'Barometric pressure', unit: 'kPa', min: 0, max: 120),
|
||||||
|
SensorDef(
|
||||||
|
id: 'afr', displayName: 'AFR', unit: 'raw', min: 0, max: 255, decimals: 0),
|
||||||
SensorDef(
|
SensorDef(
|
||||||
id: 'strim', displayName: 'Short trim', unit: '%', min: -30, max: 30),
|
id: 'strim', displayName: 'Short trim', unit: '%', min: -30, max: 30),
|
||||||
SensorDef(
|
SensorDef(
|
||||||
id: 'ltrim', displayName: 'Long trim', unit: '%', min: -30, max: 30),
|
id: 'ltrim', displayName: 'Long trim', unit: '%', min: -30, max: 30),
|
||||||
SensorDef(
|
SensorDef(
|
||||||
id: 'ain0', displayName: 'Analog input 0', unit: 'V', min: 0, max: 5),
|
id: 'ain0', displayName: 'Analog input 0', unit: 'raw', min: 0, max: 1023, decimals: 0),
|
||||||
SensorDef(
|
SensorDef(
|
||||||
id: 'ain1', displayName: 'Analog input 1', unit: 'V', min: 0, max: 5),
|
id: 'ain1', displayName: 'Analog input 1', unit: 'raw', min: 0, max: 1023, decimals: 0),
|
||||||
SensorDef(
|
SensorDef(
|
||||||
id: 'ain2', displayName: 'Analog input 2', unit: 'V', min: 0, max: 5),
|
id: 'ain2', displayName: 'Analog input 2', unit: 'raw', min: 0, max: 1023, decimals: 0),
|
||||||
SensorDef(
|
SensorDef(
|
||||||
id: 'ain3', displayName: 'Analog input 3', unit: 'V', min: 0, max: 5),
|
id: 'ain3', displayName: 'Analog input 3', unit: 'raw', min: 0, max: 1023, decimals: 0),
|
||||||
SensorDef(
|
SensorDef(
|
||||||
id: 'ain4', displayName: 'Analog input 4', unit: 'V', min: 0, max: 5),
|
id: 'ain4', displayName: 'Analog input 4', unit: 'raw', min: 0, max: 1023, decimals: 0),
|
||||||
SensorDef(
|
SensorDef(
|
||||||
id: 'ain5', displayName: 'Analog input 5', unit: 'V', min: 0, max: 5),
|
id: 'ain5', displayName: 'Analog input 5', unit: 'raw', min: 0, max: 1023, decimals: 0),
|
||||||
SensorDef(
|
SensorDef(
|
||||||
id: 'ain6', displayName: 'Analog input 6', unit: 'V', min: 0, max: 5),
|
id: 'ain6', displayName: 'Analog input 6', unit: 'raw', min: 0, max: 1023, decimals: 0),
|
||||||
SensorDef(
|
SensorDef(
|
||||||
id: 'ain7', displayName: 'Analog input 7', unit: 'V', min: 0, max: 5),
|
id: 'ain7', displayName: 'Analog input 7', unit: 'raw', min: 0, max: 1023, decimals: 0),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Master list of boolean flag sensors.
|
SensorDef sensorDef(String id) =>
|
||||||
const List<FlagDef> flagDefs = [
|
sensorDefs.firstWhere((d) => d.id == id, orElse: () => sensorDefs.first);
|
||||||
FlagDef(id: 'mil', displayName: 'MIL'),
|
|
||||||
FlagDef(id: 'fuelcut', displayName: 'Fuel Cut'),
|
/// Base-unit value of sensor [id] in [s].
|
||||||
FlagDef(id: 'fanout', displayName: 'FAN Out'),
|
double sensorValue(SensorState s, String id) {
|
||||||
FlagDef(id: 'vtec', displayName: 'VTEC'),
|
switch (id) {
|
||||||
FlagDef(id: 'knock', displayName: 'Knock'),
|
case 'rpm': return s.rpm;
|
||||||
FlagDef(id: 'revlimit', displayName: 'Rev Limit'),
|
case 'vss': return s.vss;
|
||||||
FlagDef(id: 'launch', displayName: 'Launch'),
|
case 'map': return s.map;
|
||||||
|
case 'tps': return s.tps;
|
||||||
|
case 'inj': return s.inj;
|
||||||
|
case 'ign': return s.ign;
|
||||||
|
case 'ect': return s.ect;
|
||||||
|
case 'iat': return s.iat;
|
||||||
|
case 'bat': return s.bat;
|
||||||
|
case 'o2': return s.o2;
|
||||||
|
// One injection per 2 revolutions: ms × rpm / 120000 × 100.
|
||||||
|
case 'duty': return s.inj * s.rpm / 1200;
|
||||||
|
case 'gear': return s.gear.toDouble();
|
||||||
|
case 'eth': return s.eth;
|
||||||
|
case 'pa': return s.pa;
|
||||||
|
case 'afr': return s.afr;
|
||||||
|
case 'strim': return s.strim;
|
||||||
|
case 'ltrim': return s.ltrim;
|
||||||
|
case 'ain0': return s.ain0;
|
||||||
|
case 'ain1': return s.ain1;
|
||||||
|
case 'ain2': return s.ain2;
|
||||||
|
case 'ain3': return s.ain3;
|
||||||
|
case 'ain4': return s.ain4;
|
||||||
|
case 'ain5': return s.ain5;
|
||||||
|
case 'ain6': return s.ain6;
|
||||||
|
case 'ain7': return s.ain7;
|
||||||
|
default: return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Master list of boolean flags (selectable on the dashboard). The first
|
||||||
|
/// eight follow the requirement document's examples; the rest are the other
|
||||||
|
/// status bits the ECU frame carries.
|
||||||
|
final List<FlagDef> flagDefs = [
|
||||||
|
FlagDef(id: 'mil', displayName: 'MIL', read: (s) => s.mil),
|
||||||
|
FlagDef(id: 'fanout', displayName: 'FAN Out', read: (s) => s.fanOut),
|
||||||
|
FlagDef(id: 'vtec', displayName: 'VTEC Out', read: (s) => s.vtec),
|
||||||
|
FlagDef(id: 'knock', displayName: 'Knock', read: (s) => s.knock),
|
||||||
|
FlagDef(id: 'o2error', displayName: 'WBO Error', read: (s) => s.o2Error),
|
||||||
|
FlagDef(id: 'iaterror', displayName: 'IAT Error', read: (s) => s.iatError),
|
||||||
|
FlagDef(id: 'ecterror', displayName: 'CLT Error', read: (s) => s.ectError),
|
||||||
|
FlagDef(id: 'fuelpump', displayName: 'Fuel Pump', read: (s) => s.fuelPump),
|
||||||
|
FlagDef(id: 'revlimit', displayName: 'Rev Limit', read: (s) => s.revLimit),
|
||||||
|
FlagDef(id: 'igncut', displayName: 'Ign Cut', read: (s) => s.ignCut),
|
||||||
|
FlagDef(id: 'boostcut', displayName: 'Boost Cut', read: (s) => s.boostCut),
|
||||||
|
FlagDef(id: 'launch', displayName: 'Launch Cut', read: (s) => s.launch),
|
||||||
|
FlagDef(
|
||||||
|
id: 'launchretard',
|
||||||
|
displayName: 'Launch Retard',
|
||||||
|
read: (s) => s.launchRetard),
|
||||||
|
FlagDef(id: 'shiftcut', displayName: 'Shift Cut', read: (s) => s.shiftCut),
|
||||||
|
FlagDef(
|
||||||
|
id: 'closedloop', displayName: 'Closed Loop', read: (s) => s.closedLoop),
|
||||||
|
FlagDef(id: 'vtp', displayName: 'VTEC Press', read: (s) => s.vtp),
|
||||||
|
FlagDef(id: 'acswitch', displayName: 'AC Switch', read: (s) => s.acSwitch),
|
||||||
|
FlagDef(id: 'brake', displayName: 'Brake', read: (s) => s.brakeSwitch),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
FlagDef flagDef(String id) =>
|
||||||
|
flagDefs.firstWhere((f) => f.id == id, orElse: () => flagDefs.first);
|
||||||
|
|||||||
101
lib/core/models/units.dart
Normal file
101
lib/core/models/units.dart
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
import 'sensor_def.dart';
|
||||||
|
import 'sensor_defs.dart';
|
||||||
|
|
||||||
|
enum SpeedUnit { kmh, mph }
|
||||||
|
|
||||||
|
enum TempUnit { celsius, fahrenheit }
|
||||||
|
|
||||||
|
enum PressureUnit { kpa, bar, psi }
|
||||||
|
|
||||||
|
/// User's display units. Values are always stored and logged in the base
|
||||||
|
/// (metric) units of [sensorDefs]; conversion happens only for display.
|
||||||
|
class UnitPrefs {
|
||||||
|
final SpeedUnit speed;
|
||||||
|
final TempUnit temp;
|
||||||
|
final PressureUnit pressure;
|
||||||
|
|
||||||
|
const UnitPrefs({
|
||||||
|
this.speed = SpeedUnit.kmh,
|
||||||
|
this.temp = TempUnit.celsius,
|
||||||
|
this.pressure = PressureUnit.kpa,
|
||||||
|
});
|
||||||
|
|
||||||
|
UnitPrefs copyWith({
|
||||||
|
SpeedUnit? speed,
|
||||||
|
TempUnit? temp,
|
||||||
|
PressureUnit? pressure,
|
||||||
|
}) =>
|
||||||
|
UnitPrefs(
|
||||||
|
speed: speed ?? this.speed,
|
||||||
|
temp: temp ?? this.temp,
|
||||||
|
pressure: pressure ?? this.pressure,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
other is UnitPrefs &&
|
||||||
|
other.speed == speed &&
|
||||||
|
other.temp == temp &&
|
||||||
|
other.pressure == pressure;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(speed, temp, pressure);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How one sensor is displayed under the current [UnitPrefs]: unit label,
|
||||||
|
/// converted range, and value conversion/formatting.
|
||||||
|
class SensorView {
|
||||||
|
final SensorDef def;
|
||||||
|
final String unit;
|
||||||
|
final double min;
|
||||||
|
final double max;
|
||||||
|
final int decimals;
|
||||||
|
final double Function(double base) _convert;
|
||||||
|
|
||||||
|
const SensorView._(
|
||||||
|
this.def, this.unit, this.min, this.max, this.decimals, this._convert);
|
||||||
|
|
||||||
|
String get name => def.displayName;
|
||||||
|
|
||||||
|
/// Converts a base-unit value to the display unit.
|
||||||
|
double convert(double base) => _convert(base);
|
||||||
|
|
||||||
|
/// Converted and formatted, without unit.
|
||||||
|
String format(double base) => convert(base).toStringAsFixed(decimals);
|
||||||
|
|
||||||
|
/// 0..1 position of [base] within the sensor's range.
|
||||||
|
double fraction(double base) =>
|
||||||
|
((convert(base) - min) / (max - min)).clamp(0.0, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
double _id(double v) => v;
|
||||||
|
|
||||||
|
SensorView sensorView(String id, UnitPrefs u) {
|
||||||
|
final d = sensorDef(id);
|
||||||
|
SensorView view(String unit, double Function(double) f, {int? decimals}) =>
|
||||||
|
SensorView._(d, unit, f(d.min), f(d.max), decimals ?? d.decimals, f);
|
||||||
|
|
||||||
|
switch (id) {
|
||||||
|
case 'vss':
|
||||||
|
return u.speed == SpeedUnit.mph
|
||||||
|
? view('mph', (v) => v * 0.621371)
|
||||||
|
: view(d.unit, _id);
|
||||||
|
case 'ect':
|
||||||
|
case 'iat':
|
||||||
|
return u.temp == TempUnit.fahrenheit
|
||||||
|
? view('°F', (v) => v * 9 / 5 + 32)
|
||||||
|
: view(d.unit, _id);
|
||||||
|
case 'map':
|
||||||
|
case 'pa':
|
||||||
|
switch (u.pressure) {
|
||||||
|
case PressureUnit.bar:
|
||||||
|
return view('bar', (v) => v / 100, decimals: 2);
|
||||||
|
case PressureUnit.psi:
|
||||||
|
return view('psi', (v) => v * 0.145038);
|
||||||
|
case PressureUnit.kpa:
|
||||||
|
return view(d.unit, _id);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return view(d.unit, _id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,235 +1,267 @@
|
|||||||
/// DTC maps for S300 and KPro ECU protocols.
|
/// DTC maps for S300 and KPro ECU protocols, transcribed from the "DTC map"
|
||||||
|
/// sections of hvbt_protocol.pdf.
|
||||||
///
|
///
|
||||||
/// Key format: 'ERRxx_bity' where xx = byte index (00-based), y = bit number (0=LSB).
|
/// Key format: 'ERRxx_bity' where xx = ERR byte index (00-based), y = bit
|
||||||
/// Value: 'Pxxxx — description'
|
/// number (0=LSB). Value: '<code> — description'. Bits the PDF leaves
|
||||||
|
/// undocumented ("???") use 'ERRxx.y' as the code so the raw bit is still
|
||||||
|
/// visible on screen.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
/// S300 DTC map (ERR00–ERR03, 4 bytes × 8 bits = 32 possible codes)
|
/// S300 DTC map (ERR00–ERR03 = frame bytes 37–40). Descriptions include the
|
||||||
|
/// Honda blink code and sensor abbreviation from the PDF.
|
||||||
const Map<String, String> s300DtcMap = {
|
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
|
||||||
'ERR00_bit0': 'P0130 — O2 Sensor (front)',
|
'ERR00_bit0': 'P0130 — Oxygen Sensor #1 (O2A, code 1)',
|
||||||
'ERR00_bit1': 'P0135 — O2 Sensor Heater (front)',
|
'ERR00_bit1': 'P0150 — Oxygen Sensor #2 (O2B, code 2)',
|
||||||
'ERR00_bit2': 'P0505 — Idle Air Control Valve',
|
'ERR00_bit2': 'P0105 — Manifold Absolute Pressure Sensor (MAP, code 3)',
|
||||||
'ERR00_bit3': 'P0108 — MAP Sensor High',
|
'ERR00_bit3': 'P0335 — Crank Position Sensor (CKP, code 4)',
|
||||||
'ERR00_bit4': 'P0107 — MAP Sensor Low',
|
'ERR00_bit4': 'P0105 — Manifold Absolute Pressure Sensor (MAP, code 5)',
|
||||||
'ERR00_bit5': 'P0113 — IAT Sensor High',
|
'ERR00_bit5': 'P0106 — Engine Coolant Sensor (ECT, code 6)',
|
||||||
'ERR00_bit6': 'P0112 — IAT Sensor Low',
|
'ERR00_bit6': 'P0123 — Throttle Position Sensor (TPS, code 7)',
|
||||||
'ERR00_bit7': 'P0118 — ECT Sensor High',
|
'ERR00_bit7': 'P1362 — Top Dead Center Sensor (TDC, code 8)',
|
||||||
|
|
||||||
// ERR01
|
// ERR01
|
||||||
'ERR01_bit0': 'P0117 — ECT Sensor Low',
|
'ERR01_bit0': 'P0340 — Cylinder/Cam Sensor (CYL, code 9)',
|
||||||
'ERR01_bit1': 'P0336 — CKP Sensor',
|
'ERR01_bit1': 'P0113 — Intake Air Temperature Sensor (IAT, code 10)',
|
||||||
'ERR01_bit2': 'P0341 — CMP Sensor',
|
'ERR01_bit2': 'P0217 — Engine Overheating (OH, code 11)',
|
||||||
'ERR01_bit3': 'P1362 — TDC Sensor (No Signal)',
|
'ERR01_bit3': 'P0403 — EGR Lift Valve (EGR, code 12)',
|
||||||
'ERR01_bit4': 'P1363 — TDC Sensor (No Signal 2)',
|
'ERR01_bit4': 'P1106 — Atmospheric Pressure Sensor (BARO, code 13)',
|
||||||
'ERR01_bit5': 'P0335 — CKP Sensor (No Signal)',
|
'ERR01_bit5': 'P0505 — Idle Air Control Valve (IAC, code 14)',
|
||||||
'ERR01_bit6': 'P1361 — TDC Sensor',
|
'ERR01_bit6': 'P0350 — Ignition Control Module (IGM, code 15)',
|
||||||
'ERR01_bit7': 'P0300 — Random Misfire',
|
'ERR01_bit7': 'P0200 — Fuel Injectors (INJ, code 16)',
|
||||||
|
|
||||||
// ERR02
|
// ERR02
|
||||||
'ERR02_bit0': 'P0301 — Misfire Cylinder 1',
|
'ERR02_bit0': 'P0500 — Vehicle Speed Sensor (VSS, code 17)',
|
||||||
'ERR02_bit1': 'P0302 — Misfire Cylinder 2',
|
'ERR02_bit1': 'ERR02.1 — Undocumented code',
|
||||||
'ERR02_bit2': 'P0303 — Misfire Cylinder 3',
|
'ERR02_bit2': 'P0700 — Automatic Transmission Lockup Control Valve (code 19)',
|
||||||
'ERR02_bit3': 'P0304 — Misfire Cylinder 4',
|
'ERR02_bit3': 'P1298 — Electrical Load Detector (ELD, code 20)',
|
||||||
'ERR02_bit4': 'P1259 — VTEC System Malfunction',
|
'ERR02_bit4': 'P2649 — VTEC Solenoid (VTS, code 21)',
|
||||||
'ERR02_bit5': 'P0420 — Catalyst System Low Efficiency',
|
'ERR02_bit5': 'P2647 — VTEC Pressure Valve (VTP, code 22)',
|
||||||
'ERR02_bit6': 'P0401 — EGR Insufficient Flow',
|
'ERR02_bit6': 'P0325 — Knock Sensor (code 23)',
|
||||||
'ERR02_bit7': 'P1456 — EVAP Leak (large) Fuel Tank Side',
|
'ERR02_bit7': 'P1324 — Knock Sensor (code 24)',
|
||||||
|
|
||||||
// ERR03
|
// ERR03
|
||||||
'ERR03_bit0': 'P1457 — EVAP Leak (large) Canister Side',
|
'ERR03_bit0': 'P0750 — Automatic Transmission A Signal (ATA, code 30)',
|
||||||
'ERR03_bit1': 'P0498 — EVAP Canister Vent Shut Valve',
|
'ERR03_bit1': 'P0755 — Automatic Transmission B Signal (ATB, code 31)',
|
||||||
'ERR03_bit2': 'P0497 — EVAP Low Purge Flow',
|
'ERR03_bit2': 'P0135 — Primary Oxygen Sensor Heater (PO2H, code 41)',
|
||||||
'ERR03_bit3': 'P0122 — TPS Sensor Low',
|
'ERR03_bit3': 'P0135 — Oxygen Sensor Heater',
|
||||||
'ERR03_bit4': 'P0123 — TPS Sensor High',
|
'ERR03_bit4': 'P0169 — Fuel Supply System (code 43)',
|
||||||
'ERR03_bit5': 'P0562 — Battery Voltage Low',
|
'ERR03_bit5': 'ERR03.5 — Undocumented code',
|
||||||
'ERR03_bit6': 'P0563 — Battery Voltage High',
|
'ERR03_bit6': 'ERR03.6 — Undocumented code',
|
||||||
'ERR03_bit7': 'P1607 — PCM Internal Circuit',
|
'ERR03_bit7': 'ERR03.7 — Undocumented code',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// KPro DTC map (ERR00–ERR17 = frame bytes 62–79).
|
||||||
|
const Map<String, String> kproDtcMap = {
|
||||||
|
// ERR00
|
||||||
|
'ERR00_bit0': 'ERR00.0 — Unknown error 0',
|
||||||
|
'ERR00_bit1': 'P1607 — Internal Circuit Malfunction (check calibration type)',
|
||||||
|
'ERR00_bit2': 'P0131 — O2 Sensor Voltage Low',
|
||||||
|
'ERR00_bit3': 'P0132 — O2 Sensor Voltage High',
|
||||||
|
'ERR00_bit4': 'P0107 — MAP Sensor Low Voltage',
|
||||||
|
'ERR00_bit5': 'P0108 — MAP Sensor High Voltage',
|
||||||
|
'ERR00_bit6': 'P0335 — CRK Sensor No Signal',
|
||||||
|
'ERR00_bit7': 'P0336 — CRK Sensor Intermittent Interruption',
|
||||||
|
|
||||||
|
// ERR01
|
||||||
|
'ERR01_bit0': 'P1128 — MAP Sensor Signal Lower Than Expected',
|
||||||
|
'ERR01_bit1': 'P1129 — MAP Sensor Signal Higher Than Expected',
|
||||||
|
'ERR01_bit2': 'P0117 — ECT Sensor Low Voltage',
|
||||||
|
'ERR01_bit3': 'P0118 — ECT Sensor High Voltage',
|
||||||
|
'ERR01_bit4': 'P0122 — TPS Low Voltage',
|
||||||
|
'ERR01_bit5': 'P0123 — TPS High Voltage',
|
||||||
|
'ERR01_bit6': 'P1121 — TPS Signal Lower Than Expected',
|
||||||
|
'ERR01_bit7': 'P1122 — TPS Signal Higher Than Expected',
|
||||||
|
|
||||||
|
// ERR02
|
||||||
|
'ERR02_bit0': 'P0176 — Ethanol Sensor Input Error',
|
||||||
|
'ERR02_bit1': 'ERR02.1 — No description available',
|
||||||
|
'ERR02_bit2': 'ERR02.2 — No description available',
|
||||||
|
'ERR02_bit3': 'ERR02.3 — No description available',
|
||||||
|
'ERR02_bit4': 'P1362 — TDC Sensor No Signal',
|
||||||
|
'ERR02_bit5': 'P1361 — TDC Sensor Intermittent Signal Interruption',
|
||||||
|
'ERR02_bit6': 'P0112 — IAT Sensor Low Voltage',
|
||||||
|
'ERR02_bit7': 'P0113 — IAT Sensor High Voltage',
|
||||||
|
|
||||||
|
// ERR03
|
||||||
|
'ERR03_bit0': 'P1213 — IMA Sensor Voltage Low',
|
||||||
|
'ERR03_bit1': 'P1214 — IMA Sensor Voltage High',
|
||||||
|
'ERR03_bit2': 'ERR03.2 — EGR Lift Sensor Voltage Error',
|
||||||
|
'ERR03_bit3': 'ERR03.3 — EGR F/B Error',
|
||||||
|
'ERR03_bit4': 'P1107 — Barometric Pressure Low Voltage',
|
||||||
|
'ERR03_bit5': 'P1108 — Barometric Pressure High Voltage',
|
||||||
|
'ERR03_bit6': 'P1106 — Barometric Pressure Sensor Range/Performance',
|
||||||
|
'ERR03_bit7': 'ERR03.7 — Unknown error 37',
|
||||||
|
|
||||||
// ERR04
|
// ERR04
|
||||||
'ERR04_bit0': 'P0136 — O2 Sensor (rear)',
|
'ERR04_bit0': 'P0505 — Idle Control System Malfunction',
|
||||||
'ERR04_bit1': 'P0141 — O2 Sensor Heater (rear)',
|
'ERR04_bit1': 'P1519 — Idle Air Control Valve Circuit Malfunction',
|
||||||
'ERR04_bit2': 'P1166 — Air Fuel Ratio (A/F) Sensor Heater',
|
'ERR04_bit2': 'P0500 — VSS Malfunction',
|
||||||
'ERR04_bit3': 'P1167 — Air Fuel Ratio (A/F) Sensor',
|
'ERR04_bit3': 'ERR04.3 — VSP',
|
||||||
'ERR04_bit4': 'P2195 — A/F Sensor Signal Stuck Lean',
|
'ERR04_bit4': 'P1297 — ELD Circuit Low Voltage',
|
||||||
'ERR04_bit5': 'P2196 — A/F Sensor Signal Stuck Rich',
|
'ERR04_bit5': 'P1298 — ELD Circuit High Voltage',
|
||||||
'ERR04_bit6': 'P2237 — A/F Sensor Pump Cell Current Input Low',
|
'ERR04_bit6': 'P1253 — VTEC Solenoid Drive Time Abnormality',
|
||||||
'ERR04_bit7': 'P2240 — A/F Sensor Pump Cell Current Input High',
|
'ERR04_bit7': 'ERR04.7 — Unknown error 47',
|
||||||
|
|
||||||
// ERR05
|
// ERR05
|
||||||
'ERR05_bit0': 'P2243 — A/F Sensor Reference Voltage Low',
|
'ERR05_bit0': 'P1259 — VTEC System Malfunction (Oil Pressure Switch)',
|
||||||
'ERR05_bit1': 'P2247 — A/F Sensor Reference Voltage High',
|
'ERR05_bit1': 'ERR05.1 — Unknown error 51',
|
||||||
'ERR05_bit2': 'P2251 — A/F Sensor VS Cell Voltage Low',
|
'ERR05_bit2': 'P0325 — Knock Sensor Circuit Malfunction',
|
||||||
'ERR05_bit3': 'P2254 — A/F Sensor VS Cell Voltage High',
|
'ERR05_bit3': 'P1324 — Knock Sensor Circuit Failure',
|
||||||
'ERR05_bit4': 'P0031 — O2 Sensor Heater Current Low (bank 1, sensor 1)',
|
'ERR05_bit4': 'P0563 — VBU Voltage High',
|
||||||
'ERR05_bit5': 'P0032 — O2 Sensor Heater Current High (bank 1, sensor 1)',
|
'ERR05_bit5': 'ERR05.5 — Unknown error 55',
|
||||||
'ERR05_bit6': 'P0037 — O2 Sensor Heater Current Low (bank 1, sensor 2)',
|
'ERR05_bit6': 'ERR05.6 — Unknown error 56',
|
||||||
'ERR05_bit7': 'P0038 — O2 Sensor Heater Current High (bank 1, sensor 2)',
|
'ERR05_bit7': 'ERR05.7 — Unknown error 57',
|
||||||
|
|
||||||
// ERR06
|
// ERR06
|
||||||
'ERR06_bit0': 'P0068 — MAP/MAF vs Throttle Position Correlation',
|
'ERR06_bit0': 'P1162 — Air Fuel Ratio Sensor Circuit Malfunction',
|
||||||
'ERR06_bit1': 'P0101 — MAF Sensor Range/Performance',
|
'ERR06_bit1': 'ERR06.1 — LAF Sensor AFV1 Short',
|
||||||
'ERR06_bit2': 'P0102 — MAF Sensor Low Input',
|
'ERR06_bit2': 'ERR06.2 — LAF Sensor AFC1 Short',
|
||||||
'ERR06_bit3': 'P0103 — MAF Sensor High Input',
|
'ERR06_bit3': 'ERR06.3 — Unknown error 63',
|
||||||
'ERR06_bit4': 'P0171 — System Lean (bank 1)',
|
'ERR06_bit4':
|
||||||
'ERR06_bit5': 'P0172 — System Rich (bank 1)',
|
'ERR06.4 — Daughter Board Communication Error (battery voltage low when cranking)',
|
||||||
'ERR06_bit6': 'P0174 — System Lean (bank 2)',
|
'ERR06_bit5': 'ERR06.5 — Unknown error 65',
|
||||||
'ERR06_bit7': 'P0175 — System Rich (bank 2)',
|
'ERR06_bit6':
|
||||||
|
'P0600 — Multiplexer Not Present (for engine swaps disable in misc parameters)',
|
||||||
|
'ERR06_bit7': 'ERR06.7 — Unknown error 67',
|
||||||
|
|
||||||
// ERR07
|
// ERR07
|
||||||
'ERR07_bit0': 'P0325 — Knock Sensor',
|
'ERR07_bit0': 'P1505 — PCV Negative Pressure',
|
||||||
'ERR07_bit1': 'P0330 — Knock Sensor 2',
|
'ERR07_bit1': 'ERR07.1 — Unknown error 71',
|
||||||
'ERR07_bit2': 'P0339 — CKP Sensor Intermittent',
|
'ERR07_bit2': 'ERR07.2 — Unknown error 72',
|
||||||
'ERR07_bit3': 'P0344 — CMP Sensor Intermittent',
|
'ERR07_bit3': 'ERR07.3 — Idle Valve',
|
||||||
'ERR07_bit4': 'P0365 — CMP Sensor B Circuit',
|
'ERR07_bit4': 'P0135 — Primary HO2S Circuit Malfunction',
|
||||||
'ERR07_bit5': 'P0390 — CMP Sensor B Circuit (bank 2)',
|
'ERR07_bit5': 'P1166 — Primary HO2S Heater Circuit Malfunction',
|
||||||
'ERR07_bit6': 'P0498 — EVAP Canister Vent Shut Valve Low',
|
'ERR07_bit6': 'P1167 — Primary LAF HO2S Heater System Malfunction',
|
||||||
'ERR07_bit7': 'P0499 — EVAP Canister Vent Shut Valve High',
|
'ERR07_bit7': 'P0134 — Air/Fuel Ratio Sensor No Activity Detected',
|
||||||
|
|
||||||
// ERR08
|
// ERR08
|
||||||
'ERR08_bit0': 'P0600 — Serial Communication Link',
|
'ERR08_bit0': 'P0172 — Fuel System Too Rich',
|
||||||
'ERR08_bit1': 'P0601 — Internal Control Module Memory',
|
'ERR08_bit1': 'P0171 — Fuel System Too Lean',
|
||||||
'ERR08_bit2': 'P0604 — Internal Control Module RAM',
|
'ERR08_bit2': 'P1162 — Air/Fuel Ratio (A/F) Sensor Slow Response',
|
||||||
'ERR08_bit3': 'P0605 — Internal Control Module ROM',
|
'ERR08_bit3': 'ERR08.3 — Unknown error 83',
|
||||||
'ERR08_bit4': 'P0657 — Actuator Supply Voltage A Open',
|
'ERR08_bit4': 'P0010 — VTC Oil Control Solenoid Valve Malfunction',
|
||||||
'ERR08_bit5': 'P0685 — ECM/PCM Power Relay Control Open',
|
'ERR08_bit5': 'P0011 — VTC System Malfunction',
|
||||||
'ERR08_bit6': 'P0691 — Fan 1 Control Circuit Low',
|
'ERR08_bit6': 'P0340 — CMP Sensor No Signal',
|
||||||
'ERR08_bit7': 'P0692 — Fan 1 Control Circuit High',
|
'ERR08_bit7': 'P0344 — CMP Sensor Intermittent Interruption',
|
||||||
|
|
||||||
// ERR09
|
// ERR09
|
||||||
'ERR09_bit0': 'P0693 — Fan 2 Control Circuit Low',
|
'ERR09_bit0': 'P0341 — Variable Valve Timing (VTC) Phase Gap',
|
||||||
'ERR09_bit1': 'P0694 — Fan 2 Control Circuit High',
|
'ERR09_bit1': 'ERR09.1 — Unknown error 91',
|
||||||
'ERR09_bit2': 'P1454 — EVAP Pressure Sensor Range/Performance',
|
'ERR09_bit2': 'ERR09.2 — Undocumented code',
|
||||||
'ERR09_bit3': 'P1455 — EVAP Leak (small)',
|
'ERR09_bit3': 'P1163 — Air Fuel Ratio Sensor Slow Response',
|
||||||
'ERR09_bit4': 'P1508 — IAC Valve Circuit Low',
|
'ERR09_bit4': 'P1164 — Air Fuel Ratio Sensor Range/Performance Problem',
|
||||||
'ERR09_bit5': 'P1509 — IAC Valve Circuit High',
|
'ERR09_bit5': 'ERR09.5 — Undocumented code',
|
||||||
'ERR09_bit6': 'P1519 — Idle Air Control Valve Stuck',
|
'ERR09_bit6': 'P0137 — Secondary O2 Circuit Low Voltage',
|
||||||
'ERR09_bit7': 'P1706 — A/T Range Switch Out Of Range',
|
'ERR09_bit7': 'P0138 — Secondary O2 Circuit High Voltage',
|
||||||
|
|
||||||
// ERR10
|
// ERR10
|
||||||
'ERR10_bit0': 'P2101 — Throttle Actuator Control Motor Range',
|
'ERR10_bit0': 'P0139 — Secondary O2 Circuit Slow Response',
|
||||||
'ERR10_bit1': 'P2118 — Throttle Actuator Control Motor Current Range',
|
'ERR10_bit1': 'ERR10.1 — Unknown error 101',
|
||||||
'ERR10_bit2': 'P2119 — Throttle Actuator Control Throttle Body Range',
|
'ERR10_bit2': 'P0141 — Secondary O2 Circuit Heater Circuit Malfunction',
|
||||||
'ERR10_bit3': 'P2122 — Throttle/Pedal Position Sensor D Low Input',
|
'ERR10_bit3': 'ERR10.3 — Unknown error 103',
|
||||||
'ERR10_bit4': 'P2123 — Throttle/Pedal Position Sensor D High Input',
|
'ERR10_bit4': 'P0420 — Catalyst System Efficiency Below Threshold',
|
||||||
'ERR10_bit5': 'P2127 — Throttle/Pedal Position Sensor E Low Input',
|
'ERR10_bit5': 'ERR10.5 — Catalyst / Secondary O2 Sensor',
|
||||||
'ERR10_bit6': 'P2128 — Throttle/Pedal Position Sensor E High Input',
|
'ERR10_bit6': 'ERR10.6 — Unknown error 106',
|
||||||
'ERR10_bit7': 'P2135 — Throttle/Pedal Position Sensor A/B Voltage Correlation',
|
'ERR10_bit7': 'ERR10.7 — Unknown error 107',
|
||||||
|
|
||||||
// ERR11
|
// ERR11
|
||||||
'ERR11_bit0': 'P2138 — Throttle/Pedal Position Sensor D/E Voltage Correlation',
|
'ERR11_bit0': 'P0301 — Cylinder #1 Misfire',
|
||||||
'ERR11_bit1': 'P2176 — Throttle Actuator Control System Idle Position Not Learned',
|
'ERR11_bit1': 'ERR11.1 — Unknown error 111',
|
||||||
'ERR11_bit2': 'P2227 — Barometric Pressure Sensor A Circuit Range',
|
'ERR11_bit2': 'P0302 — Cylinder #2 Misfire',
|
||||||
'ERR11_bit3': 'P2228 — Barometric Pressure Sensor A Circuit Low',
|
'ERR11_bit3': 'ERR11.3 — Undocumented code',
|
||||||
'ERR11_bit4': 'P2229 — Barometric Pressure Sensor A Circuit High',
|
'ERR11_bit4': 'P0303 — Cylinder #3 Misfire',
|
||||||
'ERR11_bit5': 'P2610 — ECM/PCM Internal Engine Off Timer',
|
'ERR11_bit5': 'ERR11.5 — Unknown error 115',
|
||||||
'ERR11_bit6': 'P2647 — VTEC System High (bank 1)',
|
'ERR11_bit6': 'P0304 — Cylinder #4 Misfire',
|
||||||
'ERR11_bit7': 'P2648 — VTEC System Low (bank 1)',
|
'ERR11_bit7': 'ERR11.7 — Unknown error 117',
|
||||||
|
|
||||||
// ERR12
|
// ERR12
|
||||||
'ERR12_bit0': 'P2649 — VTEC System High (bank 2)',
|
'ERR12_bit0': 'P0300 — Random Misfire',
|
||||||
'ERR12_bit1': 'P2650 — VTEC System Low (bank 2)',
|
'ERR12_bit1': 'ERR12.1 — Unknown error 121',
|
||||||
'ERR12_bit2': 'P3400 — VTEC System Stuck Off (bank 1)',
|
'ERR12_bit2': 'ERR12.2 — EGR Flow',
|
||||||
'ERR12_bit3': 'P3497 — VTEC System Stuck Off (bank 2)',
|
'ERR12_bit3': 'ERR12.3 — Unknown error 123',
|
||||||
'ERR12_bit4': 'U0073 — Control Module Communication Bus Off',
|
'ERR12_bit4': 'P0116 — ECT Sensor Range/Performance Problem',
|
||||||
'ERR12_bit5': 'U0100 — Lost Communication With ECM/PCM A',
|
'ERR12_bit5': 'ERR12.5 — Unknown error 125',
|
||||||
'ERR12_bit6': 'U0155 — Lost Communication With Instrument Panel Cluster',
|
'ERR12_bit6': 'P0128 — Cooling System Malfunction (Thermostat)',
|
||||||
'ERR12_bit7': 'U0164 — Lost Communication With HVAC Control Module',
|
'ERR12_bit7': 'ERR12.7 — VTC Solenoid',
|
||||||
|
|
||||||
// ERR13
|
// ERR13
|
||||||
'ERR13_bit0': 'P0011 — Camshaft Position A — Timing Over-Advanced (bank 1)',
|
'ERR13_bit0': 'P1456 — EVAP Control System Leakage (Fuel Tank)',
|
||||||
'ERR13_bit1': 'P0012 — Camshaft Position A — Timing Over-Retarded (bank 1)',
|
'ERR13_bit1': 'P1457 — EVAP Control System Leakage (Canister System)',
|
||||||
'ERR13_bit2': 'P0021 — Camshaft Position B — Timing Over-Advanced (bank 1)',
|
'ERR13_bit2': 'P0452 — Fuel Tank Pressure Sensor Low Voltage',
|
||||||
'ERR13_bit3': 'P0022 — Camshaft Position B — Timing Over-Retarded (bank 1)',
|
'ERR13_bit3': 'P0453 — Fuel Tank Pressure Sensor High Voltage',
|
||||||
'ERR13_bit4': 'P0340 — CMP Sensor A Circuit (bank 1)',
|
'ERR13_bit4': 'P0451 — Fuel Tank Pressure Sensor Range/Performance',
|
||||||
'ERR13_bit5': 'P0345 — CMP Sensor A Circuit (bank 2)',
|
'ERR13_bit5': 'ERR13.5 — Undocumented code',
|
||||||
'ERR13_bit6': 'P0366 — CMP Sensor B Circuit Range/Performance (bank 1)',
|
'ERR13_bit6': 'P0443 — EVAP Purge Valve Circuit',
|
||||||
'ERR13_bit7': 'P0391 — CMP Sensor B Circuit Range/Performance (bank 2)',
|
'ERR13_bit7': 'ERR13.7 — Unknown error 137',
|
||||||
|
|
||||||
// ERR14
|
// ERR14
|
||||||
'ERR14_bit0': 'P0016 — Crankshaft/Camshaft Position Correlation (bank 1, sensor A)',
|
'ERR14_bit0': 'ERR14.0 — PF2 Low',
|
||||||
'ERR14_bit1': 'P0017 — Crankshaft/Camshaft Position Correlation (bank 1, sensor B)',
|
'ERR14_bit1': 'ERR14.1 — PF2 High',
|
||||||
'ERR14_bit2': 'P0018 — Crankshaft/Camshaft Position Correlation (bank 2, sensor A)',
|
'ERR14_bit2': 'ERR14.2 — PF2 Monitor',
|
||||||
'ERR14_bit3': 'P0019 — Crankshaft/Camshaft Position Correlation (bank 2, sensor B)',
|
'ERR14_bit3': 'ERR14.3 — Undocumented code',
|
||||||
'ERR14_bit4': 'P0008 — Engine Position System (bank 1)',
|
'ERR14_bit4': 'ERR14.4 — TF2 Low',
|
||||||
'ERR14_bit5': 'P0009 — Engine Position System (bank 2)',
|
'ERR14_bit5': 'ERR14.5 — TF2 High',
|
||||||
'ERR14_bit6': 'P0014 — Camshaft Position B — Timing Over-Advanced (bank 1)',
|
'ERR14_bit6': 'ERR14.6 — PF0 Low',
|
||||||
'ERR14_bit7': 'P0024 — Camshaft Position B — Timing Over-Advanced (bank 2)',
|
'ERR14_bit7': 'ERR14.7 — PF0 High',
|
||||||
|
|
||||||
// ERR15
|
// ERR15
|
||||||
'ERR15_bit0': 'P0087 — Fuel Rail/System Pressure Too Low',
|
'ERR15_bit0': 'ERR15.0 — TF0 Low',
|
||||||
'ERR15_bit1': 'P0088 — Fuel Rail/System Pressure Too High',
|
'ERR15_bit1': 'ERR15.1 — TF0 High',
|
||||||
'ERR15_bit2': 'P0089 — Fuel Pressure Regulator Performance',
|
'ERR15_bit2': 'ERR15.2 — 3rd O2 Sensor Voltage Low',
|
||||||
'ERR15_bit3': 'P0090 — Fuel Pressure Regulator Control Circuit',
|
'ERR15_bit3': 'ERR15.3 — 3rd O2 Sensor Voltage High',
|
||||||
'ERR15_bit4': 'P0091 — Fuel Pressure Regulator Control Circuit Low',
|
'ERR15_bit4': 'ERR15.4 — 3rd O2 Sensor Response',
|
||||||
'ERR15_bit5': 'P0092 — Fuel Pressure Regulator Control Circuit High',
|
'ERR15_bit5': 'ERR15.5 — Unknown error 155',
|
||||||
'ERR15_bit6': 'P0201 — Injector Circuit/Open Cylinder 1',
|
'ERR15_bit6': 'ERR15.6 — 3rd O2 Sensor Heater Current Abnormality',
|
||||||
'ERR15_bit7': 'P0202 — Injector Circuit/Open Cylinder 2',
|
'ERR15_bit7': 'ERR15.7 — Unknown error 157',
|
||||||
|
|
||||||
// ERR16
|
// ERR16
|
||||||
'ERR16_bit0': 'P0203 — Injector Circuit/Open Cylinder 3',
|
'ERR16_bit0': 'ERR16.0 — NOx Ability Monitor',
|
||||||
'ERR16_bit1': 'P0204 — Injector Circuit/Open Cylinder 4',
|
'ERR16_bit1': 'ERR16.1 — Unknown error 161',
|
||||||
'ERR16_bit2': 'P0261 — Injector Circuit Low Cylinder 1',
|
'ERR16_bit2':
|
||||||
'ERR16_bit3': 'P0264 — Injector Circuit Low Cylinder 2',
|
'P1077 — Intake Manifold Runner Control System Malfunction (low rpm)',
|
||||||
'ERR16_bit4': 'P0267 — Injector Circuit Low Cylinder 3',
|
'ERR16_bit3':
|
||||||
'ERR16_bit5': 'P0270 — Injector Circuit Low Cylinder 4',
|
'P1078 — Intake Manifold Runner Control System Malfunction (high rpm)',
|
||||||
'ERR16_bit6': 'P0262 — Injector Circuit High Cylinder 1',
|
'ERR16_bit4': 'P0661 — Intake Manifold Runner Control Low Voltage',
|
||||||
'ERR16_bit7': 'P0265 — Injector Circuit High Cylinder 2',
|
'ERR16_bit5': 'P0662 — Intake Manifold Runner Control High Voltage',
|
||||||
|
'ERR16_bit6': 'ERR16.6 — P1 Switch Abnormality',
|
||||||
|
'ERR16_bit7': 'ERR16.7 — Unknown error 167',
|
||||||
|
|
||||||
// ERR17
|
// ERR17
|
||||||
'ERR17_bit0': 'P0268 — Injector Circuit High Cylinder 3',
|
'ERR17_bit0': 'P0219 — Engine Over-Rev',
|
||||||
'ERR17_bit1': 'P0271 — Injector Circuit High Cylinder 4',
|
'ERR17_bit1': 'P0217 — Engine Over-Heating',
|
||||||
'ERR17_bit2': 'P0351 — Ignition Coil A Primary/Secondary Circuit',
|
'ERR17_bit2': 'ERR17.2 — Undocumented code',
|
||||||
'ERR17_bit3': 'P0352 — Ignition Coil B Primary/Secondary Circuit',
|
'ERR17_bit3': 'ERR17.3 — Undocumented code',
|
||||||
'ERR17_bit4': 'P0353 — Ignition Coil C Primary/Secondary Circuit',
|
'ERR17_bit4': 'ERR17.4 — Undocumented code',
|
||||||
'ERR17_bit5': 'P0354 — Ignition Coil D Primary/Secondary Circuit',
|
'ERR17_bit5': 'ERR17.5 — Undocumented code',
|
||||||
'ERR17_bit6': 'P0627 — Fuel Pump Control Circuit Open',
|
'ERR17_bit6': 'ERR17.6 — Undocumented code',
|
||||||
'ERR17_bit7': 'P0628 — Fuel Pump Control Circuit Low',
|
'ERR17_bit7': 'ERR17.7 — Undocumented code',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// True if any of [bits] — (ERR byte index, bit number) pairs — is set.
|
||||||
|
/// Used to derive sensor-error flags (IAT/CLT/O2) from the stored codes.
|
||||||
|
bool anyDtcBit(List<int> errBytes, List<(int, int)> bits) {
|
||||||
|
for (final (byte, bit) in bits) {
|
||||||
|
if (byte < errBytes.length && (errBytes[byte] >> bit) & 1 == 1) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sensor-error flag sources, per the DTC maps above.
|
||||||
|
const s300IatErrorBits = [(1, 1)]; // P0113
|
||||||
|
const s300EctErrorBits = [(0, 5)]; // P0106
|
||||||
|
const s300O2ErrorBits = [(0, 0), (0, 1), (3, 2), (3, 3)]; // O2A/B, heaters
|
||||||
|
const kproIatErrorBits = [(2, 6), (2, 7)]; // P0112/P0113
|
||||||
|
const kproEctErrorBits = [(1, 2), (1, 3), (12, 4)]; // P0117/P0118/P0116
|
||||||
|
const kproO2ErrorBits = [
|
||||||
|
(0, 2), (0, 3), // O2 voltage low/high
|
||||||
|
(6, 0), (6, 1), (6, 2), // A/F (LAF) sensor circuit
|
||||||
|
(7, 4), (7, 5), (7, 6), (7, 7), // HO2S / LAF heater, no activity
|
||||||
|
(9, 3), (9, 4), // A/F slow response, range
|
||||||
|
];
|
||||||
|
|
||||||
/// Returns a list of active DTC descriptions from the given raw error bytes
|
/// Returns a list of active DTC descriptions from the given raw error bytes
|
||||||
/// using the provided map.
|
/// using the provided map.
|
||||||
List<String> decodeDtcs(List<int> errBytes, Map<String, String> dtcMap) {
|
List<String> decodeDtcs(List<int> errBytes, Map<String, String> dtcMap) {
|
||||||
|
|||||||
@ -2,6 +2,8 @@ import 'dart:typed_data';
|
|||||||
import 'sensor_state.dart';
|
import 'sensor_state.dart';
|
||||||
import 'temp_table.dart';
|
import 'temp_table.dart';
|
||||||
import 'neg8.dart';
|
import 'neg8.dart';
|
||||||
|
import 'dtc_map.dart';
|
||||||
|
import 's300_parser.dart' show tpsPercent;
|
||||||
|
|
||||||
/// Parses a 128-byte KPro ECU response frame into a [SensorState].
|
/// Parses a 128-byte KPro ECU response frame into a [SensorState].
|
||||||
/// Throws [ArgumentError] if frame length is wrong or NEG8 checksum fails.
|
/// Throws [ArgumentError] if frame length is wrong or NEG8 checksum fails.
|
||||||
@ -13,29 +15,35 @@ SensorState parseKPro(Uint8List frame) {
|
|||||||
throw ArgumentError('KPro frame NEG8 checksum failed');
|
throw ArgumentError('KPro frame NEG8 checksum failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
// RPM: bytes 2..3 — raw / 4
|
// Values marked KMGR were checked against KPro Manager on the bench
|
||||||
final int rpmRaw = _readUint16BE(frame, 2);
|
// (2026-10-13, 7267 rpm frame); the PDF table's offsets for PA and BAT
|
||||||
|
// turned out to be wrong, see below.
|
||||||
|
|
||||||
|
// RPM: bytes 2..3 little-endian / 4. KMGR: 7267 rpm.
|
||||||
|
final int rpmRaw = _readUint16LE(frame, 2);
|
||||||
final double rpm = rpmRaw / 4.0;
|
final double rpm = rpmRaw / 4.0;
|
||||||
|
|
||||||
// VSS: byte 4 direct km/h
|
// VSS: byte 4 direct km/h
|
||||||
final double vss = frame[4].toDouble();
|
final double vss = frame[4].toDouble();
|
||||||
|
|
||||||
// TPS: byte 5 direct %
|
// TPS: byte 5, (raw - 25) / 2.04. KMGR: raw 163 -> 68 %.
|
||||||
final double tps = frame[5].toDouble();
|
final double tps = tpsPercent(frame[5]);
|
||||||
|
|
||||||
// MAP: bytes 6 (MAP1) and 33..34 (MAP2 L+H)
|
// MAP: bytes 6 (MAP1) and 33..34 (MAP2 L+H)
|
||||||
final int map1Raw = frame[6];
|
final int map1Raw = frame[6];
|
||||||
final int map2Raw = _readUint16BE(frame, 33);
|
final int map2Raw = _readUint16LE(frame, 33); // MAP2L @33, MAP2H @34
|
||||||
final double map = (map2Raw != 0) ? map2Raw / 10.0 : map1Raw.toDouble();
|
final double map = (map2Raw != 0) ? map2Raw / 10.0 : map1Raw.toDouble();
|
||||||
|
|
||||||
// INJ: byte 11 raw
|
// INJ: bytes 11..12 little-endian, µs -> ms. KMGR: 00 00 -> 0.00 ms (in
|
||||||
final double inj = frame[11].toDouble();
|
// fuel cut). Byte order follows the L/H pairs; scale UNVERIFIED.
|
||||||
|
final double inj = _readUint16LE(frame, 11) / 1000.0;
|
||||||
|
|
||||||
// IGN: byte 13 raw degrees
|
// IGN: byte 13, (raw - 128) / 2 degrees. KMGR: raw 138 -> 5°.
|
||||||
final double ign = frame[13].toDouble();
|
final double ign = (frame[13] - 128) / 2.0;
|
||||||
|
|
||||||
// O2: byte 14 raw
|
// O2: byte 14 raw
|
||||||
final double o2 = frame[14].toDouble();
|
// O2: volts, raw × 5/256 as on the S300. UNVERIFIED for KPro.
|
||||||
|
final double o2 = frame[14] * 5.0 / 256.0;
|
||||||
|
|
||||||
// KRtrd: byte 21
|
// KRtrd: byte 21
|
||||||
final int krtrd = frame[21];
|
final int krtrd = frame[21];
|
||||||
@ -52,14 +60,15 @@ SensorState parseKPro(Uint8List frame) {
|
|||||||
// IAT: byte 50
|
// IAT: byte 50
|
||||||
final double iat = (tempXlt[frame[50]] - 40).toDouble();
|
final double iat = (tempXlt[frame[50]] - 40).toDouble();
|
||||||
|
|
||||||
// PA: byte 51
|
// PA: byte 53, kPa. KMGR: raw 95 -> 95.0 kPa. (The PDF puts PA at 51.)
|
||||||
final double pa = frame[51].toDouble();
|
final double pa = frame[53].toDouble();
|
||||||
|
|
||||||
// BAT: byte 52 — raw / 10
|
// BAT: byte 51, raw / 10. KMGR: raw 114 -> 11.40 V. (The PDF puts BAT at
|
||||||
final double bat = frame[52] / 10.0;
|
// 52; that byte read 232 -> "23.2 V".)
|
||||||
|
final double bat = frame[51] / 10.0;
|
||||||
|
|
||||||
// STRIM: byte 32 (SW2 shares offset 32, STRIM is byte 20 per spec offset +20)
|
// STRIM: byte 20, (raw - 128) / 1.28 %. KMGR: raw 128 -> 0 %.
|
||||||
final double strim = frame[20].toDouble();
|
final double strim = (frame[20] - 128) / 1.28;
|
||||||
|
|
||||||
// LTRIM — not explicitly listed in KPro; use 0
|
// LTRIM — not explicitly listed in KPro; use 0
|
||||||
const double ltrim = 0.0;
|
const double ltrim = 0.0;
|
||||||
@ -73,28 +82,32 @@ SensorState parseKPro(Uint8List frame) {
|
|||||||
// ERR bytes: 18 bytes starting at offset 0x3E = 62
|
// ERR bytes: 18 bytes starting at offset 0x3E = 62
|
||||||
final List<int> errBytes = List<int>.generate(18, (i) => frame[62 + i]);
|
final List<int> errBytes = List<int>.generate(18, (i) => frame[62 + i]);
|
||||||
|
|
||||||
// AIN0–AIN7: bytes 0x52..0x61 (offset 82..97, uint16 BE each)
|
// AIN0–AIN7: bytes 0x52..0x61 (offset 82..97, uint16 LE each)
|
||||||
final double ain0 = _readUint16BE(frame, 82).toDouble();
|
final double ain0 = _readUint16LE(frame, 82).toDouble();
|
||||||
final double ain1 = _readUint16BE(frame, 84).toDouble();
|
final double ain1 = _readUint16LE(frame, 84).toDouble();
|
||||||
final double ain2 = _readUint16BE(frame, 86).toDouble();
|
final double ain2 = _readUint16LE(frame, 86).toDouble();
|
||||||
final double ain3 = _readUint16BE(frame, 88).toDouble();
|
final double ain3 = _readUint16LE(frame, 88).toDouble();
|
||||||
final double ain4 = _readUint16BE(frame, 90).toDouble();
|
final double ain4 = _readUint16LE(frame, 90).toDouble();
|
||||||
final double ain5 = _readUint16BE(frame, 92).toDouble();
|
final double ain5 = _readUint16LE(frame, 92).toDouble();
|
||||||
final double ain6 = _readUint16BE(frame, 94).toDouble();
|
final double ain6 = _readUint16LE(frame, 94).toDouble();
|
||||||
final double ain7 = _readUint16BE(frame, 96).toDouble();
|
final double ain7 = _readUint16LE(frame, 96).toDouble();
|
||||||
|
|
||||||
// Gear — not in KPro frame, default 0
|
// Gear — not in KPro frame, default 0
|
||||||
const int gear = 0;
|
const int gear = 0;
|
||||||
|
|
||||||
// Flags
|
// Flags. The PDF lists the bits LOWEST first. KMGR: SW1 = 0x50 with SCS
|
||||||
// SW2 bit2 = MIL
|
// and FLR on -> SCS bit4, FLR bit6.
|
||||||
final bool mil = (sw2 & 0x04) != 0;
|
// SW1: RVSLCK(0) BKSW(1) ACSW(2) ACCL(3) SCS(4) EPS(5) FLR(6) FANC(7)
|
||||||
// SW1 bit6 = FLR (fuel cut relay)
|
final bool brakeSwitch = (sw1 & 0x02) != 0;
|
||||||
final bool fuelCut = (sw1 & 0x40) != 0;
|
final bool acSwitch = (sw1 & 0x04) != 0;
|
||||||
// SW1 bit0 = FANC
|
final bool fuelPump = (sw1 & 0x40) != 0; // FLR = fuel pump relay
|
||||||
final bool fanOut = (sw1 & 0x01) != 0;
|
final bool fanOut = (sw1 & 0x80) != 0; // FANC
|
||||||
// SW2 bit1..0 = VTS (VTEC)
|
// SW2: VTP(0) VTS(1) … MIL(3). KMGR: SW2 = 0x01/0x09 with VTP on, VTS
|
||||||
final bool vtec = (sw2 & 0x03) != 0;
|
// off, MIL on -> MIL is bit3 (the PDF list has a gap). MIL UNVERIFIED
|
||||||
|
// beyond this one capture.
|
||||||
|
final bool vtp = (sw2 & 0x01) != 0;
|
||||||
|
final bool vtec = (sw2 & 0x02) != 0; // VTS = VTEC solenoid
|
||||||
|
final bool mil = (sw2 & 0x08) != 0;
|
||||||
// Knock = KRtrd > 0
|
// Knock = KRtrd > 0
|
||||||
final bool knock = krtrd > 0;
|
final bool knock = krtrd > 0;
|
||||||
// Rev limit — not directly mapped in KPro SW; false
|
// Rev limit — not directly mapped in KPro SW; false
|
||||||
@ -128,17 +141,23 @@ SensorState parseKPro(Uint8List frame) {
|
|||||||
ain6: ain6,
|
ain6: ain6,
|
||||||
ain7: ain7,
|
ain7: ain7,
|
||||||
mil: mil,
|
mil: mil,
|
||||||
fuelCut: fuelCut,
|
fuelPump: fuelPump,
|
||||||
fanOut: fanOut,
|
fanOut: fanOut,
|
||||||
vtec: vtec,
|
vtec: vtec,
|
||||||
knock: knock,
|
knock: knock,
|
||||||
revLimit: revLimit,
|
revLimit: revLimit,
|
||||||
launch: launch,
|
launch: launch,
|
||||||
|
brakeSwitch: brakeSwitch,
|
||||||
|
acSwitch: acSwitch,
|
||||||
|
vtp: vtp,
|
||||||
|
iatError: anyDtcBit(errBytes, kproIatErrorBits),
|
||||||
|
ectError: anyDtcBit(errBytes, kproEctErrorBits),
|
||||||
|
o2Error: anyDtcBit(errBytes, kproO2ErrorBits),
|
||||||
errBytes: errBytes,
|
errBytes: errBytes,
|
||||||
timestamp: DateTime.now(),
|
timestamp: DateTime.now(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
int _readUint16BE(Uint8List frame, int offset) {
|
int _readUint16LE(Uint8List frame, int offset) {
|
||||||
return (frame[offset] << 8) | frame[offset + 1];
|
return frame[offset] | (frame[offset + 1] << 8);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,58 @@ import 'dart:typed_data';
|
|||||||
import 'sensor_state.dart';
|
import 'sensor_state.dart';
|
||||||
import 'temp_table.dart';
|
import 'temp_table.dart';
|
||||||
import 'neg8.dart';
|
import 'neg8.dart';
|
||||||
|
import 'dtc_map.dart';
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// S300 datalog frame (128 bytes, response to request 1B 00 E5).
|
||||||
|
//
|
||||||
|
// BYTE OFFSETS: hvbt_protocol.pdf labels its columns inconsistently
|
||||||
|
// ("+26 +27 +28 +29 +2A … +2F +30 +31"). The labels are NOT hex offsets.
|
||||||
|
// The real offsets are simply the column count, 0..127. This is proven by
|
||||||
|
// the fields already confirmed against live USB captures — Gear=27, ECT=33,
|
||||||
|
// IAT=34, BAT=36 — which all match plain column counting, and by the adapter's
|
||||||
|
// default frame (below) reproducing the PDF example row byte-for-byte.
|
||||||
|
//
|
||||||
|
// 0 ID1(1B) 1 ID2(00) 2 DLrate 3-4 RPM 5-6 VSS 7-8 MAP 9 TPS 10-11 INJ
|
||||||
|
// 12 IGN 13 KRtrd 14-15 Klvl 16 O2 17 SW1 18 SW2 19 SW3 20 SW4 21-26 ?
|
||||||
|
// 27 Gear 28 ? 29 Strim 30 ? 31 Ltrim 32 PA 33 ECT 34 IAT 35 ? 36 BAT
|
||||||
|
// 37 ERR00 38 ERR01 39 ERR02 40 ERR03 41 Eth 42-44 ? 45 IATc 46 ECTc 47-48 ?
|
||||||
|
// 49 SW5 50 SW6 51-54 ? 55 AF 56 ? 57 TCrtrd 58 TClim 59 ? 60-81 reserved
|
||||||
|
// 82-97 AIN0..AIN7 (16-bit) 98-126 reserved 127 NEG8
|
||||||
|
//
|
||||||
|
// BYTE ORDER: multi-byte values are LITTLE-endian on the wire (confirmed live
|
||||||
|
// for RPM/VSS/MAP; the PDF's "Big Endian" note only applies to the request).
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// True when the frame is the adapter's built-in DEFAULT frame — the exact
|
||||||
|
/// example values printed in hvbt_protocol.pdf (DLrate 0x14, VSS 0xFFFF,
|
||||||
|
/// MAP 0x03F2, TPS 0x18, IGN 0x10, O2 0xC4 …; bytes 32-36 = 93 A0 A0 DC 7D).
|
||||||
|
/// The adapter sends it when it has no fresh data from the ECU (typically
|
||||||
|
/// because it was asked again before it could finish reading the ECU).
|
||||||
|
/// Such a frame carries NO real readings and must not be displayed or logged.
|
||||||
|
///
|
||||||
|
/// Both spans must match: bytes 0-16 alone could, in principle, coincide with
|
||||||
|
/// a real key-on/engine-off reading (RPM 0, MAP 101.0, closed throttle), and a
|
||||||
|
/// false positive here hides real data and triggers auto-reconnects.
|
||||||
|
bool isS300DefaultFrame(Uint8List frame) {
|
||||||
|
if (frame.length != 128) return false;
|
||||||
|
const head = <int>[
|
||||||
|
0x1B, 0x00, 0x14, 0x00, 0x00, 0xFF, 0xFF, 0xF2, 0x03, //
|
||||||
|
0x18, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, 0xC4,
|
||||||
|
];
|
||||||
|
for (var i = 0; i < head.length; i++) {
|
||||||
|
if (frame[i] != head[i]) return false;
|
||||||
|
}
|
||||||
|
// PA, ECT, IAT, ?, BAT at 32-36.
|
||||||
|
const tail = <int>[0x93, 0xA0, 0xA0, 0xDC, 0x7D];
|
||||||
|
for (var i = 0; i < tail.length; i++) {
|
||||||
|
if (frame[32 + i] != tail[i]) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Former name, kept so older call sites/tests keep compiling.
|
||||||
|
bool isS300TemplateFrame(Uint8List frame) => isS300DefaultFrame(frame);
|
||||||
|
|
||||||
/// Parses a 128-byte S300 ECU response frame into a [SensorState].
|
/// Parses a 128-byte S300 ECU response frame into a [SensorState].
|
||||||
/// Throws [ArgumentError] if frame length is wrong, or if [validateChecksum]
|
/// Throws [ArgumentError] if frame length is wrong, or if [validateChecksum]
|
||||||
@ -14,110 +66,94 @@ SensorState parseS300(Uint8List frame, {bool validateChecksum = true}) {
|
|||||||
throw ArgumentError('S300 frame NEG8 checksum failed');
|
throw ArgumentError('S300 frame NEG8 checksum failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
// RPM: bytes +3..4, little-endian, use directly. Confirmed against a live
|
// ── Confirmed against live captures ────────────────────────────────────
|
||||||
// capture: LE @ offset 3 matched a reference tool's RPM exactly, while
|
final double rpm = _u16le(frame, 3).toDouble();
|
||||||
// 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 = _u16le(frame, 5);
|
||||||
final int vssRaw = _readUint16LE(frame, 5);
|
|
||||||
final double vss =
|
final double vss =
|
||||||
(vssRaw < 893 || vssRaw == 0xFFFF) ? 0.0 : 228480.0 / vssRaw;
|
(vssRaw < 893 || vssRaw == 0xFFFF) ? 0.0 : 228480.0 / vssRaw;
|
||||||
|
|
||||||
// MAP: bytes +7..8, little-endian (confirmed against a live capture — the
|
final double map = _u16le(frame, 7) / 10.0;
|
||||||
// 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
|
final double tps = tpsPercent(frame[9]);
|
||||||
// (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];
|
final int gear = frame[27];
|
||||||
|
|
||||||
// Strim: +29 hex = 41 decimal
|
// Table value minus 40 (live-confirmed; also the default frame's 0xA0
|
||||||
final double strim = frame[0x29].toDouble();
|
// decodes to a sensible 20 °C this way).
|
||||||
|
final double ect = (tempXlt[frame[33]] - 40).toDouble();
|
||||||
|
final double iat = (tempXlt[frame[34]] - 40).toDouble();
|
||||||
|
|
||||||
// Ltrim: +2B hex = 43 decimal
|
final double bat = frame[36] * 26.0 / 270.0;
|
||||||
final double ltrim = frame[0x2B].toDouble();
|
|
||||||
|
|
||||||
// PA: +2C hex = 44 decimal
|
// ── Offsets corrected 2026-10-12 (column counting, see header) ─────────
|
||||||
final double pa = frame[0x2C].toDouble();
|
// Formulas marked UNVERIFIED still need one bench check against the USB
|
||||||
|
// tool; they are sanity-checked against the default frame only.
|
||||||
|
|
||||||
// ECT: +2D hex = 45 decimal. Table is tempXlt[raw] - 40, not +40: the
|
// INJ: 16-bit LE (was read big-endian). UNVERIFIED scale: assumes µs.
|
||||||
// spec's own sensor range (section 3: ECT/IAT -40..150 degC) is only
|
final double inj = _u16le(frame, 10) / 1000.0;
|
||||||
// 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
|
// IGN: formula from the PDF. UNVERIFIED (one live check disagreed).
|
||||||
final double iat = (tempXlt[frame[0x2E]] - 40).toDouble();
|
final double ign = (frame[12] + 120) / 2.0;
|
||||||
|
|
||||||
// BAT: +30 hex = 48 decimal
|
// O2: volts, raw × 5/256 (USB tool: raw 195 → 3.81 V).
|
||||||
final double bat = frame[0x30] * 26.0 / 270.0;
|
final double o2 = frame[16] * 5.0 / 256.0;
|
||||||
|
|
||||||
// ERR bytes: +31..+34 hex = 49..52 decimal
|
final int sw1 = frame[17];
|
||||||
final List<int> errBytes = [
|
final int sw2 = frame[18];
|
||||||
frame[0x31],
|
final int sw3 = frame[19];
|
||||||
frame[0x32],
|
final int sw5 = frame[49]; // was read from byte 67 (reserved area)
|
||||||
frame[0x33],
|
|
||||||
frame[0x34],
|
|
||||||
];
|
|
||||||
|
|
||||||
// Eth: +38 hex = 56 decimal
|
// Fuel trims: 0x80 = 0 %. Default frame has 0x80 in both → 0 %.
|
||||||
final double eth = frame[0x38].toDouble();
|
final double strim = (frame[29] - 128) * 100.0 / 128.0; // was byte 41
|
||||||
|
final double ltrim = (frame[31] - 128) * 100.0 / 128.0; // was byte 43
|
||||||
|
|
||||||
// AFR: spec overlaps +34 with ERR03; reuse that byte per the spec's own table.
|
// Baro: byte 32 (was 44). Honda OBD1 baro ADC formula; default frame's
|
||||||
final double afr = frame[0x34].toDouble();
|
// 0x93 → 100.3 kPa (sea level), which supports it. UNVERIFIED on bench.
|
||||||
|
final double pa = frame[32] * 0.716 - 5.0;
|
||||||
|
|
||||||
// AIN0–AIN7: decimal offsets 82..97 (uint16 BE each)
|
// DTC bytes ERR00..ERR03 = 37..40 (were read from 49..52 = SW5/SW6/…,
|
||||||
final double ain0 = _readUint16BE(frame, 82).toDouble();
|
// which produced phantom codes such as P0505/P0350 from SW6 = 0x60).
|
||||||
final double ain1 = _readUint16BE(frame, 84).toDouble();
|
final List<int> errBytes = [frame[37], frame[38], frame[39], frame[40]];
|
||||||
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
|
// Ethanol %: byte 41 (was 56).
|
||||||
// SW1 bit3 = REVL
|
final double eth = frame[41].toDouble();
|
||||||
|
|
||||||
|
// AF: byte 55 (was 52 = unknown byte). UNVERIFIED scale — shown as raw.
|
||||||
|
final double afr = frame[55].toDouble();
|
||||||
|
|
||||||
|
// AIN0–AIN7: 82..97, 16-bit LE (was big-endian). Raw counts.
|
||||||
|
final double ain0 = _u16le(frame, 82).toDouble();
|
||||||
|
final double ain1 = _u16le(frame, 84).toDouble();
|
||||||
|
final double ain2 = _u16le(frame, 86).toDouble();
|
||||||
|
final double ain3 = _u16le(frame, 88).toDouble();
|
||||||
|
final double ain4 = _u16le(frame, 90).toDouble();
|
||||||
|
final double ain5 = _u16le(frame, 92).toDouble();
|
||||||
|
final double ain6 = _u16le(frame, 94).toDouble();
|
||||||
|
final double ain7 = _u16le(frame, 96).toDouble();
|
||||||
|
|
||||||
|
// ── Flags (bit maps from the PDF) ──────────────────────────────────────
|
||||||
|
// SW1: ?(7) DistErr(6) SCS(5) PSP(4) REVL(3) BKSW(2) ACSW(1) VTP(0)
|
||||||
final bool revLimit = (sw1 & 0x08) != 0;
|
final bool revLimit = (sw1 & 0x08) != 0;
|
||||||
// SW2 bit5 = MIL
|
// SW2: ALTC(7) IAB(6) MIL(5) PCS(4) ACCL(3) Fuel(2) VTS(1..0)
|
||||||
final bool mil = (sw2 & 0x20) != 0;
|
final bool mil = (sw2 & 0x20) != 0;
|
||||||
// SW2 bit2 = Fuel (cut)
|
// Fuel pump relay, active-LOW (live-confirmed).
|
||||||
final bool fuelCut = (sw2 & 0x04) != 0;
|
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;
|
final bool vtec = (sw2 & 0x03) != 0;
|
||||||
// Knock = KRtrd > 0 (byte 13)
|
// SW5: … CL(2) … FANC(0). Fan output is FANC only — SW2 bit7 is ALTC
|
||||||
final bool knock = frame[13] > 0;
|
// (alternator control), not the fan.
|
||||||
// SW3 bit7 = LnchC
|
final bool fanOut = (sw5 & 0x01) != 0;
|
||||||
|
final bool knock = frame[13] > 0; // KRtrd
|
||||||
|
// SW3: LnchC(7) LnchR(6) IGNC(5) ShftC(4) BstC(3)
|
||||||
final bool launch = (sw3 & 0x80) != 0;
|
final bool launch = (sw3 & 0x80) != 0;
|
||||||
|
final bool launchRetard = (sw3 & 0x40) != 0;
|
||||||
|
final bool ignCut = (sw3 & 0x20) != 0;
|
||||||
|
final bool shiftCut = (sw3 & 0x10) != 0;
|
||||||
|
final bool boostCut = (sw3 & 0x08) != 0;
|
||||||
|
final bool closedLoop = (sw5 & 0x04) != 0; // SW5 CL
|
||||||
|
final bool brakeSwitch = (sw1 & 0x04) != 0; // SW1 BKSW
|
||||||
|
final bool acSwitch = (sw1 & 0x02) != 0; // SW1 ACSW
|
||||||
|
final bool vtp = (sw1 & 0x01) != 0; // SW1 VTP (VTEC pressure)
|
||||||
|
|
||||||
return SensorState(
|
return SensorState(
|
||||||
rpm: rpm,
|
rpm: rpm,
|
||||||
@ -145,21 +181,33 @@ SensorState parseS300(Uint8List frame, {bool validateChecksum = true}) {
|
|||||||
ain6: ain6,
|
ain6: ain6,
|
||||||
ain7: ain7,
|
ain7: ain7,
|
||||||
mil: mil,
|
mil: mil,
|
||||||
fuelCut: fuelCut,
|
fuelPump: fuelPump,
|
||||||
fanOut: fanOut,
|
fanOut: fanOut,
|
||||||
vtec: vtec,
|
vtec: vtec,
|
||||||
knock: knock,
|
knock: knock,
|
||||||
revLimit: revLimit,
|
revLimit: revLimit,
|
||||||
launch: launch,
|
launch: launch,
|
||||||
|
launchRetard: launchRetard,
|
||||||
|
ignCut: ignCut,
|
||||||
|
shiftCut: shiftCut,
|
||||||
|
boostCut: boostCut,
|
||||||
|
closedLoop: closedLoop,
|
||||||
|
brakeSwitch: brakeSwitch,
|
||||||
|
acSwitch: acSwitch,
|
||||||
|
vtp: vtp,
|
||||||
|
iatError: anyDtcBit(errBytes, s300IatErrorBits),
|
||||||
|
ectError: anyDtcBit(errBytes, s300EctErrorBits),
|
||||||
|
o2Error: anyDtcBit(errBytes, s300O2ErrorBits),
|
||||||
errBytes: errBytes,
|
errBytes: errBytes,
|
||||||
timestamp: DateTime.now(),
|
timestamp: DateTime.now(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
int _readUint16BE(Uint8List frame, int offset) {
|
/// Throttle %, both ECUs: (raw - 25) / 2.04, clamped to 0..100 (the PDF's
|
||||||
return (frame[offset] << 8) | frame[offset + 1];
|
/// "TPS < 25 ? 0" threshold). Checked against the USB tools: S300 raw ≈ 88
|
||||||
}
|
/// -> 31 %, KPro raw 163 -> 68 %. (The previous raw × 100/255 matched only
|
||||||
|
/// near 50 %.)
|
||||||
|
double tpsPercent(int raw) => ((raw - 25) / 2.04).clamp(0.0, 100.0);
|
||||||
|
|
||||||
int _readUint16LE(Uint8List frame, int offset) {
|
int _u16le(Uint8List frame, int offset) =>
|
||||||
return frame[offset] | (frame[offset + 1] << 8);
|
frame[offset] | (frame[offset + 1] << 8);
|
||||||
}
|
|
||||||
|
|||||||
@ -27,12 +27,25 @@ class SensorState {
|
|||||||
final double ain7;
|
final double ain7;
|
||||||
// Boolean flags
|
// Boolean flags
|
||||||
final bool mil;
|
final bool mil;
|
||||||
final bool fuelCut;
|
final bool fuelPump;
|
||||||
final bool fanOut;
|
final bool fanOut;
|
||||||
final bool vtec;
|
final bool vtec;
|
||||||
final bool knock;
|
final bool knock;
|
||||||
final bool revLimit;
|
final bool revLimit;
|
||||||
final bool launch;
|
final bool launch;
|
||||||
|
// Extra flags (default false where an ECU does not report them).
|
||||||
|
// iat/ect/o2Error are derived from the stored DTC bits.
|
||||||
|
final bool ignCut;
|
||||||
|
final bool boostCut;
|
||||||
|
final bool shiftCut;
|
||||||
|
final bool launchRetard;
|
||||||
|
final bool closedLoop;
|
||||||
|
final bool acSwitch;
|
||||||
|
final bool brakeSwitch;
|
||||||
|
final bool vtp;
|
||||||
|
final bool iatError;
|
||||||
|
final bool ectError;
|
||||||
|
final bool o2Error;
|
||||||
// Raw DTC bytes (ERR00..ERR17 for KPro, ERR00..ERR03 for S300)
|
// Raw DTC bytes (ERR00..ERR17 for KPro, ERR00..ERR03 for S300)
|
||||||
final List<int> errBytes;
|
final List<int> errBytes;
|
||||||
// Frame timestamp
|
// Frame timestamp
|
||||||
@ -64,12 +77,23 @@ class SensorState {
|
|||||||
required this.ain6,
|
required this.ain6,
|
||||||
required this.ain7,
|
required this.ain7,
|
||||||
required this.mil,
|
required this.mil,
|
||||||
required this.fuelCut,
|
required this.fuelPump,
|
||||||
required this.fanOut,
|
required this.fanOut,
|
||||||
required this.vtec,
|
required this.vtec,
|
||||||
required this.knock,
|
required this.knock,
|
||||||
required this.revLimit,
|
required this.revLimit,
|
||||||
required this.launch,
|
required this.launch,
|
||||||
|
this.ignCut = false,
|
||||||
|
this.boostCut = false,
|
||||||
|
this.shiftCut = false,
|
||||||
|
this.launchRetard = false,
|
||||||
|
this.closedLoop = false,
|
||||||
|
this.acSwitch = false,
|
||||||
|
this.brakeSwitch = false,
|
||||||
|
this.vtp = false,
|
||||||
|
this.iatError = false,
|
||||||
|
this.ectError = false,
|
||||||
|
this.o2Error = false,
|
||||||
required this.errBytes,
|
required this.errBytes,
|
||||||
required this.timestamp,
|
required this.timestamp,
|
||||||
});
|
});
|
||||||
@ -100,7 +124,7 @@ class SensorState {
|
|||||||
ain6: 0,
|
ain6: 0,
|
||||||
ain7: 0,
|
ain7: 0,
|
||||||
mil: false,
|
mil: false,
|
||||||
fuelCut: false,
|
fuelPump: false,
|
||||||
fanOut: false,
|
fanOut: false,
|
||||||
vtec: false,
|
vtec: false,
|
||||||
knock: false,
|
knock: false,
|
||||||
@ -136,7 +160,7 @@ class SensorState {
|
|||||||
double? ain6,
|
double? ain6,
|
||||||
double? ain7,
|
double? ain7,
|
||||||
bool? mil,
|
bool? mil,
|
||||||
bool? fuelCut,
|
bool? fuelPump,
|
||||||
bool? fanOut,
|
bool? fanOut,
|
||||||
bool? vtec,
|
bool? vtec,
|
||||||
bool? knock,
|
bool? knock,
|
||||||
@ -171,12 +195,23 @@ class SensorState {
|
|||||||
ain6: ain6 ?? this.ain6,
|
ain6: ain6 ?? this.ain6,
|
||||||
ain7: ain7 ?? this.ain7,
|
ain7: ain7 ?? this.ain7,
|
||||||
mil: mil ?? this.mil,
|
mil: mil ?? this.mil,
|
||||||
fuelCut: fuelCut ?? this.fuelCut,
|
fuelPump: fuelPump ?? this.fuelPump,
|
||||||
fanOut: fanOut ?? this.fanOut,
|
fanOut: fanOut ?? this.fanOut,
|
||||||
vtec: vtec ?? this.vtec,
|
vtec: vtec ?? this.vtec,
|
||||||
knock: knock ?? this.knock,
|
knock: knock ?? this.knock,
|
||||||
revLimit: revLimit ?? this.revLimit,
|
revLimit: revLimit ?? this.revLimit,
|
||||||
launch: launch ?? this.launch,
|
launch: launch ?? this.launch,
|
||||||
|
ignCut: ignCut,
|
||||||
|
boostCut: boostCut,
|
||||||
|
shiftCut: shiftCut,
|
||||||
|
launchRetard: launchRetard,
|
||||||
|
closedLoop: closedLoop,
|
||||||
|
acSwitch: acSwitch,
|
||||||
|
brakeSwitch: brakeSwitch,
|
||||||
|
vtp: vtp,
|
||||||
|
iatError: iatError,
|
||||||
|
ectError: ectError,
|
||||||
|
o2Error: o2Error,
|
||||||
errBytes: errBytes ?? this.errBytes,
|
errBytes: errBytes ?? this.errBytes,
|
||||||
timestamp: timestamp ?? this.timestamp,
|
timestamp: timestamp ?? this.timestamp,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -5,12 +5,32 @@ import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../bluetooth/bt_poller.dart';
|
import '../bluetooth/bt_poller.dart';
|
||||||
|
import '../bluetooth/rpm_glitch_filter.dart';
|
||||||
import '../bluetooth/bt_service.dart';
|
import '../bluetooth/bt_service.dart';
|
||||||
|
import '../logging/app_log.dart';
|
||||||
|
import '../protocol/neg8.dart';
|
||||||
|
import '../protocol/s300_parser.dart';
|
||||||
import 'settings_provider.dart';
|
import 'settings_provider.dart';
|
||||||
|
|
||||||
// ─── Connection status enum ──────────────────────────────────────────────────
|
// ─── Enums ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
enum BtStatus { disconnected, connecting, connected, error }
|
enum BtStatus { disconnected, connecting, connected, reconnecting, error }
|
||||||
|
|
||||||
|
/// What the adapter is actually giving us, independent of the BT link.
|
||||||
|
enum EcuDataStatus {
|
||||||
|
/// Not connected / nothing received yet.
|
||||||
|
none,
|
||||||
|
|
||||||
|
/// Real ECU readings are arriving.
|
||||||
|
live,
|
||||||
|
|
||||||
|
/// The adapter answers, but only with its built-in default frame
|
||||||
|
/// (hvbt_protocol.pdf example values) — it has no fresh ECU data.
|
||||||
|
adapterDefaults,
|
||||||
|
|
||||||
|
/// The adapter is not answering requests.
|
||||||
|
noResponse,
|
||||||
|
}
|
||||||
|
|
||||||
// ─── State ───────────────────────────────────────────────────────────────────
|
// ─── State ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@ -18,111 +38,483 @@ class BtState {
|
|||||||
final BtStatus status;
|
final BtStatus status;
|
||||||
final String? deviceName;
|
final String? deviceName;
|
||||||
final String? error;
|
final String? error;
|
||||||
final int frameCount;
|
final EcuDataStatus dataStatus;
|
||||||
final int droppedFrames;
|
final int requestsSent;
|
||||||
|
final int frameCount; // valid frames (live + default)
|
||||||
|
final int liveFrames;
|
||||||
|
final int rpmGlitches; // frames dropped for an implausible RPM jump
|
||||||
|
final int defaultFrames;
|
||||||
|
final int droppedFrames; // bad checksum
|
||||||
|
final int timeouts;
|
||||||
|
final int reconnects;
|
||||||
|
final int? lastResponseMs;
|
||||||
final Uint8List? lastFrame;
|
final Uint8List? lastFrame;
|
||||||
|
|
||||||
const BtState({
|
const BtState({
|
||||||
required this.status,
|
required this.status,
|
||||||
this.deviceName,
|
this.deviceName,
|
||||||
this.error,
|
this.error,
|
||||||
|
this.dataStatus = EcuDataStatus.none,
|
||||||
|
this.requestsSent = 0,
|
||||||
this.frameCount = 0,
|
this.frameCount = 0,
|
||||||
|
this.liveFrames = 0,
|
||||||
|
this.rpmGlitches = 0,
|
||||||
|
this.defaultFrames = 0,
|
||||||
this.droppedFrames = 0,
|
this.droppedFrames = 0,
|
||||||
|
this.timeouts = 0,
|
||||||
|
this.reconnects = 0,
|
||||||
|
this.lastResponseMs,
|
||||||
this.lastFrame,
|
this.lastFrame,
|
||||||
});
|
});
|
||||||
|
|
||||||
const BtState.disconnected() : this(status: BtStatus.disconnected);
|
const BtState.disconnected() : this(status: BtStatus.disconnected);
|
||||||
|
|
||||||
BtState connecting(String name) =>
|
BtState copyWith({
|
||||||
BtState(status: BtStatus.connecting, deviceName: name);
|
BtStatus? status,
|
||||||
|
String? deviceName,
|
||||||
BtState connected(String name) =>
|
String? error,
|
||||||
BtState(status: BtStatus.connected, deviceName: name);
|
bool clearError = false,
|
||||||
|
EcuDataStatus? dataStatus,
|
||||||
BtState withError(String msg) => BtState(
|
int? requestsSent,
|
||||||
status: BtStatus.error,
|
int? frameCount,
|
||||||
deviceName: deviceName,
|
int? liveFrames,
|
||||||
error: msg,
|
int? rpmGlitches,
|
||||||
frameCount: frameCount,
|
int? defaultFrames,
|
||||||
droppedFrames: droppedFrames,
|
int? droppedFrames,
|
||||||
lastFrame: lastFrame,
|
int? timeouts,
|
||||||
);
|
int? reconnects,
|
||||||
|
int? lastResponseMs,
|
||||||
BtState withFrameStats(int frames, int dropped, Uint8List frame) => BtState(
|
Uint8List? lastFrame,
|
||||||
status: status,
|
}) =>
|
||||||
deviceName: deviceName,
|
BtState(
|
||||||
frameCount: frames,
|
status: status ?? this.status,
|
||||||
droppedFrames: dropped,
|
deviceName: deviceName ?? this.deviceName,
|
||||||
lastFrame: Uint8List.fromList(frame),
|
error: clearError ? null : (error ?? this.error),
|
||||||
|
dataStatus: dataStatus ?? this.dataStatus,
|
||||||
|
requestsSent: requestsSent ?? this.requestsSent,
|
||||||
|
frameCount: frameCount ?? this.frameCount,
|
||||||
|
liveFrames: liveFrames ?? this.liveFrames,
|
||||||
|
rpmGlitches: rpmGlitches ?? this.rpmGlitches,
|
||||||
|
defaultFrames: defaultFrames ?? this.defaultFrames,
|
||||||
|
droppedFrames: droppedFrames ?? this.droppedFrames,
|
||||||
|
timeouts: timeouts ?? this.timeouts,
|
||||||
|
reconnects: reconnects ?? this.reconnects,
|
||||||
|
lastResponseMs: lastResponseMs ?? this.lastResponseMs,
|
||||||
|
lastFrame: lastFrame ?? this.lastFrame,
|
||||||
);
|
);
|
||||||
|
|
||||||
bool get isConnected => status == BtStatus.connected;
|
bool get isConnected => status == BtStatus.connected;
|
||||||
bool get isConnecting => status == BtStatus.connecting;
|
bool get isConnecting =>
|
||||||
|
status == BtStatus.connecting || status == BtStatus.reconnecting;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Notifier ────────────────────────────────────────────────────────────────
|
// ─── Notifier ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class BtNotifier extends StateNotifier<BtState> {
|
class BtNotifier extends StateNotifier<BtState> {
|
||||||
|
BtNotifier(this._ref) : super(const BtState.disconnected()) {
|
||||||
|
// Apply polling-interval changes from Settings to a running poller.
|
||||||
|
_ref.listen<AppSettings>(settingsProvider, (prev, next) {
|
||||||
|
_poller?.pollingInterval = next.pollingInterval;
|
||||||
|
_poller?.ecuType = next.ecuType;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
final Ref _ref;
|
final Ref _ref;
|
||||||
BtPoller? _poller;
|
BtPoller? _poller;
|
||||||
StreamSubscription<Uint8List>? _pollSub;
|
StreamSubscription<Uint8List>? _pollSub;
|
||||||
|
Timer? _statusTimer;
|
||||||
final StreamController<Uint8List> _frameController =
|
final StreamController<Uint8List> _frameController =
|
||||||
StreamController<Uint8List>.broadcast();
|
StreamController<Uint8List>.broadcast();
|
||||||
|
|
||||||
BtNotifier(this._ref) : super(const BtState.disconnected());
|
BluetoothDevice? _device;
|
||||||
|
bool _userDisconnected = false;
|
||||||
|
bool _reconnecting = false;
|
||||||
|
// Bumped on every user connect/disconnect. A link opened by an older
|
||||||
|
// session (e.g. an auto-reconnect still in flight when the user tapped
|
||||||
|
// Disconnect) is closed instead of being adopted.
|
||||||
|
int _session = 0;
|
||||||
|
DateTime? _lastAnyFrameAt;
|
||||||
|
DateTime? _lastLiveFrameAt;
|
||||||
|
DateTime? _defaultsSince;
|
||||||
|
DateTime? _lastAutoReconnectAt;
|
||||||
|
int _liveFrames = 0;
|
||||||
|
int _defaultFrames = 0;
|
||||||
|
int _reconnects = 0;
|
||||||
|
|
||||||
/// Raw validated 128-byte frame stream — consumed by sensorStateProvider.
|
static const _staleAfter = Duration(milliseconds: 1500);
|
||||||
|
static const _defaultsReconnectAfter = Duration(seconds: 8);
|
||||||
|
static const _minTimeBetweenAutoReconnects = Duration(seconds: 45);
|
||||||
|
|
||||||
|
/// Validated 128-byte frames carrying REAL ECU data (adapter default frames
|
||||||
|
/// are filtered out). Consumed by sensorStateProvider and the datalog.
|
||||||
Stream<Uint8List> get frameStream => _frameController.stream;
|
Stream<Uint8List> get frameStream => _frameController.stream;
|
||||||
|
|
||||||
|
BtPoller? get poller => _poller;
|
||||||
|
|
||||||
Future<void> connect(BluetoothDevice device) async {
|
Future<void> connect(BluetoothDevice device) async {
|
||||||
state = state.connecting(device.name ?? device.address);
|
if (state.isConnecting) return; // ignore double taps
|
||||||
final service = _ref.read(btServiceProvider);
|
final session = ++_session;
|
||||||
final settings = _ref.read(settingsProvider);
|
// The adapter's name says which ECU it is wired to; a stale saved
|
||||||
|
// setting for the other ECU makes every request go unanswered.
|
||||||
|
final inferred = ecuTypeFromDeviceName(device.name);
|
||||||
|
if (inferred != null && inferred != _ref.read(settingsProvider).ecuType) {
|
||||||
|
AppLog.i('ECU protocol set to ${inferred.name} from adapter name '
|
||||||
|
'"${device.name}"');
|
||||||
|
_ref.read(settingsProvider.notifier).setEcuType(inferred);
|
||||||
|
}
|
||||||
|
_device = device;
|
||||||
|
_userDisconnected = false;
|
||||||
|
_resetCounters();
|
||||||
|
state = BtState(
|
||||||
|
status: BtStatus.connecting,
|
||||||
|
deviceName: device.name ?? device.address,
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
await service.connect(device);
|
await _openLink(device);
|
||||||
|
if (session != _session || !mounted) {
|
||||||
_poller = BtPoller(
|
AppLog.i('Connect finished after disconnect — closing link');
|
||||||
service,
|
await _closeLink();
|
||||||
ecuType: settings.ecuType,
|
return;
|
||||||
pollingInterval: settings.pollingInterval,
|
}
|
||||||
);
|
state = state.copyWith(status: BtStatus.connected, clearError: true);
|
||||||
|
} catch (e, st) {
|
||||||
_pollSub = _poller!.frameStream.listen(
|
AppLog.e('Connect failed', e, st);
|
||||||
(frame) {
|
if (session != _session || !mounted) return;
|
||||||
_frameController.add(frame);
|
await _closeLink();
|
||||||
state = state.withFrameStats(
|
state = state.copyWith(status: BtStatus.error, error: _friendly(e));
|
||||||
_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 {
|
Future<void> disconnect() async {
|
||||||
await _pollSub?.cancel();
|
_session++;
|
||||||
_poller?.stop();
|
_userDisconnected = true;
|
||||||
_poller?.dispose();
|
await _closeLink();
|
||||||
_poller = null;
|
|
||||||
await _ref.read(btServiceProvider).disconnect();
|
|
||||||
state = const BtState.disconnected();
|
state = const BtState.disconnected();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Link management ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Future<void> _openLink(BluetoothDevice device) async {
|
||||||
|
final service = _ref.read(btServiceProvider);
|
||||||
|
final settings = _ref.read(settingsProvider);
|
||||||
|
await service.connect(device);
|
||||||
|
|
||||||
|
final poller = BtPoller(
|
||||||
|
service,
|
||||||
|
ecuType: settings.ecuType,
|
||||||
|
pollingInterval: settings.pollingInterval,
|
||||||
|
onLinkStalled: () => _onLinkProblem('adapter stopped answering'),
|
||||||
|
);
|
||||||
|
_poller = poller;
|
||||||
|
_pollSub = poller.frameStream.listen(
|
||||||
|
_onFrame,
|
||||||
|
onError: (Object e) => _onLinkProblem('$e'),
|
||||||
|
);
|
||||||
|
poller.start();
|
||||||
|
|
||||||
|
_statusTimer?.cancel();
|
||||||
|
_statusTimer =
|
||||||
|
Timer.periodic(const Duration(milliseconds: 500), (_) => _tick());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _closeLink() async {
|
||||||
|
_statusTimer?.cancel();
|
||||||
|
_statusTimer = null;
|
||||||
|
await _pollSub?.cancel();
|
||||||
|
_pollSub = null;
|
||||||
|
_poller?.dispose();
|
||||||
|
_poller = null;
|
||||||
|
await _ref.read(btServiceProvider).disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _resetCounters() {
|
||||||
|
_rpmFilter.reset();
|
||||||
|
_rpmFilter.dropped = 0;
|
||||||
|
_glitchesLogged = 0;
|
||||||
|
_lastAnyFrameAt = null;
|
||||||
|
_lastLiveFrameAt = null;
|
||||||
|
_defaultsSince = null;
|
||||||
|
_liveFrames = 0;
|
||||||
|
_defaultFrames = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onFrame(Uint8List frame) {
|
||||||
|
if (!mounted) return;
|
||||||
|
final now = DateTime.now();
|
||||||
|
_lastAnyFrameAt = now;
|
||||||
|
final ecuType = _ref.read(settingsProvider).ecuType;
|
||||||
|
final isDefault = ecuType == EcuType.s300 && isS300DefaultFrame(frame);
|
||||||
|
if (isDefault) {
|
||||||
|
_defaultFrames++;
|
||||||
|
if (_defaultFrames == 1 || _defaultFrames % 50 == 0) {
|
||||||
|
AppLog.w('Adapter returned its default (no-ECU-data) frame '
|
||||||
|
'(count=$_defaultFrames)');
|
||||||
|
}
|
||||||
|
} else if (!_rpmFilter.accept(frame, now)) {
|
||||||
|
// Implausible RPM jump (e.g. crank-signal dropout, P0336 on the KPro
|
||||||
|
// bench): drop the whole frame and keep showing the last good one,
|
||||||
|
// as the USB tool does. Counted for the Debug screen.
|
||||||
|
final n = _rpmFilter.dropped;
|
||||||
|
if (n == 1 || n % 100 == 0) {
|
||||||
|
AppLog.w('RPM glitch frame dropped '
|
||||||
|
'(${RpmGlitchFilter.rpmOf(frame).round()} rpm, count=$n)');
|
||||||
|
}
|
||||||
|
if (_glitchesLogged < 2) {
|
||||||
|
_glitchesLogged++;
|
||||||
|
AppLog.w('Glitch frame: ${[
|
||||||
|
for (final v in frame) v.toRadixString(16).padLeft(2, '0')
|
||||||
|
].join(' ')}');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_liveFrames++;
|
||||||
|
_lastLiveFrameAt = now;
|
||||||
|
_maybeLogFrame(frame, now);
|
||||||
|
_defaultsSince = null;
|
||||||
|
_frameController.add(frame);
|
||||||
|
}
|
||||||
|
_publishStats(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime? _lastFrameLogAt;
|
||||||
|
late final _rpmFilter = RpmGlitchFilter(
|
||||||
|
onConfirmed: (from, to) => AppLog.i(
|
||||||
|
'RPM change ${from.round()} -> ${to.round()} accepted (majority of recent frames)'),
|
||||||
|
);
|
||||||
|
int _glitchesLogged = 0;
|
||||||
|
|
||||||
|
/// Raw hex of one live frame every 5 s, so bench readings from the USB
|
||||||
|
/// tool can be matched to exact bytes (Debug → Copy app log, or logcat).
|
||||||
|
void _maybeLogFrame(Uint8List frame, DateTime now) {
|
||||||
|
final last = _lastFrameLogAt;
|
||||||
|
if (last != null && now.difference(last) < const Duration(seconds: 5)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_lastFrameLogAt = now;
|
||||||
|
final hex = StringBuffer();
|
||||||
|
for (var i = 0; i < 64; i++) {
|
||||||
|
if (i > 0) hex.write(i % 16 == 0 ? ' | ' : ' ');
|
||||||
|
hex.write(frame[i].toRadixString(16).padLeft(2, '0'));
|
||||||
|
}
|
||||||
|
AppLog.i('FRAME[0..63] $hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
void _publishStats([Uint8List? frame]) {
|
||||||
|
final p = _poller;
|
||||||
|
state = state.copyWith(
|
||||||
|
requestsSent: p?.requestsSent,
|
||||||
|
frameCount: p?.validFrames,
|
||||||
|
liveFrames: _liveFrames,
|
||||||
|
rpmGlitches: _rpmFilter.dropped,
|
||||||
|
defaultFrames: _defaultFrames,
|
||||||
|
droppedFrames: p?.droppedFrames,
|
||||||
|
timeouts: p?.timeouts,
|
||||||
|
reconnects: _reconnects,
|
||||||
|
lastResponseMs: p?.lastResponseTime?.inMilliseconds,
|
||||||
|
lastFrame: frame,
|
||||||
|
dataStatus: _computeDataStatus(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
EcuDataStatus _computeDataStatus() {
|
||||||
|
final now = DateTime.now();
|
||||||
|
bool recent(DateTime? t) => t != null && now.difference(t) < _staleAfter;
|
||||||
|
if (recent(_lastLiveFrameAt)) return EcuDataStatus.live;
|
||||||
|
if (recent(_lastAnyFrameAt)) return EcuDataStatus.adapterDefaults;
|
||||||
|
if (_poller?.isRunning ?? false) return EcuDataStatus.noResponse;
|
||||||
|
return EcuDataStatus.none;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _tick() {
|
||||||
|
if (!mounted || _poller == null) return;
|
||||||
|
final status = _computeDataStatus();
|
||||||
|
|
||||||
|
// Adapter answering only with defaults for a long stretch: one reconnect
|
||||||
|
// has been seen to clear this (see docs/HANDOFF.md), but never loop on it.
|
||||||
|
if (status == EcuDataStatus.adapterDefaults) {
|
||||||
|
_defaultsSince ??= DateTime.now();
|
||||||
|
if (DateTime.now().difference(_defaultsSince!) > _defaultsReconnectAfter &&
|
||||||
|
_autoReconnectAllowed()) {
|
||||||
|
_onLinkProblem('only default frames for '
|
||||||
|
'${_defaultsReconnectAfter.inSeconds}s');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_defaultsSince = null;
|
||||||
|
}
|
||||||
|
_publishStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _autoReconnectAllowed() {
|
||||||
|
final last = _lastAutoReconnectAt;
|
||||||
|
return last == null ||
|
||||||
|
DateTime.now().difference(last) > _minTimeBetweenAutoReconnects;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onLinkProblem(String reason) async {
|
||||||
|
if (_reconnecting || _userDisconnected || _device == null || !mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final service = _ref.read(btServiceProvider);
|
||||||
|
// A stall on a link that is still up: only reconnect occasionally; the
|
||||||
|
// poller keeps trying at its timeout pace in between.
|
||||||
|
if (service.isConnected && !_autoReconnectAllowed()) {
|
||||||
|
AppLog.w('Link problem ($reason) — keeping link, recent reconnect');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final session = _session;
|
||||||
|
bool stillWanted() => session == _session && !_userDisconnected && mounted;
|
||||||
|
_reconnecting = true;
|
||||||
|
_lastAutoReconnectAt = DateTime.now();
|
||||||
|
AppLog.w('Link problem: $reason — reconnecting');
|
||||||
|
state = state.copyWith(status: BtStatus.reconnecting);
|
||||||
|
await _closeLink();
|
||||||
|
|
||||||
|
for (var attempt = 1; attempt <= 5; attempt++) {
|
||||||
|
await Future<void>.delayed(Duration(seconds: attempt));
|
||||||
|
if (!stillWanted()) break;
|
||||||
|
try {
|
||||||
|
_resetCounters();
|
||||||
|
await _openLink(_device!);
|
||||||
|
if (!stillWanted()) {
|
||||||
|
// User disconnected while this attempt was in flight.
|
||||||
|
AppLog.i('Reconnect finished after disconnect — closing link');
|
||||||
|
await _closeLink();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
_reconnects++;
|
||||||
|
AppLog.i('Reconnected (attempt $attempt)');
|
||||||
|
_reconnecting = false;
|
||||||
|
state = state.copyWith(status: BtStatus.connected, clearError: true);
|
||||||
|
return;
|
||||||
|
} catch (e) {
|
||||||
|
AppLog.w('Reconnect attempt $attempt failed: $e');
|
||||||
|
// Don't tear down a link that a newer session may own.
|
||||||
|
if (!stillWanted()) break;
|
||||||
|
await _closeLink();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_reconnecting = false;
|
||||||
|
if (stillWanted()) {
|
||||||
|
state = state.copyWith(
|
||||||
|
status: BtStatus.error,
|
||||||
|
error: 'Connection lost. Check the adapter and connect again.',
|
||||||
|
dataStatus: EcuDataStatus.none,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _friendly(Object e) {
|
||||||
|
final s = e.toString();
|
||||||
|
if (s.contains('timed out')) return s.replaceFirst('Exception: ', '');
|
||||||
|
if (s.contains('read failed') || s.contains('socket')) {
|
||||||
|
return 'Could not open the Bluetooth link. Make sure the adapter is '
|
||||||
|
'powered and not connected to another phone, then try again.';
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── One-off commands ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Diagnostic: Hondata official-protocol DL_DeviceInfo probe. Runs between
|
||||||
|
/// polls via the poller's link lock, so it never overlaps a request.
|
||||||
|
Future<Uint8List> probeOfficialProtocol() async {
|
||||||
|
final p = _poller;
|
||||||
|
if (p == null) return Uint8List(0);
|
||||||
|
return p.sendCommandCollect(
|
||||||
|
Uint8List.fromList([0x00, 0x02, 0x04, 0x00]),
|
||||||
|
const Duration(milliseconds: 800),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// DTC reset per hvbt_protocol.pdf: { 0x1B, 0x70, NEG8 }; the adapter should
|
||||||
|
/// echo the same 3 bytes as acknowledgement.
|
||||||
|
///
|
||||||
|
/// The adapter never sends that ack (confirmed on the bench), so success is
|
||||||
|
/// judged from the data: after sending, polling resumes and the ERR bytes
|
||||||
|
/// of the live frames are watched for [_dtcWatch]. If any code that was
|
||||||
|
/// stored before disappears, the ECU cleared. Codes for faults that are
|
||||||
|
/// still present come straight back (often before the first frame), just
|
||||||
|
/// as with the USB tool, so "all zero" is not required. The ack wait is
|
||||||
|
/// kept short because the adapter stops reading the ECU after ~1 s without
|
||||||
|
/// datalog requests (Bluetooth.pdf §2.4).
|
||||||
|
///
|
||||||
|
/// [codesBefore]/[codesCleared] count stored code bits before the command
|
||||||
|
/// and how many of them were seen cleared.
|
||||||
|
Future<
|
||||||
|
({
|
||||||
|
bool acknowledged,
|
||||||
|
int codesBefore,
|
||||||
|
int codesCleared,
|
||||||
|
Uint8List raw,
|
||||||
|
})> clearDtcs() async {
|
||||||
|
final p = _poller;
|
||||||
|
if (p == null) {
|
||||||
|
return (
|
||||||
|
acknowledged: false,
|
||||||
|
codesBefore: 0,
|
||||||
|
codesCleared: 0,
|
||||||
|
raw: Uint8List(0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final ecuType = _ref.read(settingsProvider).ecuType;
|
||||||
|
// ERR00.. bytes: S300 37-40, KPro 62-79 (hvbt_protocol.pdf).
|
||||||
|
List<int> errOf(Uint8List f) =>
|
||||||
|
ecuType == EcuType.s300 ? f.sublist(37, 41) : f.sublist(62, 80);
|
||||||
|
String hex(List<int> b) =>
|
||||||
|
b.map((v) => v.toRadixString(16).padLeft(2, '0')).join(' ');
|
||||||
|
int bits(int v) => v.toRadixString(2).replaceAll('0', '').length;
|
||||||
|
|
||||||
|
final beforeFrame = state.lastFrame;
|
||||||
|
final before = beforeFrame == null ? null : errOf(beforeFrame);
|
||||||
|
final codesBefore = before == null ? 0 : before.fold(0, (n, b) => n + bits(b));
|
||||||
|
AppLog.i('Clear DTC: ERR bytes before = ${before == null ? "?" : hex(before)}');
|
||||||
|
|
||||||
|
// Bits stored before that have been seen clear at least once.
|
||||||
|
final clearedMask = List<int>.filled(before?.length ?? 0, 0);
|
||||||
|
String? lastErr = before == null ? null : hex(before);
|
||||||
|
final watchStart = DateTime.now();
|
||||||
|
final sub = frameStream.listen((f) {
|
||||||
|
final err = errOf(f);
|
||||||
|
final h = hex(err);
|
||||||
|
if (h != lastErr) {
|
||||||
|
final ms = DateTime.now().difference(watchStart).inMilliseconds;
|
||||||
|
AppLog.i('Clear DTC: +$ms ms ERR bytes = $h');
|
||||||
|
lastErr = h;
|
||||||
|
}
|
||||||
|
if (before != null) {
|
||||||
|
for (var i = 0; i < before.length; i++) {
|
||||||
|
clearedMask[i] |= before[i] & ~err[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
final command = Uint8List.fromList([0x1B, 0x70, 0x00]);
|
||||||
|
command[2] = calculateNeg8(Uint8List.fromList([0x1B, 0x70]));
|
||||||
|
final result = await p.sendCommandAwaitReply(
|
||||||
|
command,
|
||||||
|
command,
|
||||||
|
timeout: const Duration(milliseconds: 600),
|
||||||
|
);
|
||||||
|
await Future<void>.delayed(_dtcWatch);
|
||||||
|
await sub.cancel();
|
||||||
|
|
||||||
|
final codesCleared = clearedMask.fold(0, (n, b) => n + bits(b));
|
||||||
|
AppLog.i('Clear DTC: ack=${result.acknowledged} '
|
||||||
|
'rxBytes=${result.raw.length} '
|
||||||
|
'codesCleared=$codesCleared/$codesBefore '
|
||||||
|
'ERR bytes after = ${lastErr ?? "?"}');
|
||||||
|
return (
|
||||||
|
acknowledged: result.acknowledged,
|
||||||
|
codesBefore: codesBefore,
|
||||||
|
codesCleared: codesCleared,
|
||||||
|
raw: result.raw,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static const _dtcWatch = Duration(seconds: 5);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_statusTimer?.cancel();
|
||||||
_pollSub?.cancel();
|
_pollSub?.cancel();
|
||||||
_poller?.dispose();
|
_poller?.dispose();
|
||||||
_frameController.close();
|
_frameController.close();
|
||||||
@ -143,7 +535,8 @@ final btServiceProvider = Provider<BtService>((ref) {
|
|||||||
final btProvider =
|
final btProvider =
|
||||||
StateNotifierProvider<BtNotifier, BtState>((ref) => BtNotifier(ref));
|
StateNotifierProvider<BtNotifier, BtState>((ref) => BtNotifier(ref));
|
||||||
|
|
||||||
/// Paired BT devices list (re-fetchable via ref.refresh).
|
/// Paired BT devices list (re-fetchable via ref.refresh). Only watched after
|
||||||
|
/// Bluetooth permissions are granted.
|
||||||
final pairedDevicesProvider = FutureProvider<List<BluetoothDevice>>((ref) {
|
final pairedDevicesProvider = FutureProvider<List<BluetoothDevice>>((ref) {
|
||||||
return FlutterBluetoothSerial.instance.getBondedDevices();
|
return FlutterBluetoothSerial.instance.getBondedDevices();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -4,7 +4,9 @@ import '../datalog/datalog_db.dart';
|
|||||||
import '../datalog/datalog_player.dart';
|
import '../datalog/datalog_player.dart';
|
||||||
import '../datalog/datalog_recorder.dart';
|
import '../datalog/datalog_recorder.dart';
|
||||||
import '../models/datalog_session.dart';
|
import '../models/datalog_session.dart';
|
||||||
|
import '../bluetooth/bt_poller.dart';
|
||||||
import 'bt_provider.dart';
|
import 'bt_provider.dart';
|
||||||
|
import 'sensor_history_provider.dart';
|
||||||
import 'settings_provider.dart';
|
import 'settings_provider.dart';
|
||||||
|
|
||||||
// ─── Singletons ───────────────────────────────────────────────────────────────
|
// ─── Singletons ───────────────────────────────────────────────────────────────
|
||||||
@ -114,6 +116,8 @@ final sessionListProvider = FutureProvider<List<DatalogSession>>((ref) {
|
|||||||
class PlaybackState {
|
class PlaybackState {
|
||||||
final bool isActive;
|
final bool isActive;
|
||||||
final int? sessionId;
|
final int? sessionId;
|
||||||
|
/// ECU type the session was recorded with; frames are parsed with it.
|
||||||
|
final EcuType ecuType;
|
||||||
final int position;
|
final int position;
|
||||||
final int total;
|
final int total;
|
||||||
final double speed;
|
final double speed;
|
||||||
@ -122,6 +126,7 @@ class PlaybackState {
|
|||||||
const PlaybackState({
|
const PlaybackState({
|
||||||
this.isActive = false,
|
this.isActive = false,
|
||||||
this.sessionId,
|
this.sessionId,
|
||||||
|
this.ecuType = EcuType.s300,
|
||||||
this.position = 0,
|
this.position = 0,
|
||||||
this.total = 0,
|
this.total = 0,
|
||||||
this.speed = 1.0,
|
this.speed = 1.0,
|
||||||
@ -141,6 +146,7 @@ class PlaybackState {
|
|||||||
PlaybackState(
|
PlaybackState(
|
||||||
isActive: isActive ?? this.isActive,
|
isActive: isActive ?? this.isActive,
|
||||||
sessionId: sessionId ?? this.sessionId,
|
sessionId: sessionId ?? this.sessionId,
|
||||||
|
ecuType: ecuType,
|
||||||
position: position ?? this.position,
|
position: position ?? this.position,
|
||||||
total: total ?? this.total,
|
total: total ?? this.total,
|
||||||
speed: speed ?? this.speed,
|
speed: speed ?? this.speed,
|
||||||
@ -153,9 +159,12 @@ class PlaybackNotifier extends StateNotifier<PlaybackState> {
|
|||||||
|
|
||||||
PlaybackNotifier(this._ref) : super(const PlaybackState());
|
PlaybackNotifier(this._ref) : super(const PlaybackState());
|
||||||
|
|
||||||
Future<void> loadSession(int sessionId) async {
|
/// Loads a recorded session; while active, the dashboard, sensor list,
|
||||||
|
/// graph and DTC screen show the recording instead of live data.
|
||||||
|
Future<void> loadSession(int sessionId, String ecuType) async {
|
||||||
final player = _ref.read(datalogPlayerProvider);
|
final player = _ref.read(datalogPlayerProvider);
|
||||||
await player.load(sessionId);
|
await player.load(sessionId);
|
||||||
|
_ref.read(sensorHistoryProvider.notifier).clear();
|
||||||
|
|
||||||
player.onProgress = (pos, total) {
|
player.onProgress = (pos, total) {
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
@ -168,6 +177,7 @@ class PlaybackNotifier extends StateNotifier<PlaybackState> {
|
|||||||
state = PlaybackState(
|
state = PlaybackState(
|
||||||
isActive: true,
|
isActive: true,
|
||||||
sessionId: sessionId,
|
sessionId: sessionId,
|
||||||
|
ecuType: ecuType == EcuType.kpro.name ? EcuType.kpro : EcuType.s300,
|
||||||
total: player.totalFrames,
|
total: player.totalFrames,
|
||||||
speed: player.speed,
|
speed: player.speed,
|
||||||
status: PlaybackStatus.idle,
|
status: PlaybackStatus.idle,
|
||||||
@ -187,6 +197,7 @@ class PlaybackNotifier extends StateNotifier<PlaybackState> {
|
|||||||
Future<void> stop() async {
|
Future<void> stop() async {
|
||||||
await _ref.read(datalogPlayerProvider).stop();
|
await _ref.read(datalogPlayerProvider).stop();
|
||||||
state = const PlaybackState();
|
state = const PlaybackState();
|
||||||
|
_ref.read(sensorHistoryProvider.notifier).clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
void seek(int index) {
|
void seek(int index) {
|
||||||
@ -204,3 +215,11 @@ final playbackProvider =
|
|||||||
StateNotifierProvider<PlaybackNotifier, PlaybackState>(
|
StateNotifierProvider<PlaybackNotifier, PlaybackState>(
|
||||||
(ref) => PlaybackNotifier(ref),
|
(ref) => PlaybackNotifier(ref),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// ECU type the displays should decode with: the recorded session's while a
|
||||||
|
/// datalog is playing back, otherwise the live setting.
|
||||||
|
final effectiveEcuTypeProvider = Provider<EcuType>((ref) {
|
||||||
|
final playback = ref.watch(playbackProvider);
|
||||||
|
if (playback.isActive) return playback.ecuType;
|
||||||
|
return ref.watch(settingsProvider.select((s) => s.ecuType));
|
||||||
|
});
|
||||||
|
|||||||
@ -23,30 +23,9 @@ class SensorHistory {
|
|||||||
if (list.length > _maxPoints) list.removeAt(0);
|
if (list.length > _maxPoints) list.removeAt(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
push('rpm', state.rpm);
|
for (final d in sensorDefs) {
|
||||||
push('vss', state.vss);
|
push(d.id, sensorValue(state, d.id));
|
||||||
push('map', state.map);
|
}
|
||||||
push('tps', state.tps);
|
|
||||||
push('inj', state.inj);
|
|
||||||
push('ign', state.ign);
|
|
||||||
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] ?? []);
|
List<FlSpot> get(String id) => List.unmodifiable(_data[id] ?? []);
|
||||||
@ -62,6 +41,11 @@ class SensorHistory {
|
|||||||
class SensorHistoryNotifier extends StateNotifier<SensorHistory> {
|
class SensorHistoryNotifier extends StateNotifier<SensorHistory> {
|
||||||
SensorHistoryNotifier() : super(SensorHistory());
|
SensorHistoryNotifier() : super(SensorHistory());
|
||||||
|
|
||||||
|
// History is mutated in place; StateNotifier's default identical() check
|
||||||
|
// would swallow `state = state`, so the graph never redrew live.
|
||||||
|
@override
|
||||||
|
bool updateShouldNotify(SensorHistory old, SensorHistory current) => true;
|
||||||
|
|
||||||
void addState(SensorState s) {
|
void addState(SensorState s) {
|
||||||
state.add(s);
|
state.add(s);
|
||||||
// trigger rebuild by reassigning same object
|
// trigger rebuild by reassigning same object
|
||||||
|
|||||||
@ -1,11 +1,36 @@
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../bluetooth/bt_poller.dart';
|
import '../bluetooth/bt_poller.dart';
|
||||||
|
import '../logging/app_log.dart';
|
||||||
|
import '../models/sensor_defs.dart';
|
||||||
import '../protocol/kpro_parser.dart';
|
import '../protocol/kpro_parser.dart';
|
||||||
import '../protocol/s300_parser.dart';
|
import '../protocol/s300_parser.dart';
|
||||||
import '../protocol/sensor_state.dart';
|
import '../protocol/sensor_state.dart';
|
||||||
import 'bt_provider.dart';
|
import 'bt_provider.dart';
|
||||||
import 'settings_provider.dart';
|
import 'datalog_provider.dart';
|
||||||
|
|
||||||
|
// NEG8 is only an 8-bit checksum, so roughly 1 in 256 corrupted frames can
|
||||||
|
// still pass validation — rare, but enough to occasionally show an
|
||||||
|
// impossible value: a single flipped bit in the high byte of a 16-bit field
|
||||||
|
// can jump it into the tens of thousands. Clamp just the fields where that's
|
||||||
|
// possible (multi-byte reads whose units genuinely match sensorDefs — RPM,
|
||||||
|
// VSS, MAP, INJ) to their realistic range, the same bounds the dashboard's
|
||||||
|
// gauges already use for normalization. Single-byte fields (TPS, gear, ECT/
|
||||||
|
// IAT, etc.) are already bounded to 0-255 raw by construction and can't
|
||||||
|
// produce this kind of runaway spike, so they're left alone here.
|
||||||
|
SensorState _clampToRealisticRange(SensorState s) {
|
||||||
|
double clampFor(String id, double value) {
|
||||||
|
final def = sensorDefs.firstWhere((d) => d.id == id);
|
||||||
|
return value.clamp(def.min, def.max);
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.copyWith(
|
||||||
|
rpm: clampFor('rpm', s.rpm),
|
||||||
|
vss: clampFor('vss', s.vss),
|
||||||
|
map: clampFor('map', s.map),
|
||||||
|
inj: clampFor('inj', s.inj),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Counts of what sensorStateProvider actually did with each frame it saw,
|
/// Counts of what sensorStateProvider actually did with each frame it saw,
|
||||||
/// exposed for the debug screen so the live pipeline can be inspected
|
/// exposed for the debug screen so the live pipeline can be inspected
|
||||||
@ -27,61 +52,39 @@ class DecodeStats {
|
|||||||
|
|
||||||
final decodeStatsProvider = StateProvider<DecodeStats>((ref) => const DecodeStats());
|
final decodeStatsProvider = StateProvider<DecodeStats>((ref) => const DecodeStats());
|
||||||
|
|
||||||
// Bluetooth's round-trip jitter can make an individual request land outside
|
/// Emits a parsed [SensorState] for every live data frame received from the
|
||||||
// the ECU's transient tach pulse-capture window, so a poll reports "no
|
/// ECU — or, while a datalog plays back, for every recorded frame. Picks the
|
||||||
// fresh count" (rpm=0) even though the engine/bench signal never actually
|
/// S300 or KPro parser via [effectiveEcuTypeProvider].
|
||||||
// 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* {
|
final sensorStateProvider = StreamProvider<SensorState>((ref) async* {
|
||||||
final ecuType = ref.watch(settingsProvider).ecuType;
|
final ecuType = ref.watch(effectiveEcuTypeProvider);
|
||||||
final btNotifier = ref.watch(btProvider.notifier);
|
final playing = ref.watch(playbackProvider.select((p) => p.isActive));
|
||||||
|
final source = playing
|
||||||
|
? ref.watch(datalogPlayerProvider).frameStream
|
||||||
|
: ref.watch(btProvider.notifier).frameStream;
|
||||||
|
|
||||||
// `await for` treats any error *event* on the source stream as fatal and
|
// `await for` treats an error event as fatal; absorb errors upstream so the
|
||||||
// exits the loop permanently — a try/catch inside the loop body cannot
|
// loop keeps waiting for the next frame.
|
||||||
// intercept that, since it happens in the iteration mechanism itself, not
|
final safeStream = source.handleError((Object _, StackTrace __) {});
|
||||||
// the body. handleError() absorbs error events upstream so `await for`
|
|
||||||
// never sees them and the loop just keeps waiting for the next frame.
|
|
||||||
final safeStream = btNotifier.frameStream.handleError((Object _, StackTrace __) {});
|
|
||||||
|
|
||||||
double lastGoodRpm = 0;
|
|
||||||
DateTime? lastGoodRpmAt;
|
|
||||||
|
|
||||||
|
// Adapter default frames are already filtered out in BtNotifier, so every
|
||||||
|
// frame here is a real ECU reading. No "hold last value" logic any more:
|
||||||
|
// a missing reading simply means the gauge keeps its last position until
|
||||||
|
// the next real frame, and the UI shows a banner if data stops.
|
||||||
await for (final frame in safeStream) {
|
await for (final frame in safeStream) {
|
||||||
// A single bad frame must never take down the rest of the live stream.
|
|
||||||
try {
|
try {
|
||||||
SensorState decoded =
|
SensorState decoded =
|
||||||
ecuType == EcuType.s300 ? parseS300(frame) : parseKPro(frame);
|
ecuType == EcuType.s300 ? parseS300(frame) : parseKPro(frame);
|
||||||
|
decoded = _clampToRealisticRange(decoded);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final s = ref.read(decodeStatsProvider);
|
final s = ref.read(decodeStatsProvider);
|
||||||
ref.read(decodeStatsProvider.notifier).state =
|
ref.read(decodeStatsProvider.notifier).state =
|
||||||
s.copyWith(decoded: s.decoded + 1);
|
s.copyWith(decoded: s.decoded + 1);
|
||||||
yield decoded;
|
yield decoded;
|
||||||
} catch (_) {
|
} catch (e) {
|
||||||
// Skip this one frame and keep the stream alive for the next.
|
|
||||||
final s = ref.read(decodeStatsProvider);
|
final s = ref.read(decodeStatsProvider);
|
||||||
ref.read(decodeStatsProvider.notifier).state =
|
ref.read(decodeStatsProvider.notifier).state =
|
||||||
s.copyWith(decodeErrors: s.decodeErrors + 1);
|
s.copyWith(decodeErrors: s.decodeErrors + 1);
|
||||||
|
if (s.decodeErrors < 5) AppLog.w('Frame decode error: $e');
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,13 @@
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import '../bluetooth/bt_poller.dart';
|
import '../bluetooth/bt_poller.dart';
|
||||||
|
import '../models/units.dart';
|
||||||
|
|
||||||
|
/// Polling choices offered in Settings. Never faster than 10 Hz: the adapter
|
||||||
|
/// MCU must have time to read the ECU between requests (device maker's
|
||||||
|
/// feedback). The poller additionally never sends a new request before the
|
||||||
|
/// previous one is answered or has timed out.
|
||||||
|
const List<int> pollingIntervalOptionsMs = [100, 150, 200, 300];
|
||||||
|
|
||||||
class AppSettings {
|
class AppSettings {
|
||||||
final EcuType ecuType;
|
final EcuType ecuType;
|
||||||
@ -7,7 +15,7 @@ class AppSettings {
|
|||||||
|
|
||||||
const AppSettings({
|
const AppSettings({
|
||||||
this.ecuType = EcuType.s300,
|
this.ecuType = EcuType.s300,
|
||||||
this.pollingInterval = const Duration(milliseconds: 50),
|
this.pollingInterval = BtPoller.defaultInterval,
|
||||||
});
|
});
|
||||||
|
|
||||||
AppSettings copyWith({EcuType? ecuType, Duration? pollingInterval}) =>
|
AppSettings copyWith({EcuType? ecuType, Duration? pollingInterval}) =>
|
||||||
@ -17,14 +25,110 @@ class AppSettings {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class SettingsNotifier extends StateNotifier<AppSettings> {
|
const _ecuTypeKey = 'ecuType';
|
||||||
SettingsNotifier() : super(const AppSettings());
|
const _pollingKey = 'pollingIntervalMs';
|
||||||
|
|
||||||
void setEcuType(EcuType type) => state = state.copyWith(ecuType: type);
|
class SettingsNotifier extends StateNotifier<AppSettings> {
|
||||||
void setPollingInterval(Duration d) =>
|
SettingsNotifier() : super(const AppSettings()) {
|
||||||
state = state.copyWith(pollingInterval: d);
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final saved = prefs.getString(_ecuTypeKey);
|
||||||
|
final ms = prefs.getInt(_pollingKey);
|
||||||
|
var next = state;
|
||||||
|
if (saved == EcuType.kpro.name) next = next.copyWith(ecuType: EcuType.kpro);
|
||||||
|
if (ms != null && pollingIntervalOptionsMs.contains(ms)) {
|
||||||
|
next = next.copyWith(pollingInterval: Duration(milliseconds: ms));
|
||||||
|
}
|
||||||
|
if (mounted) state = next;
|
||||||
|
} catch (_) {
|
||||||
|
// Keep defaults if preferences can't be read.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void setEcuType(EcuType type) {
|
||||||
|
state = state.copyWith(ecuType: type);
|
||||||
|
SharedPreferences.getInstance()
|
||||||
|
.then((prefs) => prefs.setString(_ecuTypeKey, type.name))
|
||||||
|
.catchError((Object _) => false);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setPollingInterval(Duration d) {
|
||||||
|
final ms = pollingIntervalOptionsMs.contains(d.inMilliseconds)
|
||||||
|
? d.inMilliseconds
|
||||||
|
: BtPoller.defaultInterval.inMilliseconds;
|
||||||
|
state = state.copyWith(pollingInterval: Duration(milliseconds: ms));
|
||||||
|
SharedPreferences.getInstance()
|
||||||
|
.then((prefs) => prefs.setInt(_pollingKey, ms))
|
||||||
|
.catchError((Object _) => false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final settingsProvider = StateNotifierProvider<SettingsNotifier, AppSettings>(
|
final settingsProvider = StateNotifierProvider<SettingsNotifier, AppSettings>(
|
||||||
(ref) => SettingsNotifier(),
|
(ref) => SettingsNotifier(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ─── Display units ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const _unitKeys = (speed: 'unitSpeed', temp: 'unitTemp', pressure: 'unitPressure');
|
||||||
|
|
||||||
|
T? _byName<T extends Enum>(List<T> values, String? name) {
|
||||||
|
for (final v in values) {
|
||||||
|
if (v.name == name) return v;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Display units (speed / temperature / pressure), persisted.
|
||||||
|
class UnitPrefsNotifier extends StateNotifier<UnitPrefs> {
|
||||||
|
UnitPrefsNotifier() : super(const UnitPrefs()) {
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final next = UnitPrefs(
|
||||||
|
speed: _byName(SpeedUnit.values, prefs.getString(_unitKeys.speed)) ??
|
||||||
|
state.speed,
|
||||||
|
temp: _byName(TempUnit.values, prefs.getString(_unitKeys.temp)) ??
|
||||||
|
state.temp,
|
||||||
|
pressure: _byName(
|
||||||
|
PressureUnit.values, prefs.getString(_unitKeys.pressure)) ??
|
||||||
|
state.pressure,
|
||||||
|
);
|
||||||
|
if (mounted) state = next;
|
||||||
|
} catch (_) {
|
||||||
|
// Keep defaults if preferences can't be read.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _save(String key, Enum v) {
|
||||||
|
SharedPreferences.getInstance()
|
||||||
|
.then((prefs) => prefs.setString(key, v.name))
|
||||||
|
.catchError((Object _) => false);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setSpeed(SpeedUnit v) {
|
||||||
|
state = state.copyWith(speed: v);
|
||||||
|
_save(_unitKeys.speed, v);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setTemp(TempUnit v) {
|
||||||
|
state = state.copyWith(temp: v);
|
||||||
|
_save(_unitKeys.temp, v);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setPressure(PressureUnit v) {
|
||||||
|
state = state.copyWith(pressure: v);
|
||||||
|
_save(_unitKeys.pressure, v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final unitPrefsProvider =
|
||||||
|
StateNotifierProvider<UnitPrefsNotifier, UnitPrefs>(
|
||||||
|
(ref) => UnitPrefsNotifier(),
|
||||||
|
);
|
||||||
|
|||||||
@ -1,12 +1,35 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
enum AppThemeVariant { redDark, redLight, greenDark, greenLight }
|
enum AppThemeVariant { redDark, redLight, greenDark, greenLight }
|
||||||
|
|
||||||
class ThemeNotifier extends StateNotifier<AppThemeVariant> {
|
const _themeKey = 'themeVariant';
|
||||||
ThemeNotifier() : super(AppThemeVariant.redDark);
|
|
||||||
|
|
||||||
void set(AppThemeVariant v) => state = v;
|
/// Colour scheme (red/green) × brightness (dark/light), persisted.
|
||||||
|
class ThemeNotifier extends StateNotifier<AppThemeVariant> {
|
||||||
|
ThemeNotifier() : super(AppThemeVariant.redDark) {
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final saved = prefs.getString(_themeKey);
|
||||||
|
for (final v in AppThemeVariant.values) {
|
||||||
|
if (v.name == saved && mounted) state = v;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Keep the default if preferences can't be read.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void set(AppThemeVariant v) {
|
||||||
|
state = v;
|
||||||
|
SharedPreferences.getInstance()
|
||||||
|
.then((prefs) => prefs.setString(_themeKey, v.name))
|
||||||
|
.catchError((Object _) => false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final themeProvider =
|
final themeProvider =
|
||||||
|
|||||||
@ -1,12 +1,48 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import 'core/logging/app_log.dart';
|
||||||
import 'core/providers/theme_provider.dart';
|
import 'core/providers/theme_provider.dart';
|
||||||
import 'ui/theme/app_theme.dart';
|
import 'ui/theme/app_theme.dart';
|
||||||
import 'ui/screens/bt_picker/bt_picker_screen.dart';
|
import 'ui/screens/bt_picker/bt_picker_screen.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
runApp(const ProviderScope(child: HvbtApp()));
|
runZonedGuarded(() {
|
||||||
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
// Catch every Dart-side error, log it (copyable from the Debug screen)
|
||||||
|
// and keep the app running instead of showing a red/grey error screen.
|
||||||
|
FlutterError.onError = (FlutterErrorDetails details) {
|
||||||
|
AppLog.e('Flutter error', details.exception, details.stack);
|
||||||
|
};
|
||||||
|
WidgetsBinding.instance.platformDispatcher.onError = (error, stack) {
|
||||||
|
AppLog.e('Uncaught platform error', error, stack);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
// Release only: keep Flutter's red error screen in debug builds so
|
||||||
|
// layout/build errors stay visible during development.
|
||||||
|
if (kReleaseMode) {
|
||||||
|
ErrorWidget.builder = (FlutterErrorDetails details) => const Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(16),
|
||||||
|
child: Text(
|
||||||
|
'Something went wrong on this screen.\n'
|
||||||
|
'Open Debug → Copy log and send it to support.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(color: Colors.grey),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
AppLog.i('App start');
|
||||||
|
runApp(const ProviderScope(child: HvbtApp()));
|
||||||
|
}, (error, stack) {
|
||||||
|
AppLog.e('Uncaught zone error', error, stack);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
class HvbtApp extends ConsumerWidget {
|
class HvbtApp extends ConsumerWidget {
|
||||||
@ -20,6 +56,13 @@ class HvbtApp extends ConsumerWidget {
|
|||||||
title: 'HV BT Dashboard',
|
title: 'HV BT Dashboard',
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
theme: AppTheme.build(themeVariant),
|
theme: AppTheme.build(themeVariant),
|
||||||
|
// Keep every screen clear of the system navigation bar, which sits on
|
||||||
|
// a side in landscape.
|
||||||
|
builder: (context, child) => SafeArea(
|
||||||
|
top: false,
|
||||||
|
bottom: false,
|
||||||
|
child: child ?? const SizedBox.shrink(),
|
||||||
|
),
|
||||||
home: const BtPickerScreen(),
|
home: const BtPickerScreen(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,10 +2,9 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart';
|
import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
import '../../../core/bluetooth/bt_poller.dart';
|
|
||||||
import '../../../core/providers/bt_provider.dart';
|
import '../../../core/providers/bt_provider.dart';
|
||||||
import '../../../core/providers/settings_provider.dart';
|
|
||||||
import '../../widgets/main_nav.dart';
|
import '../../widgets/main_nav.dart';
|
||||||
|
import '../../../core/logging/app_log.dart';
|
||||||
|
|
||||||
class BtPickerScreen extends ConsumerStatefulWidget {
|
class BtPickerScreen extends ConsumerStatefulWidget {
|
||||||
const BtPickerScreen({super.key});
|
const BtPickerScreen({super.key});
|
||||||
@ -24,78 +23,83 @@ class _BtPickerScreenState extends ConsumerState<BtPickerScreen> {
|
|||||||
WidgetsBinding.instance.addPostFrameCallback((_) => _initBluetooth());
|
WidgetsBinding.instance.addPostFrameCallback((_) => _initBluetooth());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _permissionsOk = false;
|
||||||
|
|
||||||
Future<void> _initBluetooth() async {
|
Future<void> _initBluetooth() async {
|
||||||
setState(() => _error = null);
|
setState(() {
|
||||||
|
_error = null;
|
||||||
|
_initDone = false;
|
||||||
|
});
|
||||||
|
|
||||||
final statuses = await [
|
try {
|
||||||
Permission.bluetoothConnect,
|
// Android 12+: BLUETOOTH_CONNECT is the only permission needed to list
|
||||||
Permission.bluetoothScan,
|
// paired devices and open an RFCOMM socket. On Android 11 and older
|
||||||
Permission.locationWhenInUse,
|
// permission_handler reports it as granted. Scan/location are requested
|
||||||
].request();
|
// but not required (we never run discovery).
|
||||||
|
final statuses = await [
|
||||||
|
Permission.bluetoothConnect,
|
||||||
|
Permission.bluetoothScan,
|
||||||
|
Permission.locationWhenInUse,
|
||||||
|
].request();
|
||||||
|
|
||||||
final denied = statuses.entries
|
final connect = statuses[Permission.bluetoothConnect];
|
||||||
.where((e) => e.value.isDenied || e.value.isPermanentlyDenied)
|
if (connect != null && !connect.isGranted && !connect.isLimited) {
|
||||||
.map((e) => e.key.toString())
|
AppLog.w('Bluetooth permission not granted: $connect');
|
||||||
.toList();
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_permissionsOk = false;
|
||||||
|
_error = 'Bluetooth permission ("Nearby devices") was not granted.\n\n'
|
||||||
|
'Go to Settings → Apps → HV BT Dashboard → Permissions.';
|
||||||
|
_initDone = true;
|
||||||
|
});
|
||||||
|
if (connect.isPermanentlyDenied) await openAppSettings();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_permissionsOk = true;
|
||||||
|
|
||||||
if (denied.isNotEmpty) {
|
final btState = await FlutterBluetoothSerial.instance.state;
|
||||||
setState(() {
|
if (btState != BluetoothState.STATE_ON) {
|
||||||
_error = 'Permissions denied: ${denied.join(", ")}\n\n'
|
await FlutterBluetoothSerial.instance.requestEnable();
|
||||||
'Go to Settings → Apps → HV BT Dashboard → Permissions.';
|
await Future<void>.delayed(const Duration(seconds: 1));
|
||||||
_initDone = true;
|
}
|
||||||
});
|
} catch (e, st) {
|
||||||
return;
|
AppLog.e('Bluetooth init failed', e, st);
|
||||||
}
|
if (!mounted) return;
|
||||||
|
setState(() => _error = 'Bluetooth init failed: $e');
|
||||||
final btState = await FlutterBluetoothSerial.instance.state;
|
|
||||||
if (btState != BluetoothState.STATE_ON) {
|
|
||||||
await FlutterBluetoothSerial.instance.requestEnable();
|
|
||||||
await Future<void>.delayed(const Duration(seconds: 1));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
setState(() => _initDone = true);
|
setState(() => _initDone = true);
|
||||||
ref.invalidate(pairedDevicesProvider);
|
if (_permissionsOk) ref.invalidate(pairedDevicesProvider);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _connect(BluetoothDevice device, EcuType ecuType) async {
|
// ECU protocol is chosen once via Settings (or defaults to S300) and
|
||||||
ref.read(settingsProvider.notifier).setEcuType(ecuType);
|
// persisted from there — no need to ask again on every connection. A
|
||||||
|
// dialog here used to re-prompt "S300 or KPro?" on every single tap,
|
||||||
|
// which was pure friction once the user had already told the app which
|
||||||
|
// ECU they have.
|
||||||
|
Future<void> _connect(BluetoothDevice device) async {
|
||||||
|
if (ref.read(btProvider).isConnecting) return;
|
||||||
await ref.read(btProvider.notifier).connect(device);
|
await ref.read(btProvider.notifier).connect(device);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (ref.read(btProvider).isConnected) {
|
if (ref.read(btProvider).isConnected) {
|
||||||
Navigator.of(context).pushReplacement(
|
// Replace the whole stack: when reached via Settings, a plain
|
||||||
|
// replacement left the old dashboard and Settings underneath.
|
||||||
|
Navigator.of(context).pushAndRemoveUntil(
|
||||||
MaterialPageRoute(builder: (_) => const MainNav()),
|
MaterialPageRoute(builder: (_) => const MainNav()),
|
||||||
|
(_) => false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final btState = ref.watch(btProvider);
|
final btState = ref.watch(btProvider);
|
||||||
final devicesAsync = ref.watch(pairedDevicesProvider);
|
// Do NOT touch the paired-device list before permission is granted:
|
||||||
|
// on Android 12+ that call throws inside the Bluetooth plugin.
|
||||||
|
final AsyncValue<List<BluetoothDevice>> devicesAsync = _permissionsOk
|
||||||
|
? ref.watch(pairedDevicesProvider)
|
||||||
|
: const AsyncValue<List<BluetoothDevice>>.data([]);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
@ -153,9 +157,7 @@ class _BtPickerScreenState extends ConsumerState<BtPickerScreen> {
|
|||||||
: const Icon(
|
: const Icon(
|
||||||
Icons.arrow_forward_ios,
|
Icons.arrow_forward_ios,
|
||||||
size: 14),
|
size: 14),
|
||||||
onTap: connecting
|
onTap: connecting ? null : () => _connect(d),
|
||||||
? null
|
|
||||||
: () => _showProtocolDialog(d),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@ -1,112 +1,81 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../../core/providers/sensor_provider.dart';
|
import '../../../core/providers/sensor_provider.dart';
|
||||||
import '../../../core/providers/theme_provider.dart';
|
import '../../../core/providers/settings_provider.dart';
|
||||||
import '../../../ui/theme/app_colors.dart';
|
import '../../theme/app_colors.dart';
|
||||||
import 'widgets/analog_gauge.dart';
|
import 'dashboard_slots_provider.dart';
|
||||||
|
import 'widgets/dashboard_common.dart';
|
||||||
import 'widgets/flag_pill.dart';
|
import 'widgets/flag_pill.dart';
|
||||||
import 'widgets/sensor_tile.dart';
|
import 'widgets/sensor_tile.dart';
|
||||||
import 'widgets/sensor_picker_sheet.dart';
|
|
||||||
import 'dashboard_slots_provider.dart';
|
|
||||||
|
|
||||||
|
/// Landscape layout (requirements): main + sub sensor in the centre,
|
||||||
|
/// 5 values left, 5 values right, 4 flags along the bottom.
|
||||||
class DashboardHorizontal extends ConsumerWidget {
|
class DashboardHorizontal extends ConsumerWidget {
|
||||||
const DashboardHorizontal({super.key});
|
const DashboardHorizontal({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final c = AppColors.of(context);
|
||||||
final state = ref.watch(latestSensorProvider);
|
final state = ref.watch(latestSensorProvider);
|
||||||
final themeVariant = ref.watch(themeProvider);
|
final config = ref.watch(dashboardConfigProvider);
|
||||||
final colors = AppColors.forTheme(themeVariant);
|
final units = ref.watch(unitPrefsProvider);
|
||||||
final slots = ref.watch(dashboardSlotsProvider);
|
|
||||||
|
|
||||||
return Container(
|
Widget column(int from) => SizedBox(
|
||||||
color: const Color(0xFF0A0A0A),
|
width: 190,
|
||||||
padding: const EdgeInsets.all(8),
|
child: Column(
|
||||||
child: Column(
|
children: [
|
||||||
children: [
|
for (int i = from; i < from + 5; i++)
|
||||||
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(
|
Expanded(
|
||||||
child: Center(
|
child: Padding(
|
||||||
child: AnalogGauge(
|
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||||
value: state.rpm,
|
child: SensorTile(
|
||||||
minValue: 0,
|
sensorId: config.values[i],
|
||||||
maxValue: 9000,
|
state: state,
|
||||||
label: 'RPM',
|
units: units,
|
||||||
unit: 'rpm',
|
onLongPress: () => editDashboardValue(context, ref, i),
|
||||||
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) {
|
return Container(
|
||||||
showModalBottomSheet<void>(
|
color: c.background,
|
||||||
context: context,
|
// SafeArea: in landscape the system navigation bar sits on a side.
|
||||||
builder: (_) => SensorPickerSheet(
|
child: SafeArea(
|
||||||
currentId: ref.read(dashboardSlotsProvider)[slotIndex],
|
top: false,
|
||||||
onSelected: (id) {
|
bottom: false,
|
||||||
ref.read(dashboardSlotsProvider.notifier).setSlot(slotIndex, id);
|
child: Padding(
|
||||||
Navigator.pop(context);
|
padding: const EdgeInsets.fromLTRB(8, 4, 8, 6),
|
||||||
},
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
column(0),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Center(child: MainGauge(state: state)),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
column(5),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 198),
|
||||||
|
child: FlagRow(
|
||||||
|
state: state,
|
||||||
|
flagIds: config.flags,
|
||||||
|
onLongPress: (i) => editDashboardFlag(context, ref, i),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,19 +1,125 @@
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
// Default sensor IDs for the 6 dashboard tile slots
|
import '../../../core/models/sensor_defs.dart';
|
||||||
const _defaultSlots = ['vss', 'map', 'tps', 'ect', 'iat', 'bat'];
|
|
||||||
|
|
||||||
class DashboardSlotsNotifier extends StateNotifier<List<String>> {
|
/// What the dashboard shows, per the requirement document: a selectable
|
||||||
DashboardSlotsNotifier() : super(List.from(_defaultSlots));
|
/// main sensor (gauge) + sub sensor, 10 selectable values (landscape: 5 left
|
||||||
|
/// + 5 right; portrait: 2×5) and 4 selectable flags. Persisted.
|
||||||
|
class DashboardConfig {
|
||||||
|
static const valueCount = 10;
|
||||||
|
static const flagCount = 4;
|
||||||
|
|
||||||
void setSlot(int index, String sensorId) {
|
final String main;
|
||||||
final next = List<String>.from(state);
|
final String sub;
|
||||||
next[index] = sensorId;
|
final List<String> values;
|
||||||
state = next;
|
final List<String> flags;
|
||||||
|
|
||||||
|
const DashboardConfig({
|
||||||
|
required this.main,
|
||||||
|
required this.sub,
|
||||||
|
required this.values,
|
||||||
|
required this.flags,
|
||||||
|
});
|
||||||
|
|
||||||
|
static const defaults = DashboardConfig(
|
||||||
|
main: 'rpm',
|
||||||
|
sub: 'vss',
|
||||||
|
values: [
|
||||||
|
'vss', 'ect', 'iat', 'map', 'o2', // left
|
||||||
|
'tps', 'bat', 'ign', 'inj', 'eth', // right
|
||||||
|
],
|
||||||
|
// Requirement picture: MIL, FuelCut, FAN Out, VTEC Out. On the S300
|
||||||
|
// the fuel flag is the fuel pump relay (client).
|
||||||
|
flags: ['mil', 'fuelpump', 'fanout', 'vtec'],
|
||||||
|
);
|
||||||
|
|
||||||
|
DashboardConfig copyWith({
|
||||||
|
String? main,
|
||||||
|
String? sub,
|
||||||
|
List<String>? values,
|
||||||
|
List<String>? flags,
|
||||||
|
}) =>
|
||||||
|
DashboardConfig(
|
||||||
|
main: main ?? this.main,
|
||||||
|
sub: sub ?? this.sub,
|
||||||
|
values: values ?? this.values,
|
||||||
|
flags: flags ?? this.flags,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const _kMain = 'dash.main';
|
||||||
|
const _kSub = 'dash.sub';
|
||||||
|
const _kValues = 'dash.values';
|
||||||
|
const _kFlags = 'dash.flags';
|
||||||
|
|
||||||
|
class DashboardConfigNotifier extends StateNotifier<DashboardConfig> {
|
||||||
|
DashboardConfigNotifier() : super(DashboardConfig.defaults) {
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool _isSensor(String id) => sensorDefs.any((d) => d.id == id);
|
||||||
|
static bool _isFlag(String id) => flagDefs.any((f) => f.id == id);
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
try {
|
||||||
|
final p = await SharedPreferences.getInstance();
|
||||||
|
const d = DashboardConfig.defaults;
|
||||||
|
List<String> list(String key, int n, bool Function(String) valid,
|
||||||
|
List<String> fallback) {
|
||||||
|
final saved = p.getStringList(key);
|
||||||
|
if (saved == null || saved.length != n || !saved.every(valid)) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
final main = p.getString(_kMain);
|
||||||
|
final sub = p.getString(_kSub);
|
||||||
|
final next = DashboardConfig(
|
||||||
|
main: main != null && _isSensor(main) ? main : d.main,
|
||||||
|
sub: sub != null && _isSensor(sub) ? sub : d.sub,
|
||||||
|
values: list(_kValues, DashboardConfig.valueCount, _isSensor, d.values),
|
||||||
|
flags: list(_kFlags, DashboardConfig.flagCount, _isFlag, d.flags),
|
||||||
|
);
|
||||||
|
if (mounted) state = next;
|
||||||
|
} catch (_) {
|
||||||
|
// Keep defaults if preferences can't be read.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _persist() {
|
||||||
|
final s = state;
|
||||||
|
SharedPreferences.getInstance().then((p) {
|
||||||
|
p.setString(_kMain, s.main);
|
||||||
|
p.setString(_kSub, s.sub);
|
||||||
|
p.setStringList(_kValues, s.values);
|
||||||
|
p.setStringList(_kFlags, s.flags);
|
||||||
|
}).catchError((Object _) {});
|
||||||
|
}
|
||||||
|
|
||||||
|
void setMain(String id) {
|
||||||
|
state = state.copyWith(main: id);
|
||||||
|
_persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setSub(String id) {
|
||||||
|
state = state.copyWith(sub: id);
|
||||||
|
_persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setValue(int index, String id) {
|
||||||
|
state = state.copyWith(values: [...state.values]..[index] = id);
|
||||||
|
_persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setFlag(int index, String id) {
|
||||||
|
state = state.copyWith(flags: [...state.flags]..[index] = id);
|
||||||
|
_persist();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final dashboardSlotsProvider =
|
final dashboardConfigProvider =
|
||||||
StateNotifierProvider<DashboardSlotsNotifier, List<String>>(
|
StateNotifierProvider<DashboardConfigNotifier, DashboardConfig>(
|
||||||
(ref) => DashboardSlotsNotifier(),
|
(ref) => DashboardConfigNotifier(),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,80 +1,94 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../../core/providers/sensor_provider.dart';
|
import '../../../core/providers/sensor_provider.dart';
|
||||||
import '../../../core/providers/theme_provider.dart';
|
import '../../../core/providers/settings_provider.dart';
|
||||||
import '../../../ui/theme/app_colors.dart';
|
import '../../theme/app_colors.dart';
|
||||||
import 'widgets/analog_gauge.dart';
|
import 'dashboard_slots_provider.dart';
|
||||||
|
import 'widgets/dashboard_common.dart';
|
||||||
import 'widgets/flag_pill.dart';
|
import 'widgets/flag_pill.dart';
|
||||||
import 'widgets/sensor_tile.dart';
|
import 'widgets/sensor_tile.dart';
|
||||||
import 'widgets/sensor_picker_sheet.dart';
|
|
||||||
import 'dashboard_slots_provider.dart';
|
|
||||||
|
|
||||||
|
/// Portrait layout (requirements): main + sub sensor on top, 4 flags in the
|
||||||
|
/// middle, 2×5 values below — all on one screen, the gauge taking whatever
|
||||||
|
/// height the flags and values leave.
|
||||||
class DashboardVertical extends ConsumerWidget {
|
class DashboardVertical extends ConsumerWidget {
|
||||||
const DashboardVertical({super.key});
|
const DashboardVertical({super.key});
|
||||||
|
|
||||||
|
static const _tileHeight = 64.0;
|
||||||
|
static const _gap = 6.0;
|
||||||
|
static const _flagsHeight = 30.0 + 2 * _gap;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final c = AppColors.of(context);
|
||||||
final state = ref.watch(latestSensorProvider);
|
final state = ref.watch(latestSensorProvider);
|
||||||
final themeVariant = ref.watch(themeProvider);
|
final config = ref.watch(dashboardConfigProvider);
|
||||||
final colors = AppColors.forTheme(themeVariant);
|
final units = ref.watch(unitPrefsProvider);
|
||||||
final slots = ref.watch(dashboardSlotsProvider);
|
|
||||||
|
|
||||||
return Container(
|
final tiles = Padding(
|
||||||
color: const Color(0xFF0A0A0A),
|
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
// Left column is values 0-4, right column 5-9, matching landscape.
|
||||||
padding: const EdgeInsets.fromLTRB(24, 8, 24, 0),
|
for (int row = 0; row < 5; row++)
|
||||||
child: AnalogGauge(
|
Padding(
|
||||||
value: state.rpm,
|
padding: EdgeInsets.only(top: row == 0 ? 0 : _gap),
|
||||||
minValue: 0,
|
child: SizedBox(
|
||||||
maxValue: 9000,
|
height: _tileHeight,
|
||||||
label: 'RPM',
|
child: Row(
|
||||||
unit: 'rpm',
|
children: [
|
||||||
accentColor: colors.accent,
|
for (final i in [row, row + 5]) ...[
|
||||||
),
|
if (i != row) const SizedBox(width: _gap),
|
||||||
),
|
Expanded(
|
||||||
Padding(
|
child: SensorTile(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12),
|
sensorId: config.values[i],
|
||||||
child: FlagRow(state: state, accentColor: colors.accent),
|
state: state,
|
||||||
),
|
units: units,
|
||||||
Expanded(
|
onLongPress: () =>
|
||||||
child: Padding(
|
editDashboardValue(context, ref, i),
|
||||||
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) {
|
return Container(
|
||||||
showModalBottomSheet<void>(
|
color: c.background,
|
||||||
context: context,
|
child: LayoutBuilder(builder: (context, box) {
|
||||||
builder: (_) => SensorPickerSheet(
|
const tilesHeight = 5 * _tileHeight + 4 * _gap + 8;
|
||||||
currentId: ref.read(dashboardSlotsProvider)[slotIndex],
|
final gaugeSize = math.max(
|
||||||
onSelected: (id) {
|
140.0,
|
||||||
ref.read(dashboardSlotsProvider.notifier).setSlot(slotIndex, id);
|
math.min(box.maxWidth - 32, box.maxHeight - tilesHeight - _flagsHeight),
|
||||||
Navigator.pop(context);
|
);
|
||||||
},
|
// Scrolls only if the screen is too short even for a small gauge.
|
||||||
),
|
return SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
height: gaugeSize,
|
||||||
|
child: Center(child: MainGauge(state: state)),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(vertical: _gap, horizontal: 8),
|
||||||
|
child: FlagRow(
|
||||||
|
state: state,
|
||||||
|
flagIds: config.flags,
|
||||||
|
onLongPress: (i) => editDashboardFlag(context, ref, i),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
tiles,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,6 +8,14 @@ class AnalogGauge extends StatefulWidget {
|
|||||||
final String label;
|
final String label;
|
||||||
final String unit;
|
final String unit;
|
||||||
final Color accentColor;
|
final Color accentColor;
|
||||||
|
/// Formatted main value; defaults to [value] with no decimals.
|
||||||
|
final String? valueText;
|
||||||
|
/// Second line under the unit, e.g. the sub sensor "120 km/h".
|
||||||
|
final String? subText;
|
||||||
|
final Color foreground;
|
||||||
|
final Color muted;
|
||||||
|
final Color track;
|
||||||
|
final Color background;
|
||||||
|
|
||||||
const AnalogGauge({
|
const AnalogGauge({
|
||||||
super.key,
|
super.key,
|
||||||
@ -17,6 +25,12 @@ class AnalogGauge extends StatefulWidget {
|
|||||||
required this.label,
|
required this.label,
|
||||||
required this.unit,
|
required this.unit,
|
||||||
required this.accentColor,
|
required this.accentColor,
|
||||||
|
this.valueText,
|
||||||
|
this.subText,
|
||||||
|
this.foreground = Colors.white,
|
||||||
|
this.muted = Colors.white54,
|
||||||
|
this.track = const Color(0xFF2A2A2A),
|
||||||
|
this.background = const Color(0xFF0A0A0A),
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -38,7 +52,10 @@ class _AnalogGaugeState extends State<AnalogGauge>
|
|||||||
super.initState();
|
super.initState();
|
||||||
_ctrl = AnimationController(
|
_ctrl = AnimationController(
|
||||||
vsync: this,
|
vsync: this,
|
||||||
duration: const Duration(milliseconds: 150),
|
// Roughly one polling interval: the needle glides from reading to
|
||||||
|
// reading instead of jumping and stopping (was 150 ms ease-out, which
|
||||||
|
// looked stepped at 10-20 Hz data).
|
||||||
|
duration: const Duration(milliseconds: 110),
|
||||||
);
|
);
|
||||||
_anim = Tween<double>(begin: 0, end: _normalized)
|
_anim = Tween<double>(begin: 0, end: _normalized)
|
||||||
.animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOut));
|
.animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOut));
|
||||||
@ -50,7 +67,7 @@ class _AnalogGaugeState extends State<AnalogGauge>
|
|||||||
super.didUpdateWidget(old);
|
super.didUpdateWidget(old);
|
||||||
if (old.value != widget.value) {
|
if (old.value != widget.value) {
|
||||||
_anim = Tween<double>(begin: _anim.value, end: _normalized)
|
_anim = Tween<double>(begin: _anim.value, end: _normalized)
|
||||||
.animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOut));
|
.animate(CurvedAnimation(parent: _ctrl, curve: Curves.linear));
|
||||||
_ctrl.forward(from: 0);
|
_ctrl.forward(from: 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -71,9 +88,14 @@ class _AnalogGaugeState extends State<AnalogGauge>
|
|||||||
painter: _GaugePainter(
|
painter: _GaugePainter(
|
||||||
normalizedValue: _anim.value,
|
normalizedValue: _anim.value,
|
||||||
accentColor: widget.accentColor,
|
accentColor: widget.accentColor,
|
||||||
displayValue: widget.value,
|
valueText: widget.valueText ?? widget.value.toStringAsFixed(0),
|
||||||
|
subText: widget.subText,
|
||||||
unit: widget.unit,
|
unit: widget.unit,
|
||||||
label: widget.label,
|
label: widget.label,
|
||||||
|
foreground: widget.foreground,
|
||||||
|
muted: widget.muted,
|
||||||
|
track: widget.track,
|
||||||
|
background: widget.background,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -84,9 +106,14 @@ class _AnalogGaugeState extends State<AnalogGauge>
|
|||||||
class _GaugePainter extends CustomPainter {
|
class _GaugePainter extends CustomPainter {
|
||||||
final double normalizedValue; // 0.0–1.0
|
final double normalizedValue; // 0.0–1.0
|
||||||
final Color accentColor;
|
final Color accentColor;
|
||||||
final double displayValue;
|
final String valueText;
|
||||||
|
final String? subText;
|
||||||
final String unit;
|
final String unit;
|
||||||
final String label;
|
final String label;
|
||||||
|
final Color foreground;
|
||||||
|
final Color muted;
|
||||||
|
final Color track;
|
||||||
|
final Color background;
|
||||||
|
|
||||||
static const double _startAngle = 135 * pi / 180; // 7:30 position
|
static const double _startAngle = 135 * pi / 180; // 7:30 position
|
||||||
static const double _sweepAngle = 270 * pi / 180; // 270° sweep
|
static const double _sweepAngle = 270 * pi / 180; // 270° sweep
|
||||||
@ -94,9 +121,14 @@ class _GaugePainter extends CustomPainter {
|
|||||||
_GaugePainter({
|
_GaugePainter({
|
||||||
required this.normalizedValue,
|
required this.normalizedValue,
|
||||||
required this.accentColor,
|
required this.accentColor,
|
||||||
required this.displayValue,
|
required this.valueText,
|
||||||
|
required this.subText,
|
||||||
required this.unit,
|
required this.unit,
|
||||||
required this.label,
|
required this.label,
|
||||||
|
required this.foreground,
|
||||||
|
required this.muted,
|
||||||
|
required this.track,
|
||||||
|
required this.background,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -107,7 +139,7 @@ class _GaugePainter extends CustomPainter {
|
|||||||
|
|
||||||
// ── Background arc ──────────────────────────────────────────────
|
// ── Background arc ──────────────────────────────────────────────
|
||||||
final bgPaint = Paint()
|
final bgPaint = Paint()
|
||||||
..color = const Color(0xFF2A2A2A)
|
..color = track
|
||||||
..style = PaintingStyle.stroke
|
..style = PaintingStyle.stroke
|
||||||
..strokeWidth = size.width * 0.06
|
..strokeWidth = size.width * 0.06
|
||||||
..strokeCap = StrokeCap.round;
|
..strokeCap = StrokeCap.round;
|
||||||
@ -156,11 +188,11 @@ class _GaugePainter extends CustomPainter {
|
|||||||
|
|
||||||
void _drawTicks(Canvas canvas, Offset center, double radius, Size size) {
|
void _drawTicks(Canvas canvas, Offset center, double radius, Size size) {
|
||||||
final majorPaint = Paint()
|
final majorPaint = Paint()
|
||||||
..color = Colors.white54
|
..color = muted
|
||||||
..strokeWidth = size.width * 0.012
|
..strokeWidth = size.width * 0.012
|
||||||
..strokeCap = StrokeCap.round;
|
..strokeCap = StrokeCap.round;
|
||||||
final minorPaint = Paint()
|
final minorPaint = Paint()
|
||||||
..color = Colors.white24
|
..color = muted.withAlpha(90)
|
||||||
..strokeWidth = size.width * 0.006
|
..strokeWidth = size.width * 0.006
|
||||||
..strokeCap = StrokeCap.round;
|
..strokeCap = StrokeCap.round;
|
||||||
|
|
||||||
@ -186,7 +218,7 @@ class _GaugePainter extends CustomPainter {
|
|||||||
final tailR = size.width * 0.08;
|
final tailR = size.width * 0.08;
|
||||||
|
|
||||||
final needlePaint = Paint()
|
final needlePaint = Paint()
|
||||||
..color = Colors.white
|
..color = foreground
|
||||||
..strokeWidth = size.width * 0.018
|
..strokeWidth = size.width * 0.018
|
||||||
..strokeCap = StrokeCap.round;
|
..strokeCap = StrokeCap.round;
|
||||||
|
|
||||||
@ -200,21 +232,21 @@ class _GaugePainter extends CustomPainter {
|
|||||||
canvas.drawCircle(
|
canvas.drawCircle(
|
||||||
center,
|
center,
|
||||||
size.width * 0.04,
|
size.width * 0.04,
|
||||||
Paint()..color = Colors.white,
|
Paint()..color = foreground,
|
||||||
);
|
);
|
||||||
canvas.drawCircle(
|
canvas.drawCircle(
|
||||||
center,
|
center,
|
||||||
size.width * 0.025,
|
size.width * 0.025,
|
||||||
Paint()..color = const Color(0xFF0A0A0A),
|
Paint()..color = background,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _drawCenterText(Canvas canvas, Offset center, Size size) {
|
void _drawCenterText(Canvas canvas, Offset center, Size size) {
|
||||||
// Value
|
// Value
|
||||||
final valSpan = TextSpan(
|
final valSpan = TextSpan(
|
||||||
text: displayValue.toStringAsFixed(0),
|
text: valueText,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white,
|
color: foreground,
|
||||||
fontSize: size.width * 0.14,
|
fontSize: size.width * 0.14,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
letterSpacing: -1,
|
letterSpacing: -1,
|
||||||
@ -237,7 +269,7 @@ class _GaugePainter extends CustomPainter {
|
|||||||
final unitSpan = TextSpan(
|
final unitSpan = TextSpan(
|
||||||
text: unit,
|
text: unit,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white54,
|
color: muted,
|
||||||
fontSize: size.width * 0.07,
|
fontSize: size.width * 0.07,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@ -253,11 +285,59 @@ class _GaugePainter extends CustomPainter {
|
|||||||
size.width * 0.1 + valPainter.height + 2,
|
size.width * 0.1 + valPainter.height + 2,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Label above the hub, e.g. "RPM"
|
||||||
|
final labelPainter = TextPainter(
|
||||||
|
text: TextSpan(
|
||||||
|
text: label,
|
||||||
|
style: TextStyle(
|
||||||
|
color: muted,
|
||||||
|
fontSize: size.width * 0.055,
|
||||||
|
letterSpacing: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
textDirection: TextDirection.ltr,
|
||||||
|
)..layout();
|
||||||
|
labelPainter.paint(
|
||||||
|
canvas,
|
||||||
|
center + Offset(-labelPainter.width / 2, -size.width * 0.2),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sub sensor line under the unit
|
||||||
|
final sub = subText;
|
||||||
|
if (sub != null) {
|
||||||
|
final subPainter = TextPainter(
|
||||||
|
text: TextSpan(
|
||||||
|
text: sub,
|
||||||
|
style: TextStyle(
|
||||||
|
color: accentColor,
|
||||||
|
fontSize: size.width * 0.075,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
textDirection: TextDirection.ltr,
|
||||||
|
)..layout();
|
||||||
|
subPainter.paint(
|
||||||
|
canvas,
|
||||||
|
center +
|
||||||
|
Offset(
|
||||||
|
-subPainter.width / 2,
|
||||||
|
size.width * 0.1 +
|
||||||
|
valPainter.height +
|
||||||
|
unitPainter.height +
|
||||||
|
size.width * 0.02,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool shouldRepaint(_GaugePainter old) =>
|
bool shouldRepaint(_GaugePainter old) =>
|
||||||
old.normalizedValue != normalizedValue ||
|
old.normalizedValue != normalizedValue ||
|
||||||
old.accentColor != accentColor ||
|
old.accentColor != accentColor ||
|
||||||
old.displayValue != displayValue;
|
old.valueText != valueText ||
|
||||||
|
old.subText != subText ||
|
||||||
|
old.label != label ||
|
||||||
|
old.unit != unit ||
|
||||||
|
old.foreground != foreground;
|
||||||
}
|
}
|
||||||
|
|||||||
95
lib/ui/screens/dashboard/widgets/dashboard_common.dart
Normal file
95
lib/ui/screens/dashboard/widgets/dashboard_common.dart
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../../../core/models/sensor_defs.dart';
|
||||||
|
import '../../../../core/models/units.dart';
|
||||||
|
import '../../../../core/protocol/sensor_state.dart';
|
||||||
|
import '../../../../core/providers/settings_provider.dart';
|
||||||
|
import '../../../theme/app_colors.dart';
|
||||||
|
import '../dashboard_slots_provider.dart';
|
||||||
|
import 'analog_gauge.dart';
|
||||||
|
import 'sensor_picker_sheet.dart';
|
||||||
|
|
||||||
|
/// Gauge for the selectable main sensor with the sub sensor as a second
|
||||||
|
/// line. Long-press to change either.
|
||||||
|
class MainGauge extends ConsumerWidget {
|
||||||
|
final SensorState state;
|
||||||
|
|
||||||
|
const MainGauge({super.key, required this.state});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final c = AppColors.of(context);
|
||||||
|
final config = ref.watch(dashboardConfigProvider);
|
||||||
|
final units = ref.watch(unitPrefsProvider);
|
||||||
|
final main = sensorView(config.main, units);
|
||||||
|
final sub = sensorView(config.sub, units);
|
||||||
|
final mainBase = sensorValue(state, config.main);
|
||||||
|
final subBase = sensorValue(state, config.sub);
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onLongPress: () => _editGauge(context, ref),
|
||||||
|
child: AnalogGauge(
|
||||||
|
value: main.convert(mainBase),
|
||||||
|
minValue: main.min,
|
||||||
|
maxValue: main.max,
|
||||||
|
label: main.name.toUpperCase(),
|
||||||
|
unit: main.unit,
|
||||||
|
valueText: main.format(mainBase),
|
||||||
|
subText: '${sub.format(subBase)} ${sub.unit}'.trim(),
|
||||||
|
accentColor: c.accent,
|
||||||
|
foreground: c.onSurface,
|
||||||
|
muted: c.onSurfaceMuted,
|
||||||
|
track: c.track,
|
||||||
|
background: c.background,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _editGauge(BuildContext context, WidgetRef ref) async {
|
||||||
|
final which = await showPickerSheet(
|
||||||
|
context,
|
||||||
|
title: 'GAUGE',
|
||||||
|
currentId: '',
|
||||||
|
items: const [
|
||||||
|
PickerItem('main', 'Change main sensor'),
|
||||||
|
PickerItem('sub', 'Change sub sensor'),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
if (which == null || !context.mounted) return;
|
||||||
|
final config = ref.read(dashboardConfigProvider);
|
||||||
|
final id = await pickSensor(
|
||||||
|
context,
|
||||||
|
title: which == 'main' ? 'MAIN SENSOR' : 'SUB SENSOR',
|
||||||
|
currentId: which == 'main' ? config.main : config.sub,
|
||||||
|
units: ref.read(unitPrefsProvider),
|
||||||
|
);
|
||||||
|
if (id == null) return;
|
||||||
|
final n = ref.read(dashboardConfigProvider.notifier);
|
||||||
|
which == 'main' ? n.setMain(id) : n.setSub(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> editDashboardValue(
|
||||||
|
BuildContext context, WidgetRef ref, int index) async {
|
||||||
|
final id = await pickSensor(
|
||||||
|
context,
|
||||||
|
title: 'SELECT SENSOR',
|
||||||
|
currentId: ref.read(dashboardConfigProvider).values[index],
|
||||||
|
units: ref.read(unitPrefsProvider),
|
||||||
|
);
|
||||||
|
if (id != null) {
|
||||||
|
ref.read(dashboardConfigProvider.notifier).setValue(index, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> editDashboardFlag(
|
||||||
|
BuildContext context, WidgetRef ref, int index) async {
|
||||||
|
final id = await pickFlag(
|
||||||
|
context,
|
||||||
|
currentId: ref.read(dashboardConfigProvider).flags[index],
|
||||||
|
);
|
||||||
|
if (id != null) {
|
||||||
|
ref.read(dashboardConfigProvider.notifier).setFlag(index, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,31 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../../../core/models/sensor_defs.dart';
|
||||||
import '../../../../core/protocol/sensor_state.dart';
|
import '../../../../core/protocol/sensor_state.dart';
|
||||||
|
import '../../../theme/app_colors.dart';
|
||||||
|
|
||||||
|
/// Colour of an active flag. Faults red, warnings amber, status green/blue.
|
||||||
|
Color flagColor(String id) {
|
||||||
|
switch (id) {
|
||||||
|
case 'mil':
|
||||||
|
case 'knock':
|
||||||
|
case 'o2error':
|
||||||
|
case 'iaterror':
|
||||||
|
case 'ecterror':
|
||||||
|
return Colors.red;
|
||||||
|
case 'revlimit':
|
||||||
|
case 'igncut':
|
||||||
|
case 'boostcut':
|
||||||
|
case 'launch':
|
||||||
|
case 'launchretard':
|
||||||
|
case 'shiftcut':
|
||||||
|
return Colors.orange;
|
||||||
|
case 'vtec':
|
||||||
|
case 'vtp':
|
||||||
|
return Colors.blue;
|
||||||
|
default:
|
||||||
|
return Colors.green;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class FlagPill extends StatelessWidget {
|
class FlagPill extends StatelessWidget {
|
||||||
final String label;
|
final String label;
|
||||||
@ -15,51 +41,65 @@ class FlagPill extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final c = AppColors.of(context);
|
||||||
return AnimatedContainer(
|
return AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 100),
|
duration: const Duration(milliseconds: 100),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
height: 30,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: active ? color.withAlpha(40) : const Color(0xFF1A1A1A),
|
color: active ? color.withAlpha(200) : c.surface,
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(6),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: active ? color : Colors.white12,
|
color: active ? color : c.accent.withAlpha(90),
|
||||||
width: 1.5,
|
width: 1.2,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: FittedBox(
|
||||||
label,
|
fit: BoxFit.scaleDown,
|
||||||
style: TextStyle(
|
child: Text(
|
||||||
color: active ? color : Colors.grey,
|
label,
|
||||||
fontSize: 11,
|
style: TextStyle(
|
||||||
fontWeight: active ? FontWeight.bold : FontWeight.normal,
|
color: active ? Colors.white : c.onSurfaceMuted,
|
||||||
letterSpacing: 1,
|
fontSize: 12,
|
||||||
|
fontWeight: active ? FontWeight.bold : FontWeight.normal,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Row of all flag pills derived from a SensorState.
|
/// The 4 selectable flags of the dashboard. Long-press a pill to change it.
|
||||||
class FlagRow extends StatelessWidget {
|
class FlagRow extends StatelessWidget {
|
||||||
final SensorState state;
|
final SensorState state;
|
||||||
final Color accentColor;
|
final List<String> flagIds;
|
||||||
|
final void Function(int index) onLongPress;
|
||||||
|
|
||||||
const FlagRow({super.key, required this.state, required this.accentColor});
|
const FlagRow({
|
||||||
|
super.key,
|
||||||
|
required this.state,
|
||||||
|
required this.flagIds,
|
||||||
|
required this.onLongPress,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Wrap(
|
return Row(
|
||||||
spacing: 6,
|
|
||||||
runSpacing: 6,
|
|
||||||
alignment: WrapAlignment.center,
|
|
||||||
children: [
|
children: [
|
||||||
FlagPill(label: 'MIL', active: state.mil, color: Colors.amber),
|
for (var i = 0; i < flagIds.length; i++) ...[
|
||||||
FlagPill(label: 'VTEC', active: state.vtec, color: Colors.blue),
|
if (i > 0) const SizedBox(width: 6),
|
||||||
FlagPill(label: 'KNOCK', active: state.knock, color: Colors.red),
|
Expanded(
|
||||||
FlagPill(label: 'FUEL CUT', active: state.fuelCut, color: Colors.red),
|
child: GestureDetector(
|
||||||
FlagPill(label: 'REV LIM', active: state.revLimit, color: Colors.orange),
|
onLongPress: () => onLongPress(i),
|
||||||
FlagPill(label: 'FAN', active: state.fanOut, color: Colors.green),
|
child: FlagPill(
|
||||||
FlagPill(label: 'LAUNCH', active: state.launch, color: Colors.purple),
|
label: flagDef(flagIds[i]).displayName,
|
||||||
|
active: flagDef(flagIds[i]).read(state),
|
||||||
|
color: flagColor(flagIds[i]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,18 +1,77 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../../../core/models/sensor_defs.dart';
|
import '../../../../core/models/sensor_defs.dart';
|
||||||
|
import '../../../../core/models/units.dart';
|
||||||
|
import '../../../theme/app_colors.dart';
|
||||||
|
|
||||||
class SensorPickerSheet extends StatelessWidget {
|
/// One choosable entry in [showPickerSheet].
|
||||||
|
class PickerItem {
|
||||||
|
final String id;
|
||||||
|
final String title;
|
||||||
|
final String? subtitle;
|
||||||
|
const PickerItem(this.id, this.title, [this.subtitle]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bottom sheet listing [items]; resolves to the chosen id, or null.
|
||||||
|
Future<String?> showPickerSheet(
|
||||||
|
BuildContext context, {
|
||||||
|
required String title,
|
||||||
|
required List<PickerItem> items,
|
||||||
|
required String currentId,
|
||||||
|
}) {
|
||||||
|
return showModalBottomSheet<String>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (_) => _PickerSheet(
|
||||||
|
title: title,
|
||||||
|
items: items,
|
||||||
|
currentId: currentId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Picker over every analog sensor, showing the unit currently in use.
|
||||||
|
Future<String?> pickSensor(
|
||||||
|
BuildContext context, {
|
||||||
|
required String title,
|
||||||
|
required String currentId,
|
||||||
|
required UnitPrefs units,
|
||||||
|
}) {
|
||||||
|
return showPickerSheet(
|
||||||
|
context,
|
||||||
|
title: title,
|
||||||
|
currentId: currentId,
|
||||||
|
items: [
|
||||||
|
for (final d in sensorDefs)
|
||||||
|
PickerItem(d.id, d.displayName, sensorView(d.id, units).unit),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Picker over every flag.
|
||||||
|
Future<String?> pickFlag(BuildContext context, {required String currentId}) {
|
||||||
|
return showPickerSheet(
|
||||||
|
context,
|
||||||
|
title: 'SELECT FLAG',
|
||||||
|
currentId: currentId,
|
||||||
|
items: [for (final f in flagDefs) PickerItem(f.id, f.displayName)],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PickerSheet extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final List<PickerItem> items;
|
||||||
final String currentId;
|
final String currentId;
|
||||||
final void Function(String id) onSelected;
|
|
||||||
|
|
||||||
const SensorPickerSheet({
|
const _PickerSheet({
|
||||||
super.key,
|
required this.title,
|
||||||
|
required this.items,
|
||||||
required this.currentId,
|
required this.currentId,
|
||||||
required this.onSelected,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final c = AppColors.of(context);
|
||||||
return DraggableScrollableSheet(
|
return DraggableScrollableSheet(
|
||||||
expand: false,
|
expand: false,
|
||||||
initialChildSize: 0.6,
|
initialChildSize: 0.6,
|
||||||
@ -20,58 +79,61 @@ class SensorPickerSheet extends StatelessWidget {
|
|||||||
maxChildSize: 0.9,
|
maxChildSize: 0.9,
|
||||||
builder: (context, scrollController) {
|
builder: (context, scrollController) {
|
||||||
return Container(
|
return Container(
|
||||||
decoration: const BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Color(0xFF1A1A1A),
|
color: c.surface,
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Container(
|
Container(
|
||||||
width: 40, height: 4,
|
width: 40,
|
||||||
|
height: 4,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white24,
|
color: c.onSurfaceMuted.withAlpha(90),
|
||||||
borderRadius: BorderRadius.circular(2),
|
borderRadius: BorderRadius.circular(2),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Padding(
|
Padding(
|
||||||
padding: EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Text(
|
child: Text(
|
||||||
'SELECT SENSOR',
|
title,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white70,
|
color: c.onSurfaceMuted,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
letterSpacing: 2,
|
letterSpacing: 2,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Divider(height: 1, color: Colors.white10),
|
Divider(height: 1, color: c.divider),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
controller: scrollController,
|
controller: scrollController,
|
||||||
itemCount: sensorDefs.length,
|
itemCount: items.length,
|
||||||
itemBuilder: (context, i) {
|
itemBuilder: (context, i) {
|
||||||
final def = sensorDefs[i];
|
final item = items[i];
|
||||||
final isSelected = def.id == currentId;
|
final isSelected = item.id == currentId;
|
||||||
return ListTile(
|
return ListTile(
|
||||||
title: Text(
|
title: Text(
|
||||||
def.displayName,
|
item.title,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: isSelected ? Colors.white : Colors.white70,
|
color: c.onSurface,
|
||||||
fontWeight: isSelected
|
fontWeight:
|
||||||
? FontWeight.bold
|
isSelected ? FontWeight.bold : FontWeight.normal,
|
||||||
: FontWeight.normal,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
subtitle: Text(
|
subtitle: item.subtitle == null
|
||||||
def.unit,
|
? null
|
||||||
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
: Text(
|
||||||
),
|
item.subtitle!,
|
||||||
|
style: TextStyle(
|
||||||
|
color: c.onSurfaceMuted, fontSize: 12),
|
||||||
|
),
|
||||||
trailing: isSelected
|
trailing: isSelected
|
||||||
? const Icon(Icons.check, color: Colors.green, size: 18)
|
? Icon(Icons.check, color: c.accent, size: 18)
|
||||||
: null,
|
: null,
|
||||||
onTap: () => onSelected(def.id),
|
onTap: () => Navigator.pop(context, item.id),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@ -1,128 +1,139 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../../../core/models/sensor_defs.dart';
|
import '../../../../core/models/sensor_defs.dart';
|
||||||
|
import '../../../../core/models/units.dart';
|
||||||
import '../../../../core/protocol/sensor_state.dart';
|
import '../../../../core/protocol/sensor_state.dart';
|
||||||
|
import '../../../theme/app_colors.dart';
|
||||||
|
|
||||||
|
/// One selectable dashboard value: "unit (name)" caption over a large
|
||||||
|
/// number, as in the requirement layouts. Long-press to change the sensor.
|
||||||
class SensorTile extends StatelessWidget {
|
class SensorTile extends StatelessWidget {
|
||||||
final int slotIndex;
|
|
||||||
final String sensorId;
|
final String sensorId;
|
||||||
final SensorState state;
|
final SensorState state;
|
||||||
final Color accentColor;
|
final UnitPrefs units;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onLongPress;
|
||||||
|
|
||||||
const SensorTile({
|
const SensorTile({
|
||||||
super.key,
|
super.key,
|
||||||
required this.slotIndex,
|
|
||||||
required this.sensorId,
|
required this.sensorId,
|
||||||
required this.state,
|
required this.state,
|
||||||
required this.accentColor,
|
required this.units,
|
||||||
required this.onTap,
|
required this.onLongPress,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final def = sensorDefs.firstWhere(
|
final c = AppColors.of(context);
|
||||||
(d) => d.id == sensorId,
|
final view = sensorView(sensorId, units);
|
||||||
orElse: () => sensorDefs.first,
|
final base = sensorValue(state, sensorId);
|
||||||
);
|
final fraction = view.fraction(base);
|
||||||
final value = _getValue(state, sensorId);
|
final caption =
|
||||||
final normalized =
|
view.unit.isEmpty ? view.name : '${view.unit} (${view.name})';
|
||||||
((value - def.min) / (def.max - def.min)).clamp(0.0, 1.0);
|
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onLongPress: onTap,
|
onLongPress: onLongPress,
|
||||||
child: Container(
|
child: LayoutBuilder(
|
||||||
decoration: BoxDecoration(
|
builder: (context, box) {
|
||||||
color: const Color(0xFF1A1A1A),
|
final valueText = Text(
|
||||||
borderRadius: BorderRadius.circular(10),
|
view.format(base),
|
||||||
border: Border.all(color: Colors.white10),
|
style: TextStyle(
|
||||||
),
|
color: fraction > 0.9 ? Colors.red : c.onSurface,
|
||||||
padding: const EdgeInsets.fromLTRB(10, 8, 10, 6),
|
fontSize: 26,
|
||||||
child: Column(
|
fontWeight: FontWeight.bold,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
fontStyle: FontStyle.italic,
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
letterSpacing: -0.5,
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
def.displayName.toUpperCase(),
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.grey,
|
|
||||||
fontSize: 10,
|
|
||||||
letterSpacing: 1,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
Row(
|
);
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
final captionText = Text(
|
||||||
|
caption,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
color: c.onSurfaceMuted,
|
||||||
|
fontSize: 10,
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// Short tiles (5 per side in landscape): caption beside the value
|
||||||
|
// so the number keeps its size.
|
||||||
|
if (box.maxHeight < 56) {
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: c.surface,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: c.divider),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(flex: 4, child: captionText),
|
||||||
|
Expanded(
|
||||||
|
flex: 5,
|
||||||
|
child: FittedBox(
|
||||||
|
fit: BoxFit.scaleDown,
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: valueText,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final showBar = box.maxHeight >= 64;
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: c.surface,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: c.divider),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.fromLTRB(8, 4, 8, 4),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
_format(value, sensorId),
|
caption,
|
||||||
style: const TextStyle(
|
maxLines: 1,
|
||||||
color: Colors.white,
|
overflow: TextOverflow.ellipsis,
|
||||||
fontSize: 22,
|
style: TextStyle(
|
||||||
fontWeight: FontWeight.bold,
|
color: c.onSurfaceMuted,
|
||||||
letterSpacing: -0.5,
|
fontSize: 10,
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 3),
|
Expanded(
|
||||||
Padding(
|
child: FittedBox(
|
||||||
padding: const EdgeInsets.only(bottom: 2),
|
fit: BoxFit.scaleDown,
|
||||||
child: Text(
|
alignment: Alignment.centerRight,
|
||||||
def.unit,
|
child: Text(
|
||||||
style: TextStyle(color: accentColor, fontSize: 11),
|
view.format(base),
|
||||||
|
style: TextStyle(
|
||||||
|
color: fraction > 0.9 ? Colors.red : c.onSurface,
|
||||||
|
fontSize: 26,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
letterSpacing: -0.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (showBar)
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
child: LinearProgressIndicator(
|
||||||
|
value: fraction,
|
||||||
|
minHeight: 3,
|
||||||
|
backgroundColor: c.track,
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(
|
||||||
|
fraction > 0.9 ? Colors.red : c.accent,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -42,7 +42,7 @@ class DatalogListScreen extends ConsumerWidget {
|
|||||||
: ListView.separated(
|
: ListView.separated(
|
||||||
itemCount: sessions.length,
|
itemCount: sessions.length,
|
||||||
separatorBuilder: (_, __) =>
|
separatorBuilder: (_, __) =>
|
||||||
const Divider(height: 1, color: Colors.white10),
|
const Divider(height: 1),
|
||||||
itemBuilder: (context, i) =>
|
itemBuilder: (context, i) =>
|
||||||
_SessionTile(session: sessions[i]),
|
_SessionTile(session: sessions[i]),
|
||||||
),
|
),
|
||||||
@ -115,8 +115,11 @@ class _SessionTile extends ConsumerWidget {
|
|||||||
onTap: () async {
|
onTap: () async {
|
||||||
await ref
|
await ref
|
||||||
.read(playbackProvider.notifier)
|
.read(playbackProvider.notifier)
|
||||||
.loadSession(session.id!);
|
.loadSession(session.id!, session.ecuType);
|
||||||
ref.read(playbackProvider.notifier).play();
|
ref.read(playbackProvider.notifier).play();
|
||||||
|
// Back to the dashboard: the displays now show the recording,
|
||||||
|
// with the playback bar for navigation.
|
||||||
|
if (context.mounted) Navigator.pop(context);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../../core/datalog/datalog_player.dart';
|
import '../../../core/datalog/datalog_player.dart';
|
||||||
import '../../../core/providers/datalog_provider.dart';
|
import '../../../core/providers/datalog_provider.dart';
|
||||||
|
import '../../theme/app_colors.dart';
|
||||||
|
|
||||||
class DatalogPlaybackBar extends ConsumerWidget {
|
class DatalogPlaybackBar extends ConsumerWidget {
|
||||||
final PlaybackState state;
|
final PlaybackState state;
|
||||||
@ -11,9 +12,10 @@ class DatalogPlaybackBar extends ConsumerWidget {
|
|||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final notifier = ref.read(playbackProvider.notifier);
|
final notifier = ref.read(playbackProvider.notifier);
|
||||||
final isPlaying = state.status == PlaybackStatus.playing;
|
final isPlaying = state.status == PlaybackStatus.playing;
|
||||||
|
final c = AppColors.of(context);
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
color: const Color(0xFF1A1A1A),
|
color: c.surface,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@ -41,7 +43,7 @@ class DatalogPlaybackBar extends ConsumerWidget {
|
|||||||
Text(
|
Text(
|
||||||
'${state.position} / ${state.total}',
|
'${state.position} / ${state.total}',
|
||||||
style:
|
style:
|
||||||
const TextStyle(color: Colors.grey, fontSize: 11),
|
TextStyle(color: c.onSurfaceMuted, fontSize: 11),
|
||||||
),
|
),
|
||||||
// Play / Pause / Stop
|
// Play / Pause / Stop
|
||||||
Row(
|
Row(
|
||||||
@ -69,8 +71,7 @@ class DatalogPlaybackBar extends ConsumerWidget {
|
|||||||
value: state.speed,
|
value: state.speed,
|
||||||
isDense: true,
|
isDense: true,
|
||||||
underline: const SizedBox(),
|
underline: const SizedBox(),
|
||||||
style: const TextStyle(
|
style: TextStyle(color: c.onSurface, fontSize: 12),
|
||||||
color: Colors.white70, fontSize: 12),
|
|
||||||
items: const [
|
items: const [
|
||||||
DropdownMenuItem(value: 0.5, child: Text('0.5×')),
|
DropdownMenuItem(value: 0.5, child: Text('0.5×')),
|
||||||
DropdownMenuItem(value: 1.0, child: Text('1×')),
|
DropdownMenuItem(value: 1.0, child: Text('1×')),
|
||||||
|
|||||||
@ -3,17 +3,41 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../../../core/bluetooth/bt_poller.dart';
|
import '../../../core/bluetooth/bt_poller.dart';
|
||||||
|
import '../../../core/logging/app_log.dart';
|
||||||
import '../../../core/providers/bt_provider.dart';
|
import '../../../core/providers/bt_provider.dart';
|
||||||
import '../../../core/providers/sensor_provider.dart';
|
import '../../../core/providers/sensor_provider.dart';
|
||||||
import '../../../core/providers/settings_provider.dart';
|
import '../../../core/providers/settings_provider.dart';
|
||||||
import '../../../core/protocol/neg8.dart';
|
import '../../../core/protocol/neg8.dart';
|
||||||
import '../../../core/protocol/sensor_state.dart';
|
import '../../../core/protocol/sensor_state.dart';
|
||||||
|
|
||||||
class RawFrameDebugScreen extends ConsumerWidget {
|
class RawFrameDebugScreen extends ConsumerStatefulWidget {
|
||||||
const RawFrameDebugScreen({super.key});
|
const RawFrameDebugScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
ConsumerState<RawFrameDebugScreen> createState() =>
|
||||||
|
_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 bt = ref.watch(btProvider);
|
||||||
final settings = ref.watch(settingsProvider);
|
final settings = ref.watch(settingsProvider);
|
||||||
final sensorAsync = ref.watch(sensorStateProvider);
|
final sensorAsync = ref.watch(sensorStateProvider);
|
||||||
@ -26,6 +50,20 @@ class RawFrameDebugScreen extends ConsumerWidget {
|
|||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('ECU Frame Debug'),
|
title: const Text('ECU Frame Debug'),
|
||||||
actions: [
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Copy app log',
|
||||||
|
icon: const Icon(Icons.description_outlined),
|
||||||
|
onPressed: () async {
|
||||||
|
await Clipboard.setData(ClipboardData(text: AppLog.dump()));
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'App log copied (${AppLog.length} lines) — paste it into an email/message')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'Copy raw frame',
|
tooltip: 'Copy raw frame',
|
||||||
icon: const Icon(Icons.copy),
|
icon: const Icon(Icons.copy),
|
||||||
@ -49,8 +87,18 @@ class RawFrameDebugScreen extends ConsumerWidget {
|
|||||||
title: 'Connection',
|
title: 'Connection',
|
||||||
rows: [
|
rows: [
|
||||||
_RowData('Protocol', settings.ecuType.name.toUpperCase()),
|
_RowData('Protocol', settings.ecuType.name.toUpperCase()),
|
||||||
_RowData('Frames (raw, from poller)', bt.frameCount.toString()),
|
_RowData('Link status', bt.status.name),
|
||||||
|
_RowData('ECU data', bt.dataStatus.name),
|
||||||
|
_RowData('Poll interval', '${settings.pollingInterval.inMilliseconds} ms'),
|
||||||
|
_RowData('Requests sent', bt.requestsSent.toString()),
|
||||||
|
_RowData('Valid replies', bt.frameCount.toString()),
|
||||||
|
_RowData(' live ECU frames', bt.liveFrames.toString()),
|
||||||
|
_RowData(' adapter default frames', bt.defaultFrames.toString()),
|
||||||
|
_RowData(' RPM glitch frames dropped', bt.rpmGlitches.toString()),
|
||||||
|
_RowData('Timeouts (no reply)', bt.timeouts.toString()),
|
||||||
_RowData('Dropped (bad checksum)', bt.droppedFrames.toString()),
|
_RowData('Dropped (bad checksum)', bt.droppedFrames.toString()),
|
||||||
|
_RowData('Last reply time', bt.lastResponseMs == null ? '-' : '${bt.lastResponseMs} ms'),
|
||||||
|
_RowData('Auto-reconnects', bt.reconnects.toString()),
|
||||||
_RowData('Last frame bytes', frame?.length.toString() ?? 'none'),
|
_RowData('Last frame bytes', frame?.length.toString() ?? 'none'),
|
||||||
if (frame != null)
|
if (frame != null)
|
||||||
_RowData(
|
_RowData(
|
||||||
@ -60,6 +108,13 @@ class RawFrameDebugScreen extends ConsumerWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
_ProtocolProbeCard(
|
||||||
|
connected: bt.isConnected,
|
||||||
|
probing: _probing,
|
||||||
|
result: _probeResult,
|
||||||
|
onProbe: _runProbe,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
_Section(
|
_Section(
|
||||||
title: 'Decode Pipeline',
|
title: 'Decode Pipeline',
|
||||||
rows: [
|
rows: [
|
||||||
@ -121,6 +176,13 @@ class RawFrameDebugScreen extends ConsumerWidget {
|
|||||||
_RowData('Battery', s.bat.toStringAsFixed(2)),
|
_RowData('Battery', s.bat.toStringAsFixed(2)),
|
||||||
_RowData('O2', s.o2.toStringAsFixed(2)),
|
_RowData('O2', s.o2.toStringAsFixed(2)),
|
||||||
_RowData('AFR', s.afr.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) {
|
List<_RowData> _s300CandidateRows(Uint8List f) {
|
||||||
@ -138,21 +200,31 @@ class RawFrameDebugScreen extends ConsumerWidget {
|
|||||||
_RowData('TPS raw @9', f[9].toString()),
|
_RowData('TPS raw @9', f[9].toString()),
|
||||||
_RowData('TPS current', _s300Tps(f[9]).toStringAsFixed(2)),
|
_RowData('TPS current', _s300Tps(f[9]).toStringAsFixed(2)),
|
||||||
_RowData('Gear raw @27 (decimal)', f[27].toString()),
|
_RowData('Gear raw @27 (decimal)', f[27].toString()),
|
||||||
_RowData('INJ raw @10 LE (offset unverified)', _u16le(f, 10).toString()),
|
_RowData('INJ raw @10 LE', _u16le(f, 10).toString()),
|
||||||
_RowData('IGN raw @12 (offset unverified)', f[12].toString()),
|
_RowData('IGN raw @12', f[12].toString()),
|
||||||
_RowData('O2 current raw (offset unverified)', f[16].toString()),
|
_RowData('O2 raw @16', f[16].toString()),
|
||||||
_RowData('ECT raw @45', f[0x2D].toString()),
|
_RowData('Strim raw @29 / Ltrim raw @31', '${f[29]} / ${f[31]}'),
|
||||||
_RowData('IAT raw @46', f[0x2E].toString()),
|
_RowData('PA raw @32', f[32].toString()),
|
||||||
_RowData('BAT raw @48 (offset unverified)', f[0x30].toString()),
|
_RowData('ECT raw @33', f[33].toString()),
|
||||||
_RowData('PA raw @44 (offset unverified)', f[0x2C].toString()),
|
_RowData('IAT raw @34', f[34].toString()),
|
||||||
_RowData('ERR bytes @49-52',
|
_RowData('BAT raw @36', f[36].toString()),
|
||||||
'${_b(f, 0x31)} ${_b(f, 0x32)} ${_b(f, 0x33)} ${_b(f, 0x34)}'),
|
_RowData('ERR00-03 @37-40',
|
||||||
|
'${_b(f, 37)} ${_b(f, 38)} ${_b(f, 39)} ${_b(f, 40)}'),
|
||||||
|
_RowData('Eth raw @41', f[41].toString()),
|
||||||
|
_RowData('AF raw @55', f[55].toString()),
|
||||||
|
_RowData('SW1 @17 (bit7..0)', '${_b(f, 17)} ${_bin(f, 17)}'),
|
||||||
|
_RowData('SW2 @18 (bit7..0)', '${_b(f, 18)} ${_bin(f, 18)}'),
|
||||||
|
_RowData('SW3 @19 (bit7..0)', '${_b(f, 19)} ${_bin(f, 19)}'),
|
||||||
|
_RowData('SW5 @49 (bit7..0)', '${_b(f, 49)} ${_bin(f, 49)}'),
|
||||||
|
_RowData('SW6 @50 (bit7..0)', '${_b(f, 50)} ${_bin(f, 50)}'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
static int _u16le(Uint8List f, int o) => f[o] | (f[o + 1] << 8);
|
static int _u16le(Uint8List f, int o) => f[o] | (f[o + 1] << 8);
|
||||||
static String _b(Uint8List f, int o) =>
|
static String _b(Uint8List f, int o) =>
|
||||||
f[o].toRadixString(16).padLeft(2, '0');
|
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) {
|
static double _s300Vss(int raw) {
|
||||||
return (raw < 893 || raw == 0xFFFF) ? 0.0 : 228480.0 / raw;
|
return (raw < 893 || raw == 0xFFFF) ? 0.0 : 228480.0 / raw;
|
||||||
@ -176,6 +248,88 @@ class RawFrameDebugScreen extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
class _Section extends StatelessWidget {
|
||||||
final String title;
|
final String title;
|
||||||
final List<_RowData> rows;
|
final List<_RowData> rows;
|
||||||
|
|||||||
@ -1,18 +1,146 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../../core/protocol/dtc_map.dart';
|
import '../../../core/protocol/dtc_map.dart';
|
||||||
|
import '../../../core/providers/bt_provider.dart';
|
||||||
import '../../../core/providers/sensor_provider.dart';
|
import '../../../core/providers/sensor_provider.dart';
|
||||||
import '../../../core/providers/settings_provider.dart';
|
import '../../../core/providers/datalog_provider.dart';
|
||||||
|
import '../../theme/app_colors.dart';
|
||||||
import '../../../core/bluetooth/bt_poller.dart';
|
import '../../../core/bluetooth/bt_poller.dart';
|
||||||
import 'widgets/dtc_row.dart';
|
import 'widgets/dtc_row.dart';
|
||||||
|
|
||||||
class DtcScreen extends ConsumerWidget {
|
class DtcScreen extends ConsumerStatefulWidget {
|
||||||
const DtcScreen({super.key});
|
const DtcScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
ConsumerState<DtcScreen> createState() => _DtcScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DtcScreenState extends ConsumerState<DtcScreen> {
|
||||||
|
bool _clearing = false;
|
||||||
|
Uint8List? _lastRaw;
|
||||||
|
|
||||||
|
String _hex(Uint8List bytes) {
|
||||||
|
final parts = <String>[];
|
||||||
|
for (var i = 0; i < bytes.length; i++) {
|
||||||
|
if (i > 0 && i % 16 == 0) parts.add('\n');
|
||||||
|
parts.add(bytes[i].toRadixString(16).padLeft(2, '0'));
|
||||||
|
}
|
||||||
|
return parts.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showRawCapture() {
|
||||||
|
final raw = _lastRaw;
|
||||||
|
if (raw == null) return;
|
||||||
|
final hex = _hex(raw);
|
||||||
|
showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: Text('Raw capture (${raw.length} bytes)'),
|
||||||
|
content: SizedBox(
|
||||||
|
width: double.maxFinite,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: SelectableText(
|
||||||
|
hex,
|
||||||
|
style: const TextStyle(fontFamily: 'monospace', fontSize: 11),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () async {
|
||||||
|
await Clipboard.setData(ClipboardData(text: hex));
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Copied')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: const Text('Copy'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Close'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _confirmAndClear() async {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Clear Trouble Codes'),
|
||||||
|
content: const Text(
|
||||||
|
'This sends a reset command to the ECU and erases all stored '
|
||||||
|
'fault codes. This cannot be undone.',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, false),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, true),
|
||||||
|
child: const Text('Clear',
|
||||||
|
style: TextStyle(color: Colors.red)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirmed != true) return;
|
||||||
|
|
||||||
|
setState(() => _clearing = true);
|
||||||
|
final result = await ref.read(btProvider.notifier).clearDtcs();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_clearing = false;
|
||||||
|
_lastRaw = result.raw;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Success is judged from the live data: the adapter does not send the
|
||||||
|
// ack. Codes for faults that are still present return right away.
|
||||||
|
final String message;
|
||||||
|
if (result.codesBefore == 0) {
|
||||||
|
message = 'No stored trouble codes to clear';
|
||||||
|
} else if (result.codesCleared > 0) {
|
||||||
|
final cleared = result.codesCleared;
|
||||||
|
final back = result.codesBefore - cleared;
|
||||||
|
message = back == 0
|
||||||
|
? 'Trouble codes cleared'
|
||||||
|
: '$cleared of ${result.codesBefore} codes cleared — $back came '
|
||||||
|
'straight back (active fault, fix the cause first)';
|
||||||
|
} else if (result.acknowledged) {
|
||||||
|
message = 'ECU confirmed the reset, but every code came straight back '
|
||||||
|
'(active faults)';
|
||||||
|
} else if (result.raw.isEmpty) {
|
||||||
|
// Can't tell apart from "every fault re-set before the next frame".
|
||||||
|
message = 'No codes cleared — the ECU ignored the reset, or every '
|
||||||
|
'fault is still active';
|
||||||
|
} else {
|
||||||
|
// "View" opens the raw reply hex to check by hand.
|
||||||
|
message = 'Unexpected reply (${result.raw.length} bytes) — codes did '
|
||||||
|
'not clear';
|
||||||
|
}
|
||||||
|
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(message),
|
||||||
|
action: (!result.acknowledged && result.raw.isNotEmpty)
|
||||||
|
? SnackBarAction(label: 'View', onPressed: _showRawCapture)
|
||||||
|
: null,
|
||||||
|
duration: const Duration(seconds: 6),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
final state = ref.watch(latestSensorProvider);
|
final state = ref.watch(latestSensorProvider);
|
||||||
final ecuType = ref.watch(settingsProvider).ecuType;
|
final bt = ref.watch(btProvider);
|
||||||
|
final ecuType = ref.watch(effectiveEcuTypeProvider);
|
||||||
|
final c = AppColors.of(context);
|
||||||
|
|
||||||
final dtcMap =
|
final dtcMap =
|
||||||
ecuType == EcuType.s300 ? s300DtcMap : kproDtcMap;
|
ecuType == EcuType.s300 ? s300DtcMap : kproDtcMap;
|
||||||
@ -22,7 +150,7 @@ class DtcScreen extends ConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
// Header bar
|
// Header bar
|
||||||
Container(
|
Container(
|
||||||
color: const Color(0xFF1A1A1A),
|
color: c.surface,
|
||||||
padding:
|
padding:
|
||||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||||
child: Row(
|
child: Row(
|
||||||
@ -49,12 +177,12 @@ class DtcScreen extends ConsumerWidget {
|
|||||||
Text(
|
Text(
|
||||||
ecuType == EcuType.s300 ? 'S300' : 'KPro',
|
ecuType == EcuType.s300 ? 'S300' : 'KPro',
|
||||||
style:
|
style:
|
||||||
const TextStyle(color: Colors.grey, fontSize: 12),
|
TextStyle(color: c.onSurfaceMuted, fontSize: 12),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Divider(height: 1, color: Colors.white10),
|
Divider(height: 1, color: c.divider),
|
||||||
|
|
||||||
// DTC list
|
// DTC list
|
||||||
Expanded(
|
Expanded(
|
||||||
@ -75,7 +203,7 @@ class DtcScreen extends ConsumerWidget {
|
|||||||
: ListView.separated(
|
: ListView.separated(
|
||||||
itemCount: active.length,
|
itemCount: active.length,
|
||||||
separatorBuilder: (_, __) =>
|
separatorBuilder: (_, __) =>
|
||||||
const Divider(height: 1, color: Colors.white10),
|
Divider(height: 1, color: c.divider),
|
||||||
itemBuilder: (context, i) {
|
itemBuilder: (context, i) {
|
||||||
final full = active[i]; // "P0130 — O2 Sensor (front)"
|
final full = active[i]; // "P0130 — O2 Sensor (front)"
|
||||||
final parts = full.split(' — ');
|
final parts = full.split(' — ');
|
||||||
@ -86,6 +214,29 @@ class DtcScreen extends ConsumerWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
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,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../../theme/app_colors.dart';
|
||||||
|
|
||||||
class DtcRow extends StatelessWidget {
|
class DtcRow extends StatelessWidget {
|
||||||
final String code;
|
final String code;
|
||||||
@ -34,7 +35,8 @@ class DtcRow extends StatelessWidget {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
description,
|
description,
|
||||||
style: const TextStyle(color: Colors.white70, fontSize: 13),
|
style: TextStyle(
|
||||||
|
color: AppColors.of(context).onSurface, fontSize: 13),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@ -1,15 +1,16 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../../core/models/sensor_defs.dart';
|
import '../../../core/models/units.dart';
|
||||||
import '../../../core/providers/sensor_history_provider.dart';
|
import '../../../core/providers/sensor_history_provider.dart';
|
||||||
import '../../../core/providers/theme_provider.dart';
|
import '../../../core/providers/settings_provider.dart';
|
||||||
import '../../../ui/theme/app_colors.dart';
|
import '../../../ui/theme/app_colors.dart';
|
||||||
|
import '../dashboard/widgets/sensor_picker_sheet.dart';
|
||||||
import 'widgets/graph_time_selector.dart';
|
import 'widgets/graph_time_selector.dart';
|
||||||
import 'widgets/sensor_graph_panel.dart';
|
import 'widgets/sensor_graph_panel.dart';
|
||||||
|
|
||||||
final _graphWindowProvider = StateProvider<int>((ref) => 60);
|
final _graphWindowProvider = StateProvider<int>((ref) => 60);
|
||||||
final _graphSensorsProvider = StateProvider<List<String>>(
|
final _graphSensorsProvider = StateProvider<List<String>>(
|
||||||
(ref) => ['rpm', 'map', 'tps'],
|
(ref) => ['rpm', 'map', 'tps', 'ect'],
|
||||||
);
|
);
|
||||||
|
|
||||||
class GraphScreen extends ConsumerWidget {
|
class GraphScreen extends ConsumerWidget {
|
||||||
@ -18,7 +19,8 @@ class GraphScreen extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final history = ref.watch(sensorHistoryProvider);
|
final history = ref.watch(sensorHistoryProvider);
|
||||||
final colors = AppColors.forTheme(ref.watch(themeProvider));
|
final colors = AppColors.of(context);
|
||||||
|
final units = ref.watch(unitPrefsProvider);
|
||||||
final window = ref.watch(_graphWindowProvider);
|
final window = ref.watch(_graphWindowProvider);
|
||||||
final sensors = ref.watch(_graphSensorsProvider);
|
final sensors = ref.watch(_graphSensorsProvider);
|
||||||
|
|
||||||
@ -26,12 +28,12 @@ class GraphScreen extends ConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
// Time selector bar
|
// Time selector bar
|
||||||
Container(
|
Container(
|
||||||
color: const Color(0xFF1A1A1A),
|
color: colors.surface,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const Text('Window:',
|
Text('Window:',
|
||||||
style: TextStyle(color: Colors.grey, fontSize: 12)),
|
style: TextStyle(color: colors.onSurfaceMuted, fontSize: 12)),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
GraphTimeSelector(
|
GraphTimeSelector(
|
||||||
windowSeconds: window,
|
windowSeconds: window,
|
||||||
@ -59,8 +61,8 @@ class GraphScreen extends ConsumerWidget {
|
|||||||
Icon(Icons.show_chart,
|
Icon(Icons.show_chart,
|
||||||
size: 48, color: colors.accent.withAlpha(80)),
|
size: 48, color: colors.accent.withAlpha(80)),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
const Text('No sensors selected',
|
Text('No sensors selected',
|
||||||
style: TextStyle(color: Colors.grey)),
|
style: TextStyle(color: colors.onSurfaceMuted)),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
ElevatedButton.icon(
|
ElevatedButton.icon(
|
||||||
onPressed: () => _addSensor(context, ref, sensors),
|
onPressed: () => _addSensor(context, ref, sensors),
|
||||||
@ -73,10 +75,9 @@ class GraphScreen extends ConsumerWidget {
|
|||||||
: ListView.builder(
|
: ListView.builder(
|
||||||
itemCount: sensors.length,
|
itemCount: sensors.length,
|
||||||
itemBuilder: (context, i) => SensorGraphPanel(
|
itemBuilder: (context, i) => SensorGraphPanel(
|
||||||
sensorId: sensors[i],
|
view: sensorView(sensors[i], units),
|
||||||
history: history,
|
history: history,
|
||||||
windowSeconds: window,
|
windowSeconds: window,
|
||||||
accentColor: colors.accent,
|
|
||||||
onRemove: () {
|
onRemove: () {
|
||||||
final next = List<String>.from(sensors)..removeAt(i);
|
final next = List<String>.from(sensors)..removeAt(i);
|
||||||
ref.read(_graphSensorsProvider.notifier).state = next;
|
ref.read(_graphSensorsProvider.notifier).state = next;
|
||||||
@ -88,49 +89,15 @@ class GraphScreen extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _addSensor(
|
Future<void> _addSensor(
|
||||||
BuildContext context, WidgetRef ref, List<String> current) {
|
BuildContext context, WidgetRef ref, List<String> current) async {
|
||||||
showModalBottomSheet<void>(
|
final id = await pickSensor(
|
||||||
context: context,
|
context,
|
||||||
backgroundColor: const Color(0xFF1A1A1A),
|
title: 'ADD SENSOR TO GRAPH',
|
||||||
shape: const RoundedRectangleBorder(
|
currentId: '',
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
units: ref.read(unitPrefsProvider),
|
||||||
),
|
|
||||||
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);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
if (id == null || current.contains(id)) return;
|
||||||
|
ref.read(_graphSensorsProvider.notifier).state = [...current, id];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -30,13 +30,13 @@ class GraphTimeSelector extends StatelessWidget {
|
|||||||
color: selected ? accent.withAlpha(30) : Colors.transparent,
|
color: selected ? accent.withAlpha(30) : Colors.transparent,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: selected ? accent : Colors.white24,
|
color: selected ? accent : Theme.of(context).dividerColor,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
s >= 60 ? '${s ~/ 60}m' : '${s}s',
|
s >= 60 ? '${s ~/ 60}m' : '${s}s',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: selected ? accent : Colors.white54,
|
color: selected ? accent : Theme.of(context).hintColor,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: selected ? FontWeight.bold : FontWeight.normal,
|
fontWeight: selected ? FontWeight.bold : FontWeight.normal,
|
||||||
),
|
),
|
||||||
|
|||||||
@ -1,133 +1,203 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:fl_chart/fl_chart.dart';
|
import 'package:fl_chart/fl_chart.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../../../core/models/sensor_defs.dart';
|
import '../../../../core/models/units.dart';
|
||||||
import '../../../../core/providers/sensor_history_provider.dart';
|
import '../../../../core/providers/sensor_history_provider.dart';
|
||||||
|
import '../../../theme/app_colors.dart';
|
||||||
|
|
||||||
|
/// One graph (requirements): line over the selected time window with the
|
||||||
|
/// window's max/min top-left, current value and name bottom-left, value
|
||||||
|
/// axis on the right, seconds-ago axis along the bottom, and a touch cursor
|
||||||
|
/// that reads out the value under the finger.
|
||||||
class SensorGraphPanel extends StatelessWidget {
|
class SensorGraphPanel extends StatelessWidget {
|
||||||
final String sensorId;
|
final SensorView view;
|
||||||
final SensorHistory history;
|
final SensorHistory history;
|
||||||
final int windowSeconds;
|
final int windowSeconds;
|
||||||
final Color accentColor;
|
|
||||||
final VoidCallback onRemove;
|
final VoidCallback onRemove;
|
||||||
|
|
||||||
const SensorGraphPanel({
|
const SensorGraphPanel({
|
||||||
super.key,
|
super.key,
|
||||||
required this.sensorId,
|
required this.view,
|
||||||
required this.history,
|
required this.history,
|
||||||
required this.windowSeconds,
|
required this.windowSeconds,
|
||||||
required this.accentColor,
|
|
||||||
required this.onRemove,
|
required this.onRemove,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final def = sensorDefs.firstWhere(
|
final c = AppColors.of(context);
|
||||||
(d) => d.id == sensorId,
|
final raw = history.get(view.def.id);
|
||||||
orElse: () => sensorDefs.first,
|
final now = raw.isEmpty ? 0.0 : raw.last.x;
|
||||||
);
|
// x = seconds before the latest sample (0 at the right edge).
|
||||||
final allSpots = history.get(sensorId);
|
final spots = [
|
||||||
|
for (final s in raw)
|
||||||
|
if (now - s.x <= windowSeconds) FlSpot(s.x - now, view.convert(s.y)),
|
||||||
|
];
|
||||||
|
|
||||||
// Slice to window
|
double? lo, hi;
|
||||||
final spots = _window(allSpots, windowSeconds.toDouble());
|
for (final s in spots) {
|
||||||
|
lo = lo == null ? s.y : math.min(lo, s.y);
|
||||||
|
hi = hi == null ? s.y : math.max(hi, s.y);
|
||||||
|
}
|
||||||
|
// Y axis follows the data in the window (like the reference), with a
|
||||||
|
// margin, and never collapses to zero height for a steady value.
|
||||||
|
var minY = view.min, maxY = view.max;
|
||||||
|
if (lo != null && hi != null) {
|
||||||
|
final pad = math.max((hi - lo) * 0.15, (view.max - view.min) * 0.02);
|
||||||
|
minY = lo - pad;
|
||||||
|
maxY = hi + pad;
|
||||||
|
}
|
||||||
|
final muted = TextStyle(color: c.onSurfaceMuted, fontSize: 11);
|
||||||
|
String f(double v) => v.toStringAsFixed(view.decimals);
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
height: 170,
|
||||||
padding: const EdgeInsets.fromLTRB(8, 10, 12, 8),
|
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
padding: const EdgeInsets.fromLTRB(8, 6, 4, 4),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFF1A1A1A),
|
color: c.surface,
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: c.divider),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Stack(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
children: [
|
||||||
// Header
|
Padding(
|
||||||
Row(
|
padding: const EdgeInsets.only(bottom: 4),
|
||||||
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
|
child: spots.length < 2
|
||||||
? const Center(
|
? Center(child: Text('Waiting for data…', style: muted))
|
||||||
child: Text('Waiting for data…',
|
|
||||||
style: TextStyle(color: Colors.white30,
|
|
||||||
fontSize: 12)))
|
|
||||||
: LineChart(
|
: LineChart(
|
||||||
LineChartData(
|
LineChartData(
|
||||||
|
minX: -windowSeconds.toDouble(),
|
||||||
|
maxX: 0,
|
||||||
|
minY: minY,
|
||||||
|
maxY: maxY,
|
||||||
|
clipData: const FlClipData.all(),
|
||||||
gridData: FlGridData(
|
gridData: FlGridData(
|
||||||
show: true,
|
show: true,
|
||||||
drawVerticalLine: false,
|
getDrawingHorizontalLine: (_) =>
|
||||||
getDrawingHorizontalLine: (_) => FlLine(
|
FlLine(color: c.divider, strokeWidth: 1),
|
||||||
color: Colors.white10,
|
getDrawingVerticalLine: (_) =>
|
||||||
strokeWidth: 1,
|
FlLine(color: c.divider, strokeWidth: 1),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
borderData: FlBorderData(show: false),
|
borderData: FlBorderData(show: false),
|
||||||
titlesData: const FlTitlesData(show: false),
|
titlesData: FlTitlesData(
|
||||||
minY: def.min.toDouble(),
|
topTitles: const AxisTitles(),
|
||||||
maxY: def.max.toDouble(),
|
leftTitles: const AxisTitles(),
|
||||||
|
rightTitles: AxisTitles(
|
||||||
|
sideTitles: SideTitles(
|
||||||
|
showTitles: true,
|
||||||
|
reservedSize: 40,
|
||||||
|
getTitlesWidget: (v, meta) => Text(
|
||||||
|
_compact(v),
|
||||||
|
style: muted.copyWith(fontSize: 9),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
bottomTitles: AxisTitles(
|
||||||
|
sideTitles: SideTitles(
|
||||||
|
showTitles: true,
|
||||||
|
reservedSize: 16,
|
||||||
|
interval: windowSeconds / 6,
|
||||||
|
getTitlesWidget: (v, meta) => Text(
|
||||||
|
(-v).round().toString(),
|
||||||
|
style: muted.copyWith(fontSize: 9),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
lineTouchData: LineTouchData(
|
||||||
|
getTouchedSpotIndicator: (bar, indexes) => [
|
||||||
|
for (final _ in indexes)
|
||||||
|
const TouchedSpotIndicatorData(
|
||||||
|
FlLine(color: Colors.red, strokeWidth: 1.5),
|
||||||
|
FlDotData(show: true),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
touchTooltipData: LineTouchTooltipData(
|
||||||
|
getTooltipColor: (_) => Colors.red,
|
||||||
|
tooltipPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 4, vertical: 2),
|
||||||
|
getTooltipItems: (touched) => [
|
||||||
|
for (final t in touched)
|
||||||
|
LineTooltipItem(
|
||||||
|
f(t.y),
|
||||||
|
const TextStyle(
|
||||||
|
color: Colors.white, fontSize: 11),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
lineBarsData: [
|
lineBarsData: [
|
||||||
LineChartBarData(
|
LineChartBarData(
|
||||||
spots: spots,
|
spots: spots,
|
||||||
isCurved: true,
|
isCurved: false,
|
||||||
curveSmoothness: 0.2,
|
color: c.accent,
|
||||||
color: accentColor,
|
|
||||||
barWidth: 1.5,
|
barWidth: 1.5,
|
||||||
dotData: const FlDotData(show: false),
|
dotData: const FlDotData(show: false),
|
||||||
belowBarData: BarAreaData(
|
|
||||||
show: true,
|
|
||||||
color: accentColor.withAlpha(25),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
duration: Duration.zero,
|
duration: Duration.zero,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
// Window max / min
|
||||||
|
Positioned(
|
||||||
|
left: 4,
|
||||||
|
top: 0,
|
||||||
|
child: IgnorePointer(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(hi == null ? '-' : f(hi), style: muted),
|
||||||
|
Text(lo == null ? '-' : f(lo), style: muted),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Name + current value
|
||||||
|
Positioned(
|
||||||
|
left: 4,
|
||||||
|
bottom: 18,
|
||||||
|
child: IgnorePointer(
|
||||||
|
child: RichText(
|
||||||
|
text: TextSpan(children: [
|
||||||
|
TextSpan(
|
||||||
|
text: '${view.name} ',
|
||||||
|
style: TextStyle(color: c.accent, fontSize: 14),
|
||||||
|
),
|
||||||
|
TextSpan(
|
||||||
|
text: raw.isEmpty ? '-' : view.format(raw.last.y),
|
||||||
|
style: TextStyle(
|
||||||
|
color: c.onSurface,
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextSpan(
|
||||||
|
text: ' ${view.unit}',
|
||||||
|
style: TextStyle(color: c.onSurfaceMuted, fontSize: 12),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: onRemove,
|
||||||
|
child: Icon(Icons.close, size: 16, color: c.onSurfaceMuted),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<FlSpot> _window(List<FlSpot> spots, double windowSec) {
|
static String _compact(double v) {
|
||||||
if (spots.isEmpty) return spots;
|
if (v.abs() >= 1000) return '${(v / 1000).toStringAsFixed(1)}k';
|
||||||
final maxX = spots.last.x;
|
if (v.abs() >= 10 || v == v.roundToDouble()) return v.toStringAsFixed(0);
|
||||||
final minX = maxX - windowSec;
|
return v.toStringAsFixed(1);
|
||||||
return spots.where((s) => s.x >= minX).toList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
String _fmt(double v) =>
|
|
||||||
v.abs() >= 100 ? v.toStringAsFixed(1) : v.toStringAsFixed(2);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,29 +1,37 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../../core/models/sensor_defs.dart';
|
import '../../../core/models/sensor_defs.dart';
|
||||||
|
import '../../../core/models/units.dart';
|
||||||
import '../../../core/providers/sensor_provider.dart';
|
import '../../../core/providers/sensor_provider.dart';
|
||||||
import '../../../core/providers/theme_provider.dart';
|
import '../../../core/providers/settings_provider.dart';
|
||||||
import '../../../ui/theme/app_colors.dart';
|
|
||||||
import 'widgets/sensor_row.dart';
|
import 'widgets/sensor_row.dart';
|
||||||
|
|
||||||
|
/// All sensors as a list (requirements): 1 column on a phone in portrait,
|
||||||
|
/// 2 columns on a tablet in portrait and in landscape.
|
||||||
class SensorListScreen extends ConsumerWidget {
|
class SensorListScreen extends ConsumerWidget {
|
||||||
const SensorListScreen({super.key});
|
const SensorListScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final state = ref.watch(latestSensorProvider);
|
final state = ref.watch(latestSensorProvider);
|
||||||
final colors = AppColors.forTheme(ref.watch(themeProvider));
|
final units = ref.watch(unitPrefsProvider);
|
||||||
|
final media = MediaQuery.of(context);
|
||||||
|
final isTablet = media.size.shortestSide >= 600;
|
||||||
|
final isLandscape = media.orientation == Orientation.landscape;
|
||||||
|
final columns = (isTablet || isLandscape) ? 2 : 1;
|
||||||
|
|
||||||
return ListView.separated(
|
return GridView.builder(
|
||||||
itemCount: sensorDefs.length,
|
itemCount: sensorDefs.length,
|
||||||
separatorBuilder: (_, __) =>
|
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
const Divider(height: 1, color: Colors.white10),
|
crossAxisCount: columns,
|
||||||
|
mainAxisExtent: 72,
|
||||||
|
crossAxisSpacing: 8,
|
||||||
|
),
|
||||||
itemBuilder: (context, i) {
|
itemBuilder: (context, i) {
|
||||||
final def = sensorDefs[i];
|
final id = sensorDefs[i].id;
|
||||||
return SensorRow(
|
return SensorRow(
|
||||||
def: def,
|
view: sensorView(id, units),
|
||||||
value: sensorValue(state, def.id),
|
baseValue: sensorValue(state, id),
|
||||||
accentColor: colors.accent,
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,130 +1,129 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../../../core/models/sensor_def.dart';
|
import '../../../../core/models/units.dart';
|
||||||
import '../../../../core/protocol/sensor_state.dart';
|
import '../../../theme/app_colors.dart';
|
||||||
|
|
||||||
|
/// Sensor list entry (requirements): name and unit, min/max of the range,
|
||||||
|
/// a segmented bar gauge and the numeric value.
|
||||||
class SensorRow extends StatelessWidget {
|
class SensorRow extends StatelessWidget {
|
||||||
final SensorDef def;
|
final SensorView view;
|
||||||
final double value;
|
final double baseValue;
|
||||||
final Color accentColor;
|
|
||||||
|
|
||||||
const SensorRow({
|
const SensorRow({super.key, required this.view, required this.baseValue});
|
||||||
super.key,
|
|
||||||
required this.def,
|
|
||||||
required this.value,
|
|
||||||
required this.accentColor,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final normalized =
|
final c = AppColors.of(context);
|
||||||
((value - def.min) / (def.max - def.min)).clamp(0.0, 1.0);
|
final fraction = view.fraction(baseValue);
|
||||||
final isHigh = normalized > 0.8;
|
final isHigh = fraction > 0.9;
|
||||||
|
final rangeStyle = TextStyle(color: c.onSurfaceMuted, fontSize: 10);
|
||||||
|
String fmt(double v) => v.toStringAsFixed(view.decimals > 1 ? 1 : 0);
|
||||||
|
|
||||||
return Padding(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
|
padding: const EdgeInsets.fromLTRB(12, 6, 12, 6),
|
||||||
child: Row(
|
decoration: BoxDecoration(
|
||||||
|
border: Border(bottom: BorderSide(color: c.divider)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
// Label
|
Row(
|
||||||
SizedBox(
|
children: [
|
||||||
width: 52,
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
def.displayName,
|
view.name,
|
||||||
style: const TextStyle(
|
maxLines: 1,
|
||||||
color: Colors.grey,
|
overflow: TextOverflow.ellipsis,
|
||||||
fontSize: 12,
|
style: TextStyle(
|
||||||
letterSpacing: 0.5,
|
color: c.accent,
|
||||||
),
|
fontSize: 14,
|
||||||
),
|
fontWeight: FontWeight.w600,
|
||||||
),
|
|
||||||
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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
Text(view.unit,
|
||||||
|
style: TextStyle(color: c.onSurfaceMuted, fontSize: 13)),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(height: 2),
|
||||||
// Value + unit
|
Row(
|
||||||
SizedBox(
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
width: 80,
|
children: [
|
||||||
child: RichText(
|
Expanded(
|
||||||
textAlign: TextAlign.right,
|
child: Column(
|
||||||
text: TextSpan(
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
TextSpan(
|
Row(
|
||||||
text: _fmt(value),
|
children: [
|
||||||
style: const TextStyle(
|
Text(fmt(view.min), style: rangeStyle),
|
||||||
color: Colors.white,
|
const Spacer(),
|
||||||
fontSize: 14,
|
Text(fmt(view.max), style: rangeStyle),
|
||||||
fontWeight: FontWeight.w600,
|
],
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 2),
|
||||||
TextSpan(
|
_SegmentBar(
|
||||||
text: ' ${def.unit}',
|
fraction: fraction,
|
||||||
|
color: isHigh ? Colors.red : c.accent,
|
||||||
|
track: c.track,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
SizedBox(
|
||||||
|
width: 90,
|
||||||
|
child: FittedBox(
|
||||||
|
fit: BoxFit.scaleDown,
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: Text(
|
||||||
|
view.format(baseValue),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: accentColor,
|
color: isHigh ? Colors.red : c.onSurface,
|
||||||
fontSize: 11,
|
fontSize: 24,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
/// Bar made of thin segments, like the reference screenshot.
|
||||||
switch (id) {
|
class _SegmentBar extends StatelessWidget {
|
||||||
case 'rpm': return s.rpm;
|
final double fraction;
|
||||||
case 'vss': return s.vss;
|
final Color color;
|
||||||
case 'map': return s.map;
|
final Color track;
|
||||||
case 'tps': return s.tps;
|
|
||||||
case 'inj': return s.inj;
|
const _SegmentBar({
|
||||||
case 'ign': return s.ign;
|
required this.fraction,
|
||||||
case 'ect': return s.ect;
|
required this.color,
|
||||||
case 'iat': return s.iat;
|
required this.track,
|
||||||
case 'bat': return s.bat;
|
});
|
||||||
case 'o2': return s.o2;
|
|
||||||
case 'gear': return s.gear.toDouble();
|
@override
|
||||||
case 'eth': return s.eth;
|
Widget build(BuildContext context) {
|
||||||
case 'pa': return s.pa;
|
return SizedBox(
|
||||||
case 'afr': return s.afr;
|
height: 14,
|
||||||
case 'strim': return s.strim;
|
child: LayoutBuilder(builder: (context, box) {
|
||||||
case 'ltrim': return s.ltrim;
|
const segWidth = 3.0;
|
||||||
case 'ain0': return s.ain0;
|
const gap = 2.0;
|
||||||
case 'ain1': return s.ain1;
|
final count = (box.maxWidth / (segWidth + gap)).floor().clamp(1, 400);
|
||||||
case 'ain2': return s.ain2;
|
final lit = (fraction * count).round();
|
||||||
case 'ain3': return s.ain3;
|
return Row(
|
||||||
case 'ain4': return s.ain4;
|
children: [
|
||||||
case 'ain5': return s.ain5;
|
for (var i = 0; i < count; i++)
|
||||||
case 'ain6': return s.ain6;
|
Container(
|
||||||
case 'ain7': return s.ain7;
|
width: segWidth,
|
||||||
default: return 0;
|
margin: const EdgeInsets.only(right: gap),
|
||||||
|
color: i < lit ? color : track,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,13 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../../core/bluetooth/bt_poller.dart';
|
import '../../../core/bluetooth/bt_poller.dart';
|
||||||
|
import '../../../core/models/units.dart';
|
||||||
import '../../../core/providers/bt_provider.dart';
|
import '../../../core/providers/bt_provider.dart';
|
||||||
import '../../../core/providers/settings_provider.dart';
|
import '../../../core/providers/settings_provider.dart';
|
||||||
import '../../../core/providers/theme_provider.dart';
|
import '../../../core/providers/theme_provider.dart';
|
||||||
import '../../theme/app_colors.dart';
|
import '../../theme/app_colors.dart';
|
||||||
import '../bt_picker/bt_picker_screen.dart';
|
import '../bt_picker/bt_picker_screen.dart';
|
||||||
|
import '../debug/raw_frame_debug_screen.dart';
|
||||||
|
|
||||||
class SettingsScreen extends ConsumerWidget {
|
class SettingsScreen extends ConsumerWidget {
|
||||||
const SettingsScreen({super.key});
|
const SettingsScreen({super.key});
|
||||||
@ -15,7 +17,9 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
final settings = ref.watch(settingsProvider);
|
final settings = ref.watch(settingsProvider);
|
||||||
final themeVariant = ref.watch(themeProvider);
|
final themeVariant = ref.watch(themeProvider);
|
||||||
final bt = ref.watch(btProvider);
|
final bt = ref.watch(btProvider);
|
||||||
final colors = AppColors.forTheme(themeVariant);
|
final colors = AppColors.of(context);
|
||||||
|
final units = ref.watch(unitPrefsProvider);
|
||||||
|
final unitsN = ref.read(unitPrefsProvider.notifier);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Settings')),
|
appBar: AppBar(title: const Text('Settings')),
|
||||||
@ -48,7 +52,7 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
MaterialPageRoute(builder: (_) => const BtPickerScreen()),
|
MaterialPageRoute(builder: (_) => const BtPickerScreen()),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Divider(height: 1, color: Colors.white10),
|
const Divider(height: 1),
|
||||||
|
|
||||||
// ── ECU Protocol ─────────────────────────────────────────
|
// ── ECU Protocol ─────────────────────────────────────────
|
||||||
_SectionHeader('ECU PROTOCOL'),
|
_SectionHeader('ECU PROTOCOL'),
|
||||||
@ -74,11 +78,11 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
if (v != null) ref.read(settingsProvider.notifier).setEcuType(v);
|
if (v != null) ref.read(settingsProvider.notifier).setEcuType(v);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const Divider(height: 1, color: Colors.white10),
|
const Divider(height: 1),
|
||||||
|
|
||||||
// ── Polling Interval ─────────────────────────────────────
|
// ── Polling Interval ─────────────────────────────────────
|
||||||
_SectionHeader('POLLING INTERVAL'),
|
_SectionHeader('POLLING INTERVAL'),
|
||||||
...[20, 50, 100, 200].map((ms) => RadioListTile<int>(
|
...pollingIntervalOptionsMs.map((ms) => RadioListTile<int>(
|
||||||
title: Text('${ms}ms (~${(1000 / ms).round()} Hz)'),
|
title: Text('${ms}ms (~${(1000 / ms).round()} Hz)'),
|
||||||
value: ms,
|
value: ms,
|
||||||
groupValue: settings.pollingInterval.inMilliseconds,
|
groupValue: settings.pollingInterval.inMilliseconds,
|
||||||
@ -91,7 +95,36 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
)),
|
)),
|
||||||
const Divider(height: 1, color: Colors.white10),
|
const Divider(height: 1),
|
||||||
|
|
||||||
|
// ── Units ────────────────────────────────────────────────
|
||||||
|
_SectionHeader('UNITS'),
|
||||||
|
_UnitRow<SpeedUnit>(
|
||||||
|
label: 'Speed',
|
||||||
|
value: units.speed,
|
||||||
|
options: const {SpeedUnit.kmh: 'km/h', SpeedUnit.mph: 'mph'},
|
||||||
|
onChanged: unitsN.setSpeed,
|
||||||
|
),
|
||||||
|
_UnitRow<TempUnit>(
|
||||||
|
label: 'Temperature',
|
||||||
|
value: units.temp,
|
||||||
|
options: const {
|
||||||
|
TempUnit.celsius: '°C',
|
||||||
|
TempUnit.fahrenheit: '°F',
|
||||||
|
},
|
||||||
|
onChanged: unitsN.setTemp,
|
||||||
|
),
|
||||||
|
_UnitRow<PressureUnit>(
|
||||||
|
label: 'Pressure',
|
||||||
|
value: units.pressure,
|
||||||
|
options: const {
|
||||||
|
PressureUnit.kpa: 'kPa',
|
||||||
|
PressureUnit.bar: 'bar',
|
||||||
|
PressureUnit.psi: 'psi',
|
||||||
|
},
|
||||||
|
onChanged: unitsN.setPressure,
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
|
||||||
// ── Theme ────────────────────────────────────────────────
|
// ── Theme ────────────────────────────────────────────────
|
||||||
_SectionHeader('THEME'),
|
_SectionHeader('THEME'),
|
||||||
@ -112,7 +145,20 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
const Divider(height: 1, color: Colors.white10),
|
const Divider(height: 1),
|
||||||
|
|
||||||
|
// ── Diagnostics ──────────────────────────────────────────
|
||||||
|
_SectionHeader('DIAGNOSTICS'),
|
||||||
|
ListTile(
|
||||||
|
leading: Icon(Icons.bug_report_outlined, color: colors.accent),
|
||||||
|
title: const Text('ECU frame debug & app log'),
|
||||||
|
trailing: const Icon(Icons.arrow_forward_ios, size: 14),
|
||||||
|
onTap: () => Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (_) => const RawFrameDebugScreen()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
|
||||||
// ── App info ─────────────────────────────────────────────
|
// ── App info ─────────────────────────────────────────────
|
||||||
_SectionHeader('ABOUT'),
|
_SectionHeader('ABOUT'),
|
||||||
@ -149,3 +195,39 @@ class _SectionHeader extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One unit choice (e.g. Speed: km/h | mph).
|
||||||
|
class _UnitRow<T> extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final T value;
|
||||||
|
final Map<T, String> options;
|
||||||
|
final void Function(T) onChanged;
|
||||||
|
|
||||||
|
const _UnitRow({
|
||||||
|
required this.label,
|
||||||
|
required this.value,
|
||||||
|
required this.options,
|
||||||
|
required this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: Text(label)),
|
||||||
|
SegmentedButton<T>(
|
||||||
|
showSelectedIcon: false,
|
||||||
|
segments: [
|
||||||
|
for (final e in options.entries)
|
||||||
|
ButtonSegment<T>(value: e.key, label: Text(e.value)),
|
||||||
|
],
|
||||||
|
selected: {value},
|
||||||
|
onSelectionChanged: (s) => onChanged(s.first),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,13 +1,17 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../core/providers/theme_provider.dart';
|
import '../../core/providers/theme_provider.dart';
|
||||||
|
|
||||||
class AppColors {
|
/// App palette for one theme variant. Registered as a [ThemeExtension] so
|
||||||
|
/// widgets read it with [AppColors.of] and follow light/dark automatically.
|
||||||
|
class AppColors extends ThemeExtension<AppColors> {
|
||||||
final Color accent;
|
final Color accent;
|
||||||
final Color background;
|
final Color background;
|
||||||
final Color surface;
|
final Color surface;
|
||||||
final Color surfaceVariant;
|
final Color surfaceVariant;
|
||||||
final Color onSurface;
|
final Color onSurface;
|
||||||
final Color onSurfaceMuted;
|
final Color onSurfaceMuted;
|
||||||
|
final Color divider;
|
||||||
|
final Color track; // empty part of bars and gauge arcs
|
||||||
|
|
||||||
const AppColors._({
|
const AppColors._({
|
||||||
required this.accent,
|
required this.accent,
|
||||||
@ -16,43 +20,36 @@ class AppColors {
|
|||||||
required this.surfaceVariant,
|
required this.surfaceVariant,
|
||||||
required this.onSurface,
|
required this.onSurface,
|
||||||
required this.onSurfaceMuted,
|
required this.onSurfaceMuted,
|
||||||
|
required this.divider,
|
||||||
|
required this.track,
|
||||||
});
|
});
|
||||||
|
|
||||||
static const redDark = AppColors._(
|
static const _dark = AppColors._(
|
||||||
accent: Color(0xFFFF4444),
|
accent: Color(0xFFFF4444),
|
||||||
background: Color(0xFF0A0A0A),
|
background: Color(0xFF0A0A0A),
|
||||||
surface: Color(0xFF1A1A1A),
|
surface: Color(0xFF1A1A1A),
|
||||||
surfaceVariant: Color(0xFF242424),
|
surfaceVariant: Color(0xFF242424),
|
||||||
onSurface: Colors.white,
|
onSurface: Colors.white,
|
||||||
onSurfaceMuted: Colors.grey,
|
onSurfaceMuted: Color(0xFF9E9E9E),
|
||||||
|
divider: Color(0x1AFFFFFF),
|
||||||
|
track: Color(0xFF2A2A2A),
|
||||||
);
|
);
|
||||||
|
|
||||||
static const redLight = AppColors._(
|
static const _light = AppColors._(
|
||||||
accent: Color(0xFFCC0000),
|
accent: Color(0xFFCC0000),
|
||||||
background: Color(0xFFF2F2F2),
|
background: Color(0xFFF2F2F2),
|
||||||
surface: Color(0xFFFFFFFF),
|
surface: Color(0xFFFFFFFF),
|
||||||
surfaceVariant: Color(0xFFEAEAEA),
|
surfaceVariant: Color(0xFFEAEAEA),
|
||||||
onSurface: Color(0xFF111111),
|
onSurface: Color(0xFF111111),
|
||||||
onSurfaceMuted: Color(0xFF777777),
|
onSurfaceMuted: Color(0xFF6E6E6E),
|
||||||
|
divider: Color(0x1F000000),
|
||||||
|
track: Color(0xFFDDDDDD),
|
||||||
);
|
);
|
||||||
|
|
||||||
static const greenDark = AppColors._(
|
static const redDark = _dark;
|
||||||
accent: Color(0xFF00E676),
|
static const redLight = _light;
|
||||||
background: Color(0xFF0A0A0A),
|
static final greenDark = _dark.copyWith(accent: const Color(0xFF00E676));
|
||||||
surface: Color(0xFF1A1A1A),
|
static final greenLight = _light.copyWith(accent: const Color(0xFF00875A));
|
||||||
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) {
|
static AppColors forTheme(AppThemeVariant v) {
|
||||||
switch (v) {
|
switch (v) {
|
||||||
@ -62,4 +59,45 @@ class AppColors {
|
|||||||
case AppThemeVariant.greenLight: return greenLight;
|
case AppThemeVariant.greenLight: return greenLight;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Palette of the current theme.
|
||||||
|
static AppColors of(BuildContext context) =>
|
||||||
|
Theme.of(context).extension<AppColors>() ?? _dark;
|
||||||
|
|
||||||
|
@override
|
||||||
|
AppColors copyWith({
|
||||||
|
Color? accent,
|
||||||
|
Color? background,
|
||||||
|
Color? surface,
|
||||||
|
Color? surfaceVariant,
|
||||||
|
Color? onSurface,
|
||||||
|
Color? onSurfaceMuted,
|
||||||
|
Color? divider,
|
||||||
|
Color? track,
|
||||||
|
}) =>
|
||||||
|
AppColors._(
|
||||||
|
accent: accent ?? this.accent,
|
||||||
|
background: background ?? this.background,
|
||||||
|
surface: surface ?? this.surface,
|
||||||
|
surfaceVariant: surfaceVariant ?? this.surfaceVariant,
|
||||||
|
onSurface: onSurface ?? this.onSurface,
|
||||||
|
onSurfaceMuted: onSurfaceMuted ?? this.onSurfaceMuted,
|
||||||
|
divider: divider ?? this.divider,
|
||||||
|
track: track ?? this.track,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
AppColors lerp(AppColors? other, double t) {
|
||||||
|
if (other == null) return this;
|
||||||
|
return AppColors._(
|
||||||
|
accent: Color.lerp(accent, other.accent, t)!,
|
||||||
|
background: Color.lerp(background, other.background, t)!,
|
||||||
|
surface: Color.lerp(surface, other.surface, t)!,
|
||||||
|
surfaceVariant: Color.lerp(surfaceVariant, other.surfaceVariant, t)!,
|
||||||
|
onSurface: Color.lerp(onSurface, other.onSurface, t)!,
|
||||||
|
onSurfaceMuted: Color.lerp(onSurfaceMuted, other.onSurfaceMuted, t)!,
|
||||||
|
divider: Color.lerp(divider, other.divider, t)!,
|
||||||
|
track: Color.lerp(track, other.track, t)!,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,6 +11,8 @@ class AppTheme {
|
|||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
brightness: isDark ? Brightness.dark : Brightness.light,
|
brightness: isDark ? Brightness.dark : Brightness.light,
|
||||||
scaffoldBackgroundColor: colors.background,
|
scaffoldBackgroundColor: colors.background,
|
||||||
|
extensions: [colors],
|
||||||
|
dividerColor: colors.divider,
|
||||||
colorScheme: ColorScheme.fromSeed(
|
colorScheme: ColorScheme.fromSeed(
|
||||||
seedColor: colors.accent,
|
seedColor: colors.accent,
|
||||||
brightness: isDark ? Brightness.dark : Brightness.light,
|
brightness: isDark ? Brightness.dark : Brightness.light,
|
||||||
@ -39,7 +41,7 @@ class AppTheme {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
dividerTheme: DividerThemeData(
|
dividerTheme: DividerThemeData(
|
||||||
color: colors.surfaceVariant,
|
color: colors.divider,
|
||||||
thickness: 1,
|
thickness: 1,
|
||||||
),
|
),
|
||||||
floatingActionButtonTheme: FloatingActionButtonThemeData(
|
floatingActionButtonTheme: FloatingActionButtonThemeData(
|
||||||
|
|||||||
@ -7,13 +7,15 @@ class BtStatusChip extends ConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final bt = ref.watch(btProvider);
|
final status = ref.watch(btProvider.select((s) => s.status));
|
||||||
|
final name = ref.watch(btProvider.select((s) => s.deviceName));
|
||||||
|
|
||||||
final (color, icon, label) = switch (bt.status) {
|
final (color, icon, label) = switch (status) {
|
||||||
BtStatus.connected => (Colors.green, Icons.bluetooth_connected, bt.deviceName ?? 'Connected'),
|
BtStatus.connected => (Colors.green, Icons.bluetooth_connected, name ?? 'Connected'),
|
||||||
BtStatus.connecting => (Colors.orange, Icons.bluetooth_searching, 'Connecting…'),
|
BtStatus.connecting => (Colors.orange, Icons.bluetooth_searching, 'Connecting…'),
|
||||||
BtStatus.error => (Colors.red, Icons.bluetooth_disabled, 'Error'),
|
BtStatus.reconnecting => (Colors.orange, Icons.bluetooth_searching, 'Reconnecting…'),
|
||||||
BtStatus.disconnected=> (Colors.grey, Icons.bluetooth, 'Disconnected'),
|
BtStatus.error => (Colors.red, Icons.bluetooth_disabled, 'Error'),
|
||||||
|
BtStatus.disconnected => (Colors.grey, Icons.bluetooth, 'Disconnected'),
|
||||||
};
|
};
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
|
|||||||
58
lib/ui/widgets/data_status_banner.dart
Normal file
58
lib/ui/widgets/data_status_banner.dart
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import '../../core/providers/bt_provider.dart';
|
||||||
|
import '../../core/providers/datalog_provider.dart';
|
||||||
|
|
||||||
|
/// Thin banner under the app bar that tells the driver when the numbers on
|
||||||
|
/// screen are NOT live, instead of silently showing stale or default values.
|
||||||
|
class DataStatusBanner extends ConsumerWidget {
|
||||||
|
const DataStatusBanner({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final status = ref.watch(btProvider.select((s) => s.status));
|
||||||
|
final data = ref.watch(btProvider.select((s) => s.dataStatus));
|
||||||
|
final error = ref.watch(btProvider.select((s) => s.error));
|
||||||
|
final playing = ref.watch(playbackProvider.select((p) => p.isActive));
|
||||||
|
|
||||||
|
final String? msg;
|
||||||
|
Color color = const Color(0xFF8A5A00);
|
||||||
|
if (playing) {
|
||||||
|
msg = 'Playing back a recorded session — not live data';
|
||||||
|
color = const Color(0xFF0D47A1);
|
||||||
|
} else if (status == BtStatus.reconnecting) {
|
||||||
|
msg = 'Connection interrupted — reconnecting…';
|
||||||
|
} else if (status == BtStatus.error) {
|
||||||
|
msg = error ?? 'Connection lost';
|
||||||
|
color = const Color(0xFF7A0000);
|
||||||
|
} else if (status == BtStatus.disconnected) {
|
||||||
|
msg = 'Not connected';
|
||||||
|
color = const Color(0xFF444444);
|
||||||
|
} else if (status != BtStatus.connected) {
|
||||||
|
msg = null;
|
||||||
|
} else {
|
||||||
|
msg = switch (data) {
|
||||||
|
EcuDataStatus.live => null,
|
||||||
|
EcuDataStatus.none => 'Connected — waiting for data…',
|
||||||
|
EcuDataStatus.adapterDefaults =>
|
||||||
|
'Adapter connected, but no data from the ECU (ignition on?)',
|
||||||
|
EcuDataStatus.noResponse => 'Adapter is not responding',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg == null) return const SizedBox.shrink();
|
||||||
|
return Material(
|
||||||
|
color: color,
|
||||||
|
child: SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
child: Text(
|
||||||
|
msg,
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,9 +6,11 @@ import '../screens/sensor_list/sensor_list_screen.dart';
|
|||||||
import '../screens/graph/graph_screen.dart';
|
import '../screens/graph/graph_screen.dart';
|
||||||
import '../screens/dtc/dtc_screen.dart';
|
import '../screens/dtc/dtc_screen.dart';
|
||||||
import '../screens/datalog/datalog_list_screen.dart';
|
import '../screens/datalog/datalog_list_screen.dart';
|
||||||
import '../screens/debug/raw_frame_debug_screen.dart';
|
import '../screens/datalog/datalog_playback_bar.dart';
|
||||||
|
import '../../core/providers/datalog_provider.dart';
|
||||||
import '../screens/settings/settings_screen.dart';
|
import '../screens/settings/settings_screen.dart';
|
||||||
import 'bt_status_chip.dart';
|
import 'bt_status_chip.dart';
|
||||||
|
import 'data_status_banner.dart';
|
||||||
import 'record_fab.dart';
|
import 'record_fab.dart';
|
||||||
|
|
||||||
final _navIndexProvider = StateProvider<int>((ref) => 0);
|
final _navIndexProvider = StateProvider<int>((ref) => 0);
|
||||||
@ -45,6 +47,7 @@ class MainNav extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final index = ref.watch(_navIndexProvider);
|
final index = ref.watch(_navIndexProvider);
|
||||||
|
final playback = ref.watch(playbackProvider);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
@ -59,14 +62,8 @@ class MainNav extends ConsumerWidget {
|
|||||||
MaterialPageRoute(builder: (_) => const DatalogListScreen()),
|
MaterialPageRoute(builder: (_) => const DatalogListScreen()),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
IconButton(
|
// Record sits in the app bar so it never covers dashboard values.
|
||||||
icon: const Icon(Icons.bug_report_outlined),
|
const RecordButton(),
|
||||||
tooltip: 'ECU Frame Debug',
|
|
||||||
onPressed: () => Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(builder: (_) => const RawFrameDebugScreen()),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.settings_outlined),
|
icon: const Icon(Icons.settings_outlined),
|
||||||
tooltip: 'Settings',
|
tooltip: 'Settings',
|
||||||
@ -77,16 +74,28 @@ class MainNav extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: IndexedStack(
|
// SafeArea: in landscape the system navigation bar sits on a side.
|
||||||
index: index,
|
body: SafeArea(
|
||||||
children: _screens,
|
top: false,
|
||||||
|
bottom: false,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
const DataStatusBanner(),
|
||||||
|
if (playback.isActive) DatalogPlaybackBar(state: playback),
|
||||||
|
Expanded(
|
||||||
|
child: IndexedStack(
|
||||||
|
index: index,
|
||||||
|
children: _screens,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
bottomNavigationBar: BottomNavigationBar(
|
bottomNavigationBar: BottomNavigationBar(
|
||||||
currentIndex: index,
|
currentIndex: index,
|
||||||
items: _items,
|
items: _items,
|
||||||
onTap: (i) => ref.read(_navIndexProvider.notifier).state = i,
|
onTap: (i) => ref.read(_navIndexProvider.notifier).state = i,
|
||||||
),
|
),
|
||||||
floatingActionButton: const RecordFab(),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,8 +3,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import '../../core/providers/bt_provider.dart';
|
import '../../core/providers/bt_provider.dart';
|
||||||
import '../../core/providers/datalog_provider.dart';
|
import '../../core/providers/datalog_provider.dart';
|
||||||
|
|
||||||
class RecordFab extends ConsumerWidget {
|
/// App-bar record/stop button for datalogging (shown while connected).
|
||||||
const RecordFab({super.key});
|
class RecordButton extends ConsumerWidget {
|
||||||
|
const RecordButton({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
@ -16,9 +17,8 @@ class RecordFab extends ConsumerWidget {
|
|||||||
|
|
||||||
final isRecording = recording.isRecording;
|
final isRecording = recording.isRecording;
|
||||||
|
|
||||||
return FloatingActionButton(
|
return IconButton(
|
||||||
heroTag: 'record_fab',
|
color: isRecording ? Colors.red : null,
|
||||||
backgroundColor: isRecording ? Colors.red : Theme.of(context).colorScheme.primary,
|
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
if (isRecording) {
|
if (isRecording) {
|
||||||
await ref.read(recordingProvider.notifier).stopRecording();
|
await ref.read(recordingProvider.notifier).stopRecording();
|
||||||
@ -37,11 +37,12 @@ class RecordFab extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
tooltip: isRecording ? 'Stop recording' : 'Start recording',
|
tooltip: isRecording ? 'Stop recording' : 'Start recording',
|
||||||
child: AnimatedSwitcher(
|
icon: AnimatedSwitcher(
|
||||||
duration: const Duration(milliseconds: 200),
|
duration: const Duration(milliseconds: 200),
|
||||||
child: isRecording
|
child: isRecording
|
||||||
? const Icon(Icons.stop, key: ValueKey('stop'))
|
? const Icon(Icons.stop_circle, key: ValueKey('stop'))
|
||||||
: const Icon(Icons.fiber_manual_record, key: ValueKey('rec')),
|
: const Icon(Icons.fiber_manual_record_outlined,
|
||||||
|
key: ValueKey('rec')),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,16 +7,29 @@ void main() {
|
|||||||
expect(s300DtcMap.length, equals(32)); // 4 bytes × 8 bits
|
expect(s300DtcMap.length, equals(32)); // 4 bytes × 8 bits
|
||||||
});
|
});
|
||||||
|
|
||||||
test('P0130 maps to correct description for ERR00 bit0', () {
|
// Expected codes come from the S300 "DTC map" in hvbt_protocol.pdf.
|
||||||
expect(s300DtcMap['ERR00_bit0'], equals('P0130 — O2 Sensor (front)'));
|
test('ERR00 bit0 = P0130 (O2A)', () {
|
||||||
|
expect(s300DtcMap['ERR00_bit0'], startsWith('P0130 — '));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('P0505 maps for ERR00 bit2', () {
|
test('ERR01 bit5 = P0505 (IAC)', () {
|
||||||
expect(s300DtcMap['ERR00_bit2'], equals('P0505 — Idle Air Control Valve'));
|
expect(s300DtcMap['ERR01_bit5'], startsWith('P0505 — '));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('P1607 maps for ERR03 bit7', () {
|
test('bench codes: ERR01.6 = P0350, ERR02.3 = P1298, ERR02.4 = P2649', () {
|
||||||
expect(s300DtcMap['ERR03_bit7'], equals('P1607 — PCM Internal Circuit'));
|
expect(s300DtcMap['ERR01_bit6'], startsWith('P0350 — '));
|
||||||
|
expect(s300DtcMap['ERR02_bit3'], startsWith('P1298 — '));
|
||||||
|
expect(s300DtcMap['ERR02_bit4'], startsWith('P2649 — '));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('undocumented bits show the raw ERR position', () {
|
||||||
|
expect(s300DtcMap['ERR03_bit7'], startsWith('ERR03.7 — '));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every entry has a code and a description', () {
|
||||||
|
for (final v in [...s300DtcMap.values, ...kproDtcMap.values]) {
|
||||||
|
expect(v.split(' — ').length, equals(2), reason: v);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -26,8 +39,11 @@ void main() {
|
|||||||
expect(kproDtcMap.length, equals(144));
|
expect(kproDtcMap.length, equals(144));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('P0130 maps for ERR00 bit0', () {
|
// Expected codes come from the KPro "DTC map" in hvbt_protocol.pdf.
|
||||||
expect(kproDtcMap['ERR00_bit0'], equals('P0130 — O2 Sensor (front)'));
|
test('ERR00 bit1 = P1607, ERR05 bit0 = P1259, ERR11 bit6 = P0304', () {
|
||||||
|
expect(kproDtcMap['ERR00_bit1'], startsWith('P1607 — '));
|
||||||
|
expect(kproDtcMap['ERR05_bit0'], startsWith('P1259 — '));
|
||||||
|
expect(kproDtcMap['ERR11_bit6'], startsWith('P0304 — '));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('ERR17 entries present', () {
|
test('ERR17 entries present', () {
|
||||||
@ -44,7 +60,7 @@ void main() {
|
|||||||
|
|
||||||
test('decodes ERR00 bit0 = P0130', () {
|
test('decodes ERR00 bit0 = P0130', () {
|
||||||
final result = decodeDtcs([0x01, 0, 0, 0], s300DtcMap);
|
final result = decodeDtcs([0x01, 0, 0, 0], s300DtcMap);
|
||||||
expect(result, contains('P0130 — O2 Sensor (front)'));
|
expect(result, contains(s300DtcMap['ERR00_bit0']));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('decodes multiple bits in one byte', () {
|
test('decodes multiple bits in one byte', () {
|
||||||
|
|||||||
22
test/ecu_type_from_name_test.dart
Normal file
22
test/ecu_type_from_name_test.dart
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:hvbt_dash/core/bluetooth/bt_poller.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('ecuTypeFromDeviceName', () {
|
||||||
|
test('real S300 adapter names', () {
|
||||||
|
expect(ecuTypeFromDeviceName('S300[94]-SPP'), EcuType.s300);
|
||||||
|
expect(ecuTypeFromDeviceName('HVS300-SPP[94]'), EcuType.s300);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('KPro names', () {
|
||||||
|
expect(ecuTypeFromDeviceName('HV-KPro-SPP'), EcuType.kpro);
|
||||||
|
expect(ecuTypeFromDeviceName('K-PRO 1234'), EcuType.kpro);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unknown or missing names leave the setting alone', () {
|
||||||
|
expect(ecuTypeFromDeviceName(null), isNull);
|
||||||
|
expect(ecuTypeFromDeviceName('HC-05'), isNull);
|
||||||
|
expect(ecuTypeFromDeviceName('S300 / KPro'), isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:hvbt_dash/core/protocol/kpro_parser.dart';
|
import 'package:hvbt_dash/core/protocol/kpro_parser.dart';
|
||||||
|
import 'package:hvbt_dash/core/protocol/neg8.dart';
|
||||||
import 'package:hvbt_dash/core/protocol/temp_table.dart';
|
import 'package:hvbt_dash/core/protocol/temp_table.dart';
|
||||||
|
|
||||||
/// Builds a zeroed 128-byte KPro frame and stamps the NEG8 checksum at byte 127.
|
/// Builds a zeroed 128-byte KPro frame and stamps the NEG8 checksum at byte 127.
|
||||||
@ -21,18 +22,18 @@ Uint8List _buildKProFrame({
|
|||||||
// Header
|
// Header
|
||||||
frame[0] = 0x1B;
|
frame[0] = 0x1B;
|
||||||
frame[1] = 0x01;
|
frame[1] = 0x01;
|
||||||
// RPM: bytes 2..3
|
// RPM: bytes 2..3, little-endian
|
||||||
frame[2] = (rpm >> 8) & 0xFF;
|
frame[2] = rpm & 0xFF;
|
||||||
frame[3] = rpm & 0xFF;
|
frame[3] = (rpm >> 8) & 0xFF;
|
||||||
// VSS: byte 4
|
// VSS: byte 4
|
||||||
frame[4] = vss & 0xFF;
|
frame[4] = vss & 0xFF;
|
||||||
// TPS: byte 5
|
// TPS: byte 5
|
||||||
frame[5] = tps & 0xFF;
|
frame[5] = tps & 0xFF;
|
||||||
// MAP1: byte 6
|
// MAP1: byte 6
|
||||||
frame[6] = map1 & 0xFF;
|
frame[6] = map1 & 0xFF;
|
||||||
// MAP2: bytes 33..34
|
// MAP2: bytes 33 (L), 34 (H)
|
||||||
frame[33] = (map2 >> 8) & 0xFF;
|
frame[33] = map2 & 0xFF;
|
||||||
frame[34] = map2 & 0xFF;
|
frame[34] = (map2 >> 8) & 0xFF;
|
||||||
// IGN: byte 13
|
// IGN: byte 13
|
||||||
frame[13] = ign;
|
frame[13] = ign;
|
||||||
// KRtrd: byte 21
|
// KRtrd: byte 21
|
||||||
@ -43,7 +44,7 @@ Uint8List _buildKProFrame({
|
|||||||
// ECT: byte 49
|
// ECT: byte 49
|
||||||
frame[49] = ect;
|
frame[49] = ect;
|
||||||
// BAT: byte 52
|
// BAT: byte 52
|
||||||
frame[52] = bat;
|
frame[51] = bat; // KMGR: BAT is byte 51 (the PDF says 52)
|
||||||
|
|
||||||
// Stamp NEG8 checksum at byte 127
|
// Stamp NEG8 checksum at byte 127
|
||||||
int neg8 = 0;
|
int neg8 = 0;
|
||||||
@ -69,10 +70,10 @@ void main() {
|
|||||||
expect(state.vss, equals(120.0));
|
expect(state.vss, equals(120.0));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('parses TPS directly', () {
|
test('parses TPS = (raw - 25) / 2.04', () {
|
||||||
final frame = _buildKProFrame(tps: 75);
|
final frame = _buildKProFrame(tps: 163); // KMGR: 68 %
|
||||||
final state = parseKPro(frame);
|
final state = parseKPro(frame);
|
||||||
expect(state.tps, equals(75.0));
|
expect(state.tps, closeTo(67.6, 0.1));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('parses MAP from MAP1 when MAP2 = 0', () {
|
test('parses MAP from MAP1 when MAP2 = 0', () {
|
||||||
@ -102,12 +103,45 @@ void main() {
|
|||||||
expect(state.bat, equals(13.0));
|
expect(state.bat, equals(13.0));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('MIL flag from SW2 bit2', () {
|
test('MIL flag from SW2 bit3', () {
|
||||||
final frame = _buildKProFrame(sw2: 0x04); // bit2
|
final frame = _buildKProFrame(sw2: 0x08); // bit3
|
||||||
final state = parseKPro(frame);
|
final state = parseKPro(frame);
|
||||||
expect(state.mil, isTrue);
|
expect(state.mil, isTrue);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('real bench frame matches KPro Manager (7267 rpm capture)', () {
|
||||||
|
// First 64 bytes logged at 00:40:25 on 2026-10-13, while KPro Manager
|
||||||
|
// showed: RPM 7267, VSS 0, TPS 68 %, IGN 5°, S.TRIM 0 %, BAT 11.40 V,
|
||||||
|
// PA 95.0 kPa, INJ 0.00 ms, SCS/FLR on, VTP on, VTS off, MIL on.
|
||||||
|
const head = [
|
||||||
|
0x1b, 0x01, 0x8d, 0x71, 0x00, 0xa3, 0x75, 0xff, //
|
||||||
|
0x28, 0x00, 0x4d, 0x00, 0x00, 0x8a, 0x00, 0xd2,
|
||||||
|
0xd9, 0xaf, 0xf6, 0x97, 0x80, 0x00, 0x03, 0x8b,
|
||||||
|
0x00, 0x00, 0x12, 0x0c, 0x07, 0x09, 0x00, 0x50,
|
||||||
|
0x09, 0xa0, 0x04, 0x01, 0x00, 0x00, 0xff, 0x00,
|
||||||
|
0x00, 0xbf, 0xa3, 0xbf, 0xe8, 0x10, 0xf0, 0xff,
|
||||||
|
0x8d, 0xd8, 0xd2, 0x72, 0xe8, 0x5f, 0xfe, 0x80,
|
||||||
|
0x04, 0x00, 0x00, 0x00, 0x65,
|
||||||
|
];
|
||||||
|
final frame = Uint8List(128)..setRange(0, head.length, head);
|
||||||
|
frame[127] = calculateNeg8(frame.sublist(0, 127));
|
||||||
|
final s = parseKPro(frame);
|
||||||
|
expect(s.rpm, closeTo(7267, 1));
|
||||||
|
expect(s.vss, 0);
|
||||||
|
expect(s.tps.round(), 68);
|
||||||
|
expect(s.ign, 5);
|
||||||
|
expect(s.strim, 0);
|
||||||
|
expect(s.bat, closeTo(11.4, 0.001));
|
||||||
|
expect(s.pa, 95);
|
||||||
|
expect(s.inj, 0);
|
||||||
|
expect(s.map, closeTo(118.4, 0.001)); // 2.5 psi boost
|
||||||
|
expect(s.fuelPump, isTrue); // FLR
|
||||||
|
expect(s.fanOut, isFalse); // FANC
|
||||||
|
expect(s.vtp, isTrue);
|
||||||
|
expect(s.vtec, isFalse); // VTS
|
||||||
|
expect(s.mil, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
test('knock flag when krtrd > 0', () {
|
test('knock flag when krtrd > 0', () {
|
||||||
final frame = _buildKProFrame(krtrd: 3);
|
final frame = _buildKProFrame(krtrd: 3);
|
||||||
final state = parseKPro(frame);
|
final state = parseKPro(frame);
|
||||||
|
|||||||
100
test/rpm_glitch_filter_test.dart
Normal file
100
test/rpm_glitch_filter_test.dart
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:hvbt_dash/core/bluetooth/rpm_glitch_filter.dart';
|
||||||
|
|
||||||
|
/// KPro frame carrying [rpm] (bytes 2-3 LE, raw = rpm × 4).
|
||||||
|
Uint8List _kpro(double rpm) {
|
||||||
|
final f = Uint8List(128);
|
||||||
|
f[0] = 0x1B;
|
||||||
|
f[1] = 0x01;
|
||||||
|
final raw = (rpm * 4).round();
|
||||||
|
f[2] = raw & 0xFF;
|
||||||
|
f[3] = raw >> 8;
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
final t0 = DateTime(2026, 10, 13);
|
||||||
|
DateTime at(int frame) => t0.add(Duration(milliseconds: 100 * frame));
|
||||||
|
|
||||||
|
test('drops the KPro bench glitch frames (2300 rpm with ~246 rpm dips)', () {
|
||||||
|
final f = RpmGlitchFilter();
|
||||||
|
// Pattern from the bench log: normal, odd, normal, odd, odd, normal …
|
||||||
|
final seq = <double>[2256, 245.5, 2315, 246, 246, 2319, 2315, 246, 2320];
|
||||||
|
final shown = <double>[];
|
||||||
|
for (var i = 0; i < seq.length; i++) {
|
||||||
|
if (f.accept(_kpro(seq[i]), at(i))) shown.add(seq[i]);
|
||||||
|
}
|
||||||
|
expect(shown.every((r) => r > 2000), isTrue, reason: '$shown');
|
||||||
|
expect(f.dropped, 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('idle glitch 1003 -> 250 is dropped too', () {
|
||||||
|
final f = RpmGlitchFilter();
|
||||||
|
expect(f.accept(_kpro(1003), at(0)), isTrue);
|
||||||
|
expect(f.accept(_kpro(250), at(1)), isFalse);
|
||||||
|
expect(f.accept(_kpro(1005), at(2)), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normal acceleration passes every frame', () {
|
||||||
|
final f = RpmGlitchFilter();
|
||||||
|
for (var i = 0; i < 30; i++) {
|
||||||
|
// +300 rpm per 100 ms = 3000 rpm/s
|
||||||
|
expect(f.accept(_kpro(1000 + 300.0 * i), at(i)), isTrue);
|
||||||
|
}
|
||||||
|
expect(f.dropped, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a burst of 5 glitch frames is still dropped', () {
|
||||||
|
final f = RpmGlitchFilter();
|
||||||
|
expect(f.accept(_kpro(3117), at(0)), isTrue);
|
||||||
|
for (var i = 1; i <= 5; i++) {
|
||||||
|
expect(f.accept(_kpro(311), at(i)), isFalse);
|
||||||
|
}
|
||||||
|
expect(f.accept(_kpro(3125), at(6)), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a real sudden change is accepted by majority (~0.6 s)', () {
|
||||||
|
double? from, to;
|
||||||
|
final f = RpmGlitchFilter(onConfirmed: (a, b) {
|
||||||
|
from = a;
|
||||||
|
to = b;
|
||||||
|
});
|
||||||
|
expect(f.accept(_kpro(3000), at(0)), isTrue);
|
||||||
|
var i = 1;
|
||||||
|
while (!f.accept(_kpro(800), at(i))) {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
expect(i, lessThanOrEqualTo(10));
|
||||||
|
expect((from, to), (3000, 800));
|
||||||
|
expect(f.accept(_kpro(790), at(i + 1)), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('new RPM wins even with glitch frames mixed in (bench, after DTC '
|
||||||
|
'clear: 7267 -> 3405 with ~311 dips)', () {
|
||||||
|
final f = RpmGlitchFilter();
|
||||||
|
expect(f.accept(_kpro(7267), at(0)), isTrue);
|
||||||
|
// 3405, 3405, 311, 3405, 311, 3405, 3405, 311 … repeating
|
||||||
|
const pattern = <double>[3405, 3405, 311];
|
||||||
|
var shown = 7267.0;
|
||||||
|
for (var i = 1; i <= 30; i++) {
|
||||||
|
final r = pattern[i % 3];
|
||||||
|
if (f.accept(_kpro(r), at(i))) shown = r;
|
||||||
|
}
|
||||||
|
expect(shown, 3405);
|
||||||
|
// and the 311 dips are never shown afterwards
|
||||||
|
for (var i = 31; i <= 60; i++) {
|
||||||
|
final r = pattern[i % 3];
|
||||||
|
if (f.accept(_kpro(r), at(i))) expect(r, 3405);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('S300 RPM is read from bytes 3-4', () {
|
||||||
|
final s = Uint8List(128)
|
||||||
|
..[0] = 0x1B
|
||||||
|
..[3] = 0x8A
|
||||||
|
..[4] = 0x0C; // 3210
|
||||||
|
expect(RpmGlitchFilter.rpmOf(s), 3210);
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -13,12 +13,14 @@ Uint8List _buildS300Frame({
|
|||||||
int inj = 0,
|
int inj = 0,
|
||||||
int ign = 0,
|
int ign = 0,
|
||||||
int ect = 0x80, // tempXlt[0x80]=73 → 73-40=33°C
|
int ect = 0x80, // tempXlt[0x80]=73 → 73-40=33°C
|
||||||
|
int iat = 0x80,
|
||||||
int bat = 0,
|
int bat = 0,
|
||||||
int sw1 = 0,
|
int sw1 = 0,
|
||||||
int sw2 = 0,
|
int sw2 = 0,
|
||||||
int sw3 = 0,
|
int sw3 = 0,
|
||||||
int krtrd = 0,
|
int krtrd = 0,
|
||||||
int gear = 0,
|
int gear = 0,
|
||||||
|
Map<int, int> extra = const {},
|
||||||
}) {
|
}) {
|
||||||
final frame = Uint8List(128);
|
final frame = Uint8List(128);
|
||||||
// Header
|
// Header
|
||||||
@ -35,9 +37,9 @@ Uint8List _buildS300Frame({
|
|||||||
frame[8] = (mapRaw >> 8) & 0xFF;
|
frame[8] = (mapRaw >> 8) & 0xFF;
|
||||||
// TPS: byte +9
|
// TPS: byte +9
|
||||||
frame[9] = tps;
|
frame[9] = tps;
|
||||||
// INJ: bytes +10..11, BE
|
// INJ: bytes +10..11, little-endian
|
||||||
frame[10] = (inj >> 8) & 0xFF;
|
frame[10] = inj & 0xFF;
|
||||||
frame[11] = inj & 0xFF;
|
frame[11] = (inj >> 8) & 0xFF;
|
||||||
// IGN: byte +12
|
// IGN: byte +12
|
||||||
frame[12] = ign;
|
frame[12] = ign;
|
||||||
// KRtrd: byte +13
|
// KRtrd: byte +13
|
||||||
@ -48,10 +50,13 @@ Uint8List _buildS300Frame({
|
|||||||
frame[0x13] = sw3;
|
frame[0x13] = sw3;
|
||||||
// Gear: decimal offset 27
|
// Gear: decimal offset 27
|
||||||
frame[27] = gear;
|
frame[27] = gear;
|
||||||
// ECT: byte +2D
|
// ECT: decimal offset 33
|
||||||
frame[0x2D] = ect;
|
frame[33] = ect;
|
||||||
// BAT: byte +30
|
// IAT: decimal offset 34
|
||||||
frame[0x30] = bat;
|
frame[34] = iat;
|
||||||
|
// BAT: decimal offset 36
|
||||||
|
frame[36] = bat;
|
||||||
|
extra.forEach((offset, value) => frame[offset] = value);
|
||||||
|
|
||||||
// Stamp NEG8 checksum at byte 127
|
// Stamp NEG8 checksum at byte 127
|
||||||
int neg8 = 0;
|
int neg8 = 0;
|
||||||
@ -104,10 +109,9 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('parses TPS correctly when raw >= 25', () {
|
test('parses TPS correctly when raw >= 25', () {
|
||||||
// tps raw=127 -> 127*100/255 ≈ 49.8% (confirmed against live capture)
|
// (raw - 25) / 2.04: raw 127 -> 50 %; USB tool showed 31 % at raw ≈ 88.
|
||||||
final frame = _buildS300Frame(tps: 127);
|
expect(parseS300(_buildS300Frame(tps: 127)).tps, closeTo(50.0, 0.1));
|
||||||
final state = parseS300(frame);
|
expect(parseS300(_buildS300Frame(tps: 88)).tps.round(), 31);
|
||||||
expect(state.tps, closeTo(49.8, 0.1));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('parses Gear from decimal offset 27', () {
|
test('parses Gear from decimal offset 27', () {
|
||||||
@ -117,10 +121,21 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('parses ECT using temp lookup table', () {
|
test('parses ECT using temp lookup table', () {
|
||||||
// ect raw = 0x80 = 128 → tempXlt[128]=73 → 73-40=33°C
|
// Confirmed via live capture: raw=0xD2(210) matched the reference
|
||||||
final frame = _buildS300Frame(ect: 0x80);
|
// 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);
|
final state = parseS300(frame);
|
||||||
expect(state.ect, equals((tempXlt[0x80] - 40).toDouble()));
|
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', () {
|
test('parses BAT = raw * 26 / 270', () {
|
||||||
@ -143,6 +158,25 @@ void main() {
|
|||||||
expect(state.mil, isTrue);
|
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', () {
|
test('knock flag set when krtrd > 0', () {
|
||||||
final frame = _buildS300Frame(krtrd: 5);
|
final frame = _buildS300Frame(krtrd: 5);
|
||||||
final state = parseS300(frame);
|
final state = parseS300(frame);
|
||||||
@ -162,6 +196,59 @@ void main() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Offsets corrected 2026-10-12 (column-count mapping) ──────────────
|
||||||
|
test('fuel trims: bytes 29/31, 0x80 = 0 %', () {
|
||||||
|
final s = parseS300(_buildS300Frame(extra: {29: 0x80, 31: 0x90}));
|
||||||
|
expect(s.strim, closeTo(0.0, 0.001));
|
||||||
|
expect(s.ltrim, closeTo(12.5, 0.001));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DTC bytes are ERR00..ERR03 at 37..40', () {
|
||||||
|
final s = parseS300(
|
||||||
|
_buildS300Frame(extra: {37: 0x01, 38: 0x02, 39: 0x04, 40: 0x08}));
|
||||||
|
expect(s.errBytes, equals([0x01, 0x02, 0x04, 0x08]));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SW6 (byte 50) no longer produces phantom DTCs', () {
|
||||||
|
final s = parseS300(_buildS300Frame(extra: {50: 0x60, 49: 0x00}));
|
||||||
|
expect(s.errBytes, equals([0, 0, 0, 0]));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fan output = SW5 (byte 49) bit0 FANC, not SW2 ALTC', () {
|
||||||
|
expect(parseS300(_buildS300Frame(extra: {49: 0x01})).fanOut, isTrue);
|
||||||
|
expect(parseS300(_buildS300Frame(sw2: 0x80)).fanOut, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ethanol from byte 41, AF raw from byte 55, PA from byte 32', () {
|
||||||
|
final s = parseS300(_buildS300Frame(extra: {41: 85, 55: 0x9A, 32: 0x93}));
|
||||||
|
expect(s.eth, equals(85.0));
|
||||||
|
expect(s.afr, equals(154.0));
|
||||||
|
expect(s.pa, closeTo(100.25, 0.01));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('INJ read little-endian', () {
|
||||||
|
final s = parseS300(_buildS300Frame(inj: 3000));
|
||||||
|
expect(s.inj, closeTo(3.0, 0.001));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AIN0 read little-endian at 82', () {
|
||||||
|
final s = parseS300(_buildS300Frame(extra: {82: 0x34, 83: 0x02}));
|
||||||
|
expect(s.ain0, equals(0x0234.toDouble()));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('adapter default frame decodes to the PDF example (sanity)', () {
|
||||||
|
final f = _fullDefaultFrame();
|
||||||
|
expect(isS300DefaultFrame(f), isTrue);
|
||||||
|
final s = parseS300(f);
|
||||||
|
expect(s.map, closeTo(101.0, 0.01)); // 0x03F2
|
||||||
|
expect(s.gear, equals(1));
|
||||||
|
expect(s.ect, equals(20.0)); // 0xA0
|
||||||
|
expect(s.iat, equals(20.0));
|
||||||
|
expect(s.bat, closeTo(12.04, 0.01)); // 0x7D
|
||||||
|
expect(s.strim, closeTo(0.0, 0.001));
|
||||||
|
expect(s.errBytes, equals([0, 0, 0, 0]));
|
||||||
|
});
|
||||||
|
|
||||||
test('throws on bad checksum', () {
|
test('throws on bad checksum', () {
|
||||||
final frame = _buildS300Frame(rpm: 1000);
|
final frame = _buildS300Frame(rpm: 1000);
|
||||||
frame[127] = 0x00; // corrupt checksum
|
frame[127] = 0x00; // corrupt checksum
|
||||||
@ -175,40 +262,87 @@ void main() {
|
|||||||
expect(state.rpm, equals(1000.0));
|
expect(state.rpm, equals(1000.0));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('decodes RPM=0 for a frame matching the old "keep-alive" byte prefix', () {
|
test('decodes RPM=0 for a frame matching the fixed event-frame template', () {
|
||||||
// A fixed 17-byte prefix here was previously treated as a keep-alive
|
// parseS300 itself doesn't special-case this template — it just reads
|
||||||
// signature to skip outright. Live testing showed it over-matched
|
// the normal offsets, which land on the template's fixed bytes.
|
||||||
// badly: with RPM actively changing, nearly every frame was being
|
// Holding the last known-good value across this frame is the caller's
|
||||||
// classified as a keep-alive and discarded, freezing the dashboard.
|
// job (sensor_provider.dart), driven by isS300TemplateFrame below.
|
||||||
// Every checksum-valid frame is decoded normally now regardless of
|
final frame = _templateFrame();
|
||||||
// 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);
|
final state = parseS300(frame);
|
||||||
expect(state.rpm, equals(0.0));
|
expect(state.rpm, equals(0.0));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('isS300TemplateFrame detects the fixed event-frame byte pattern', () {
|
||||||
|
// Verified via live A/B capture: this exact bytes-0-16 pattern shows
|
||||||
|
// up whenever SW2 (byte 18, outside this span) reports fuel cut
|
||||||
|
// active, corroborated independently by the same frame's INJ pulse
|
||||||
|
// width reading 0 and TPS reading a closed-throttle value.
|
||||||
|
expect(isS300TemplateFrame(_templateFrame()), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isS300TemplateFrame is false for a normal live-reading frame', () {
|
||||||
|
final frame = _buildS300Frame(rpm: 3000, tps: 127);
|
||||||
|
expect(isS300TemplateFrame(frame), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('default-frame head with real values at 32-36 is NOT a default frame',
|
||||||
|
() {
|
||||||
|
// Key-on/engine-off could coincide with bytes 0-16; the 32-36 span
|
||||||
|
// (PA/ECT/IAT/?/BAT) must also match before data is discarded.
|
||||||
|
final frame = _templateFrame();
|
||||||
|
frame[33] = 0xD2; // real ECT
|
||||||
|
frame[127] = calculateNeg8(frame.sublist(0, 127));
|
||||||
|
expect(isS300DefaultFrame(frame), isFalse);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Builds a 128-byte frame stamped with the S300's fixed event-frame
|
||||||
|
/// template in bytes 0-16 and 32-36, with a valid NEG8 checksum.
|
||||||
|
Uint8List _templateFrame() {
|
||||||
|
final frame = Uint8List(128);
|
||||||
|
final prefix = [
|
||||||
|
0x1B,
|
||||||
|
0x00,
|
||||||
|
0x14,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0xFF,
|
||||||
|
0xFF,
|
||||||
|
0xF2,
|
||||||
|
0x03,
|
||||||
|
0x18,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x10,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0xC4,
|
||||||
|
];
|
||||||
|
for (var i = 0; i < prefix.length; i++) {
|
||||||
|
frame[i] = prefix[i];
|
||||||
|
}
|
||||||
|
frame.setRange(32, 37, [0x93, 0xA0, 0xA0, 0xDC, 0x7D]);
|
||||||
|
frame[127] = calculateNeg8(frame.sublist(0, 127));
|
||||||
|
return frame;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The full PDF example row (hvbt_protocol.pdf), which the adapter sends as
|
||||||
|
/// its default frame when it has no ECU data. Offsets by column count.
|
||||||
|
Uint8List _fullDefaultFrame() {
|
||||||
|
final frame = Uint8List(128);
|
||||||
|
const bytes = <int>[
|
||||||
|
0x1B, 0x00, 0x14, 0x00, 0x00, 0xFF, 0xFF, 0xF2, 0x03, 0x18, // 0-9
|
||||||
|
0x00, 0x00, 0x10, 0x00, 0x00, 0x00, 0xC4, 0x71, 0x1C, 0x05, // 10-19
|
||||||
|
0x40, 0xB4, 0x05, 0x41, 0x05, 0x66, 0x02, 0x01, 0x00, 0x80, // 20-29
|
||||||
|
0x00, 0x80, 0x93, 0xA0, 0xA0, 0xDC, 0x7D, 0x00, 0x00, 0x00, // 30-39
|
||||||
|
0x00, 0x00, 0x80, 0x00, 0x00, 0x8C, 0xB1, 0x00, 0x00, 0x00, // 40-49
|
||||||
|
0x60, 0xC0, 0x7D, 0x7F, 0x08, 0x9A, 0xC4, 0x00, 0x00, 0x00, // 50-59
|
||||||
|
];
|
||||||
|
for (var i = 0; i < bytes.length; i++) {
|
||||||
|
frame[i] = bytes[i];
|
||||||
|
}
|
||||||
|
frame[127] = calculateNeg8(frame.sublist(0, 127));
|
||||||
|
return frame;
|
||||||
|
}
|
||||||
|
|||||||
107
test/units_and_flags_test.dart
Normal file
107
test/units_and_flags_test.dart
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:hvbt_dash/core/models/sensor_defs.dart';
|
||||||
|
import 'package:hvbt_dash/core/models/units.dart';
|
||||||
|
import 'package:hvbt_dash/core/protocol/neg8.dart';
|
||||||
|
import 'package:hvbt_dash/core/protocol/s300_parser.dart';
|
||||||
|
|
||||||
|
/// S300 frame with header, the given bytes set, and a valid checksum.
|
||||||
|
Uint8List _s300(Map<int, int> bytes) {
|
||||||
|
final f = Uint8List(128);
|
||||||
|
f[0] = 0x1B;
|
||||||
|
bytes.forEach((i, v) => f[i] = v);
|
||||||
|
f[127] = calculateNeg8(f.sublist(0, 127));
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('display units', () {
|
||||||
|
test('metric defaults are unchanged', () {
|
||||||
|
final v = sensorView('vss', const UnitPrefs());
|
||||||
|
expect(v.unit, 'km/h');
|
||||||
|
expect(v.convert(100), 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('speed in mph', () {
|
||||||
|
final v = sensorView('vss', const UnitPrefs(speed: SpeedUnit.mph));
|
||||||
|
expect(v.unit, 'mph');
|
||||||
|
expect(v.convert(100), closeTo(62.14, 0.01));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('temperatures in °F, range converted too', () {
|
||||||
|
final v =
|
||||||
|
sensorView('ect', const UnitPrefs(temp: TempUnit.fahrenheit));
|
||||||
|
expect(v.unit, '°F');
|
||||||
|
expect(v.convert(100), closeTo(212, 0.001));
|
||||||
|
expect(v.min, closeTo(-40, 0.001));
|
||||||
|
expect(v.max, closeTo(302, 0.001));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('MAP and baro in bar and psi', () {
|
||||||
|
final bar = sensorView('map', const UnitPrefs(pressure: PressureUnit.bar));
|
||||||
|
expect(bar.unit, 'bar');
|
||||||
|
expect(bar.format(101.3), '1.01');
|
||||||
|
final psi = sensorView('pa', const UnitPrefs(pressure: PressureUnit.psi));
|
||||||
|
expect(psi.unit, 'psi');
|
||||||
|
expect(psi.convert(100), closeTo(14.50, 0.01));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unit choice does not touch unrelated sensors', () {
|
||||||
|
const u = UnitPrefs(
|
||||||
|
speed: SpeedUnit.mph,
|
||||||
|
temp: TempUnit.fahrenheit,
|
||||||
|
pressure: PressureUnit.psi,
|
||||||
|
);
|
||||||
|
expect(sensorView('rpm', u).convert(3000), 3000);
|
||||||
|
expect(sensorView('bat', u).unit, 'V');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('values checked against the USB tool (bench, 3210 rpm)', () {
|
||||||
|
test('O2 volts = raw × 5/256 (raw 195 → 3.81 V)', () {
|
||||||
|
expect(parseS300(_s300({16: 195})).o2, closeTo(3.81, 0.005));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('injector duty = INJ ms × RPM / 1200 (16.67 ms @ 3210 → 44 %)', () {
|
||||||
|
final s = parseS300(_s300({})).copyWith(inj: 16.67, rpm: 3210);
|
||||||
|
expect(sensorValue(s, 'duty'), closeTo(44.6, 0.1));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('S300 flags', () {
|
||||||
|
test('SW3 cut/launch bits (byte 19)', () {
|
||||||
|
final s = parseS300(_s300({19: 0x20 | 0x08}));
|
||||||
|
expect(s.ignCut, isTrue);
|
||||||
|
expect(s.boostCut, isTrue);
|
||||||
|
expect(s.shiftCut, isFalse);
|
||||||
|
expect(s.launch, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SW1 brake/AC/VTP (byte 17), SW5 closed loop (byte 49)', () {
|
||||||
|
final s = parseS300(_s300({17: 0x04 | 0x02 | 0x01, 49: 0x04}));
|
||||||
|
expect(s.brakeSwitch, isTrue);
|
||||||
|
expect(s.acSwitch, isTrue);
|
||||||
|
expect(s.vtp, isTrue);
|
||||||
|
expect(s.closedLoop, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('IAT / CLT / WBO error flags come from stored DTC bits', () {
|
||||||
|
// ERR01 bit1 = P0113 IAT, ERR00 bit5 = ECT, ERR00 bit0 = O2A
|
||||||
|
final s = parseS300(_s300({38: 0x02, 37: 0x20 | 0x01}));
|
||||||
|
expect(s.iatError, isTrue);
|
||||||
|
expect(s.ectError, isTrue);
|
||||||
|
expect(s.o2Error, isTrue);
|
||||||
|
final clean = parseS300(_s300({}));
|
||||||
|
expect(clean.iatError || clean.ectError || clean.o2Error, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every flag id resolves and reads a value', () {
|
||||||
|
final s = parseS300(_s300({}));
|
||||||
|
for (final f in flagDefs) {
|
||||||
|
expect(flagDef(f.id).id, f.id);
|
||||||
|
expect(f.read(s), isA<bool>());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user