فهرست منبع

fix: recover the OBD economy after the 5E change

1.2.7's one-shot 0140 supported-PID query could race the ATSP0 protocol search, leaving every later ELM327 response shifted by one command and the economy stuck at a dash. Warm up with 0100, drain stale input before each command, remove the 0140 query and probe 5E in the poll loop every 30 cycles while unsupported. Bump to 1.2.9+29 and add the universal release APK.
Krasimir Kazakov 10 ساعت پیش
والد
کامیت
258c5412f7

+ 1 - 1
AGENTS.md

@@ -111,7 +111,7 @@ notifyListeners();
 - Updates use a non-blocking public Gogs release check. The Update action downloads the APK to cache and opens Android’s package installer; never install silently. REQUEST_INSTALL_PACKAGES + one-time unknown-apps permission is required. Show the yellow dot on the Settings gear whenever `UpdateController.updateAvailable` is true.
 - ADB installs: always try at most twice per connected device. If both attempts fail, stop silently rather than looping or switching install methods.
 - OBD is Bluetooth Classic SPP, bonded-only and read-only, and still needs a real-car test; weather uses Open-Meteo and one-shot location and stores no coordinates.
-- Fuel economy prefers the ECU engine-fuel-rate PID `5E`, which follows the car's trip computer and works for petrol and diesel. `ObdEconomy` falls back to MAF with petrol constants (AFR 14.7, 745 g/L) only when `5E` is absent; that fallback overestimates a diesel and must not be described as accurate for one. The native worker checks the supported-PID bitmap before polling `5E`, reads speed, fuel rate and MAF next to each other, and keeps the throttle sample after them so a transient is paired as closely as possible.
+- Fuel economy prefers the ECU engine-fuel-rate PID `5E`, which follows the car's trip computer and works for petrol and diesel. `ObdEconomy` falls back to MAF with petrol constants (AFR 14.7, 745 g/L) only when `5E` is absent; that fallback overestimates a diesel and must not be described as accurate for one. The native worker warms up with `0100` after `ATSP0`, drains stale input before every command and probes `5E` in the poll loop (every 30 cycles while unsupported), so a slow first request cannot shift the ELM327 request/response stream. Do not add a one-shot `0140` supported-PID query before the first poll; that was the 1.2.7 regression that made economy stay at `—`.
 - All Bluetooth binder work (`bluetoothState`, `listBondedDevices`, `connectObd`, `disconnectObd`) runs on `TelemetryBridge`'s serialized `cardash-bluetooth` worker; never move it back to the MethodChannel/main thread.
 - The OBD-II picker lists likely OBD-II adapters first; **Show all paired devices** is the explicit escape hatch for unusual adapters. Phones and headsets stay out of the default list.
 - Connection attempts are bounded (three failures, 12 s connect watchdog). After they are exhausted the card shows **Retry connection**; do not restore an infinite reconnect loop.

+ 17 - 16
README.md

@@ -466,22 +466,23 @@ real 12.2 server (Quick Connect, grouped genres, 6/5-card dashboard, album page,
 background playback, notification, media keys, exclusive equalizers, audio-focus
 handover, Random re-shuffle) and against a local 12.2 container. The OBD hardware
 path compiles and is covered by the parser/economy design, but no ELM327 adapter
-is available on this machine; socket behavior still needs a car test. The 1.2.8
-universal APK is the current published release. It streamlines the Jellyfin
-album navigation card — the favorite heart moves to the left corner at 69 px,
-three separate 60 px volume buttons sit on the right, the cover is 30% smaller
-with taller track rows, and every page has a larger back button. Both Jellyfin
-pages get a transparent top-right time / speed / temperature / Home strip. It
-includes the 1.2.7 ECU fuel-rate economy (which fixes the high diesel reading,
-with the MAF fallback for vehicles without PID `5E`), the 1.2.6 stacked album
-layout at every resolution (cover and controls above the track list), the 1.2.5
-bottom alignment and larger controls, the 1.2.4 album redesign,
-the 1.2.3 recents/normal-app work, the FYT/Syu MCU volume bridge, the
-no-snap-back volume handling, the streaming-quality setting,
-Favorites, the playing-album Home shortcut and the Jellyfin progress-duration
-fallback. The emulator has verified the in-app update path, and the MCU volume
-path is verified on the car: the on-screen slider and the car's own volume
-control now agree.
+is available on this machine; socket behavior still needs a car test. The 1.2.9
+universal APK is the current published release. It fixes the 1.2.7 economy
+regression that left the card at `—`: the ELM327 stream is warmed up after the
+`ATSP0` protocol search, stale input is drained before every command, and PID
+`5E` is probed in the poll loop. The ECU fuel-rate economy still fixes the high
+diesel reading, with the MAF fallback for vehicles without `5E`. The release
+also includes the 1.2.8 Jellyfin album navigation card — favorite heart in the
+left corner at 69 px, three separate 60 px volume buttons on the right, a 30%
+smaller cover with taller track rows, a larger back button on every page, and a
+transparent top-right time / speed / temperature / Home strip — plus the 1.2.6
+stacked album layout at every resolution, the 1.2.5 bottom alignment and larger
+controls, the 1.2.4 album redesign, the 1.2.3 recents/normal-app work, the
+FYT/Syu MCU volume bridge, the no-snap-back volume handling, the
+streaming-quality setting, Favorites, the playing-album Home shortcut and the
+Jellyfin progress-duration fallback. The emulator has verified the in-app update
+path, and the MCU volume path is verified on the car: the on-screen slider and
+the car's own volume control now agree.
 
 ## Troubleshooting the emulator
 

+ 46 - 12
android/app/src/main/kotlin/com/wasp/cardash_launcher/ObdService.kt

@@ -35,6 +35,8 @@ class ObdService(
         private const val CONNECT_TIMEOUT_MS = 12_000L
         private const val MAX_FAILURES = 3
         private const val AMBIENT_EVERY = 30
+        private const val FUEL_RATE_PROBE_EVERY = 30
+        private const val MAX_FUEL_RATE_MISSES = 5
     }
 
     private val socketLock = Any()
@@ -53,6 +55,7 @@ class ObdService(
     @Volatile private var mafGramsPerSecond: Double? = null
     @Volatile private var fuelRateLph: Double? = null
     @Volatile private var fuelRateSupported: Boolean? = null
+    private var fuelRateMisses = 0
     @Volatile private var ambientCelsius: Double? = null
     @Volatile private var error: String? = null
     @Volatile private var lastPollAt: Long = 0L
@@ -156,8 +159,8 @@ class ObdService(
 
                 fuelRateLph = null
                 fuelRateSupported = null
+                fuelRateMisses = 0
                 handshake()
-                detectFuelRateSupport()
                 if (!running) {
                     closeSocket()
                     return
@@ -234,6 +237,11 @@ class ObdService(
         if (!responded) {
             throw IllegalStateException("OBD adapter did not respond")
         }
+        // ATSP0 can start a protocol search on the first OBD request. Let it
+        // finish with a generous timeout so the search response is not left in
+        // the socket to be mistaken for the next PID's answer.
+        send("0100")
+        readResponse(3_000L)
     }
 
     private fun pollLoop() {
@@ -242,13 +250,28 @@ class ObdService(
             val speed = readPid("010D", 1)?.firstOrNull()?.toInt()?.and(0xFF)?.toDouble()
             // PID 5E is the ECU's own fuel rate. It matches the car's trip
             // computer and is the only reliable source for a diesel engine.
-            val fuelRate = if (fuelRateSupported != false) {
+            // Probe it occasionally when it is unsupported or still unknown so
+            // a slow/unsupported PID cannot add a timeout to every poll.
+            val probeFuelRate =
+                fuelRateSupported == true || tick % FUEL_RATE_PROBE_EVERY == 0
+            val fuelRate = if (probeFuelRate) {
                 readPid("015E", 2)?.let { bytes ->
                     (((bytes[0].toInt() and 0xFF) shl 8) or (bytes[1].toInt() and 0xFF)) / 20.0
                 }
             } else {
                 null
             }
+            if (probeFuelRate) {
+                if (fuelRate != null) {
+                    fuelRateSupported = true
+                    fuelRateMisses = 0
+                } else {
+                    fuelRateMisses++
+                    if (fuelRateMisses >= MAX_FUEL_RATE_MISSES) {
+                        fuelRateSupported = false
+                    }
+                }
+            }
             // Keep MAF next to speed and fuel rate so a transient sample pairs
             // the economy inputs as closely as possible.
             val maf = readPid("0110", 2)?.let { bytes ->
@@ -273,16 +296,6 @@ class ObdService(
         }
     }
 
-    /**
-     * Reads the supported-PID bitmap for 0x41-0x60. PID 0x5E is bit 2 of the
-     * fourth byte; skipping unsupported PID reads keeps the poll fast.
-     */
-    private fun detectFuelRateSupport() {
-        val bitmap = readPid("0140", 4) ?: return
-        val fourth = bitmap[3].toInt() and 0xFF
-        fuelRateSupported = (fourth and 0x04) != 0
-    }
-
     private fun readThrottle(): Double? {
         val pedal = readPid("0149", 1)?.firstOrNull()?.toInt()?.and(0xFF)
         if (pedal != null) return pedal * 100.0 / 255.0
@@ -316,10 +329,31 @@ class ObdService(
 
     private fun send(command: String) {
         val out = output ?: throw IllegalStateException("OBD socket is not open")
+        // A late response from the previous command must not be mistaken for
+        // this command's answer; drop anything still queued first.
+        drainInput()
         out.write("$command\r".toByteArray())
         out.flush()
     }
 
+    private fun drainInput() {
+        val source = input ?: return
+        try {
+            val scratch = ByteArray(256)
+            var quietUntil = System.currentTimeMillis() + 40
+            while (System.currentTimeMillis() < quietUntil) {
+                if (source.available() > 0) {
+                    if (source.read(scratch) <= 0) break
+                    quietUntil = System.currentTimeMillis() + 40
+                } else {
+                    Thread.sleep(5)
+                }
+            }
+        } catch (_: Throwable) {
+            // The next read/write will surface a socket that has gone away.
+        }
+    }
+
     private fun readResponse(timeoutMs: Long): String {
         val source = input ?: return ""
         val deadline = System.currentTimeMillis() + timeoutMs

+ 6 - 0
docs/IMPLEMENTATION_PLAN.md

@@ -500,3 +500,9 @@ Android (Kotlin)
   instead of always deriving fuel from MAF/AFR. The MAF path remains a
   petrol-only fallback. This fixes the high reading on diesel cars, whose lean
   combustion makes a stoichiometric MAF calculation overestimate fuel.
+- 2026-11-02: Fixed the 1.2.7 economy regression that left the card at `—`.
+  The one-shot `0140` supported-PID query could race the `ATSP0` protocol
+  search and shift every later ELM327 response by one command. 1.2.9 warms up
+  with `0100`, drains stale input before each command, removes the `0140`
+  query, and probes `5E` in the poll loop every 30 cycles while unsupported.
+  Bumped to 1.2.9+29.

+ 11 - 4
docs/OBD_TEMPERATURE.md

@@ -1,6 +1,6 @@
 # CarDash — OBD-II and temperature design note
 
-_Status: updated in CarDash 1.2.7; OBD hardware still needs a real-car test.
+_Status: updated in CarDash 1.2.9; OBD hardware still needs a real-car test.
 Last updated: 2026-11-02_
 
 ## 1. Scope
@@ -98,9 +98,11 @@ Dart
   SettingsRepository     adapter address, speed/temperature flags, weather
 ```
 
-The native `ObdService` checks the supported-PID bitmap for `5E` after the
-handshake and polls speed, fuel rate, MAF and throttle roughly once per second;
-ambient temperature is refreshed every 30 poll cycles. A snapshot carries
+The native `ObdService` warms the adapter with a `0100` request after `ATSP0`,
+drops any stale input before each command and probes `5E` in the poll loop,
+retrying every 30 cycles while it is unsupported. It polls speed, fuel rate,
+MAF and throttle roughly once per second; ambient temperature is refreshed
+every 30 poll cycles. A snapshot carries
 `updatedAt`; the Dart side treats data older than five seconds as stale and
 hides the live card. Connection failures surface as a short Settings status and
 keep the dashboard quiet. A failure after three attempts sets `retryExhausted`;
@@ -230,3 +232,8 @@ coordinates are stored.
 - 2026-11-02 — Fuel economy now prefers standard PID `5E` (engine fuel rate):
   it matches the car's trip computer and is the only reliable MAF-free source
   for a diesel engine. MAF/AFR remains the petrol-only fallback.
+- 2026-11-02 — Fixed the 1.2.7 economy regression where the card stayed at
+  `—`: the one-shot supported-PID query before the poll could race the `ATSP0`
+  protocol search and shift every later response by one command. The warm-up
+  request, stale-input drain and in-loop `5E` probe keep the ELM327 stream
+  aligned and make an unsupported `5E` cost at most one probe per 30 cycles.

+ 1 - 1
lib/core/app_version.dart

@@ -1,2 +1,2 @@
 /// Keep this in sync with the `version` in pubspec.yaml.
-const String appVersion = '1.2.8';
+const String appVersion = '1.2.9';

+ 1 - 1
pubspec.yaml

@@ -1,7 +1,7 @@
 name: cardash_launcher
 description: "CarDash — a calm, configurable Android car dashboard launcher."
 publish_to: "none"
-version: 1.2.8+28
+version: 1.2.9+29
 
 environment:
   sdk: ^3.13.7

+ 36 - 0
releases/1.2.9.md

@@ -0,0 +1,36 @@
+# CarDash 1.2.9
+
+## Fuel economy is back
+
+1.2.7 moved the rolling economy to the ECU's engine-fuel-rate PID (`5E`), but
+the one-shot supported-PID query added in that release could race the `ATSP0`
+protocol search on a slow adapter and shift every later ELM327 response by one
+command. The result was a permanent `—` on the OBD card.
+
+1.2.9 fixes the serial stream:
+
+- warms the adapter with a `0100` request after `ATSP0`, with a longer timeout;
+- drains stale input before every command so a late response cannot be read as
+  the next PID's answer;
+- removes the `0140` supported-PID query and probes `5E` in the poll loop
+  instead, retrying every 30 cycles while it is unsupported.
+
+The ECU fuel-rate economy itself is unchanged: vehicles that report `5E` use it
+and match the car's trip computer; vehicles without it keep the MAF fallback.
+
+The release also includes the 1.2.8 Jellyfin album navigation work: the
+favorite heart moves to the left corner at 69 px, three separate 60 px volume
+buttons sit on the right, the cover is 30% smaller with taller track rows, every
+page has a larger back button, and both Jellyfin pages get a transparent
+top-right time / speed / temperature / Home strip.
+
+## Install
+
+Download `cardash-android-1.2.9.apk` from the release page or the repository:
+
+`https://git.krasi.net/kkazakov/car-interface-android/raw/main/releases/cardash-android-1.2.9.apk`
+
+SHA-256: `3cf73d295ebe077a94c83d1aee6a7c1b4332e9eefa3048aa76c729aa7c3c9ad5`
+
+The build is signed with the same project key as previous releases, so it
+installs over 1.2.8. Android still asks you to confirm the install.

+ 14 - 0
releases/README.md

@@ -1,5 +1,19 @@
 # CarDash releases
 
+## 1.2.9 — fuel economy serial-stream fix
+
+- **File:** `cardash-android-1.2.9.apk`
+- **Version:** 1.2.9 (versionCode 29)
+- **ABIs:** arm64-v8a, armeabi-v7a, x86_64
+- **SHA-256:** `3cf73d295ebe077a94c83d1aee6a7c1b4332e9eefa3048aa76c729aa7c3c9ad5`
+- **Signing:** project debug key matching all releases.
+- **Changes:** fixes the 1.2.7 economy regression that left the OBD card at
+  `—`. Adds a `0100` warm-up after `ATSP0`, a stale-input drain before every
+  command, removes the one-shot `0140` supported-PID query and probes `5E` in
+  the poll loop every 30 cycles while unsupported. Includes the 1.2.8 Jellyfin
+  album navigation card and header strip.
+- **Verified:** analyze, formatting, 154 tests and universal release build.
+
 ## 1.2.8 — cleaner Jellyfin album controls
 
 - **File:** `cardash-android-1.2.8.apk`

BIN
releases/cardash-android-1.2.9.apk


+ 1 - 0
releases/cardash-android-1.2.9.apk.sha256

@@ -0,0 +1 @@
+3cf73d295ebe077a94c83d1aee6a7c1b4332e9eefa3048aa76c729aa7c3c9ad5  releases/cardash-android-1.2.9.apk