Sfoglia il codice sorgente

fix: use ECU fuel rate for accurate economy

Prefer standard PID 5E (engine fuel rate) over a MAF/AFR estimate so the rolling economy follows the car's trip computer, including on diesel engines where stoichiometric MAF overestimates fuel. Keep the petrol MAF fallback for vehicles without 5E, check the supported-PID bitmap before polling, and sample speed, fuel rate and MAF together. Bump to 1.2.7+27 and add the universal release APK.
Krasimir Kazakov 4 giorni fa
parent
commit
8b08b1e526

+ 1 - 0
AGENTS.md

@@ -111,6 +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.
 - 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.

+ 20 - 12
README.md

@@ -79,7 +79,8 @@ live vehicle data.
 - **OBD-II car data** — optional Bluetooth Classic ELM327 adapter. A dedicated
   **OBD-II** Settings card holds the adapter status, paired-device picker and
   Bluetooth actions. The live card shows speed, a throttle bar and rolling 5 km
-  MAF economy, plus ambient temperature when the adapter provides it.
+  fuel economy from the ECU fuel rate (with a petrol MAF fallback), plus ambient
+  temperature when the adapter provides it.
 - **Speed card with source choice** — Settings → **Speed** shows or hides the
   speed card and, while an adapter is selected, chooses OBD-II or GPS. GPS is
   the fallback when OBD speed is off or unavailable; it reads 0 km/h when
@@ -433,7 +434,7 @@ flutter test
 ./tool/check.sh
 ```
 
-The 150 tests cover PIN hashing/persistence, settings persistence, theme/accent,
+The 154 tests cover PIN hashing/persistence, settings persistence, theme/accent,
 dark accent tinting, battery visibility, first-run state, the ten-minute
 onboarding hint, slot visibility, pinned apps, custom labels/icons, the
 16-button cap at 1280×800, 1280×720 and 1024×600, the dense shortcut grid, the
@@ -460,12 +461,14 @@ 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.6
-universal APK is the current published release. It uses the stacked album
-layout at every resolution (cover and controls above the track list), shows the
-album year, and includes 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,
+is available on this machine; socket behavior still needs a car test. The 1.2.7
+universal APK is the current published release. It prefers the ECU fuel rate
+for the rolling economy, which fixes the high reading on diesel cars, and keeps
+the MAF fallback for vehicles without PID `5E`. It also includes 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
@@ -563,10 +566,15 @@ Android 15 AVD is left running headlessly after development; use
   with GPS as the source asks for location permission. Stale/no-fix GPS values
   are hidden.
 - **OBD-II is a subset**: Bluetooth Classic SPP only, paired devices only,
-  read-only. Speed, throttle, MAF economy and ambient temperature are implemented;
-  RPM, coolant, voltage, MIL/DTCs, fuel level and BLE/Wi-Fi adapters are not.
-  The adapter list is class/name filtered, and connections stop after three
-  failures so an absent adapter cannot keep the Bluetooth stack busy.
+  read-only. Speed, throttle, ECU fuel rate, MAF economy and ambient temperature
+  are implemented; RPM, coolant, voltage, MIL/DTCs, fuel level and BLE/Wi-Fi
+  adapters are not. The adapter list is class/name filtered, and connections
+  stop after three failures so an absent adapter cannot keep the Bluetooth stack
+  busy.
+- **Fuel economy prefers the ECU fuel rate** (`5E`), which follows the car's own
+  trip computer and works for diesel. A vehicle that does not report `5E` falls
+  back to a petrol MAF/AFR estimate; that fallback is not suitable for a diesel
+  engine.
 - **Temperature source** is selectable only while an OBD adapter is selected.
   Weather fallback uses Open-Meteo and the device location; it requires location
   permission and network access, and hides the row when unavailable.

+ 30 - 2
android/app/src/main/kotlin/com/wasp/cardash_launcher/ObdService.kt

@@ -51,6 +51,8 @@ class ObdService(
     @Volatile private var speedKmh: Double? = null
     @Volatile private var throttlePercent: Double? = null
     @Volatile private var mafGramsPerSecond: Double? = null
+    @Volatile private var fuelRateLph: Double? = null
+    @Volatile private var fuelRateSupported: Boolean? = null
     @Volatile private var ambientCelsius: Double? = null
     @Volatile private var error: String? = null
     @Volatile private var lastPollAt: Long = 0L
@@ -92,6 +94,7 @@ class ObdService(
             "speedKmh" to speedKmh,
             "throttle" to throttlePercent,
             "maf" to mafGramsPerSecond,
+            "fuelRateLph" to fuelRateLph,
             "ambient" to ambientCelsius,
             "error" to error,
             "updatedAt" to lastPollAt,
@@ -151,7 +154,10 @@ class ObdService(
                     return
                 }
 
+                fuelRateLph = null
+                fuelRateSupported = null
                 handshake()
+                detectFuelRateSupport()
                 if (!running) {
                     closeSocket()
                     return
@@ -220,7 +226,7 @@ class ObdService(
 
     private fun handshake() {
         var responded = false
-        for (command in listOf("ATZ", "ATE0", "ATL0", "ATS0", "ATSP0")) {
+        for (command in listOf("ATZ", "ATE0", "ATL0", "ATS0", "ATH0", "ATSP0")) {
             send(command)
             val response = readResponse(if (command == "ATZ") 3_000L else 2_000L)
             if (response.isNotBlank()) responded = true
@@ -234,14 +240,26 @@ class ObdService(
         var tick = 0
         while (running && connected) {
             val speed = readPid("010D", 1)?.firstOrNull()?.toInt()?.and(0xFF)?.toDouble()
-            val throttle = readThrottle()
+            // 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) {
+                readPid("015E", 2)?.let { bytes ->
+                    (((bytes[0].toInt() and 0xFF) shl 8) or (bytes[1].toInt() and 0xFF)) / 20.0
+                }
+            } else {
+                null
+            }
+            // 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 ->
                 (((bytes[0].toInt() and 0xFF) shl 8) or (bytes[1].toInt() and 0xFF)) / 100.0
             }
+            val throttle = readThrottle()
             if (tick % AMBIENT_EVERY == 0) {
                 ambientCelsius = readAmbient()
             }
             speedKmh = speed
+            fuelRateLph = fuelRate
             throttlePercent = throttle
             mafGramsPerSecond = maf
             lastPollAt = System.currentTimeMillis()
@@ -255,6 +273,16 @@ 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

+ 10 - 4
docs/IMPLEMENTATION_PLAN.md

@@ -39,7 +39,8 @@ compatibility:
 - Long-pressing a pinned app opens Rename, Change icon and Remove. Rename/icon
   changes persist; Remove needs no confirmation.
 - Optional OBD-II over a paired Bluetooth Classic ELM327: digital speed, throttle
-  bar, rolling 5 km MAF economy and ambient temperature.
+  bar, rolling 5 km economy (ECU fuel rate, MAF fallback) and ambient
+  temperature.
 - Speed card with a show/hide switch and, while OBD is selected, an
   OBD-II/GPS source switch. GPS is the fallback; stationary shows 0 km/h.
 - Temperature row with a show/hide switch and, while OBD is selected, an
@@ -79,8 +80,8 @@ compatibility:
 | Pinned editing | Long-press menu: Rename / Change icon / Remove | Keeps the home page clean; removal remains immediate. |
 | Custom icon source | Default app icon plus 29 bundled monochrome line icons | Matches the stylized dashboard and follows light/dark theme colors. |
 | OBD transport | Bluetooth Classic SPP, bonded ELM327 only, read-only | No scanning, BLE, USB or Wi-Fi; one native worker serializes requests. |
-| OBD values | Speed, throttle (`49`/`11`), MAF economy, ambient (`46`) | The requested dashboard subset; unsupported PIDs stay hidden. |
-| Economy | 5 km MAF rolling window, petrol constants, fuel-cut guard | Matches the design note; show `—` until 0.5 km. |
+| OBD values | Speed, throttle (`49`/`11`), fuel rate (`5E`), MAF (`10`), ambient (`46`) | The requested dashboard subset; unsupported PIDs stay hidden. |
+| Economy | 5 km rolling window; ECU fuel rate first, petrol MAF fallback with fuel-cut guard | Matches the car's trip computer on vehicles that report `5E`; show `—` until 0.5 km. |
 | Weather fallback | Open-Meteo + one-shot device location via the Temperature card | No API key/account; hide the row on any failure. |
 | GPS speed fallback | Poll GPS when speed source is GPS, OBD is unavailable, or the Speed card is off | Asks for location when speed is enabled with GPS as the source; 0 below 2 km/h to suppress stationary drift. |
 | Speed source | OBD-II preferred while connected; GPS fallback, switch in Settings | The user can force GPS even with an adapter connected. |
@@ -227,7 +228,8 @@ Android (Kotlin)
 - [x] Change icon offers Default plus 29 monochrome icons and applies the chosen icon.
 - [x] Accent-tinted dark background changes from green to blue when Blue is chosen.
 - [x] OBD Settings device dropdown and Bluetooth permission action are present.
-- [x] OBD economy/card tests pass; native ELM327 path compiles.
+- [x] OBD economy/card tests pass (ECU fuel rate and MAF fallback); native
+  ELM327 path compiles.
 - [x] Location permission, reverse geocoding and Open-Meteo fallback verified live (Ukiah, 17°C).
 - [x] Temperature row renders below the date and follows the selected OBD/weather source.
 - [x] Circled ring shows song progress; 44 tests include media keys, metadata,
@@ -490,3 +492,7 @@ Android (Kotlin)
 - 2026-11-01: Verified the FYT/Syu MCU volume bridge on the real car (Android 13
   Chinese firmware): the on-screen slider changes the amplifier, follows the
   car's own volume control, and no longer snaps back to 100%. No code change.
+- 2026-11-02: Fuel economy now prefers the ECU's engine-fuel-rate PID (`5E`)
+  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.

+ 20 - 9
docs/OBD_TEMPERATURE.md

@@ -1,7 +1,7 @@
 # CarDash — OBD-II and temperature design note
 
-_Status: updated in CarDash 1.0.6; OBD hardware still needs a real-car test.
-Last updated: 2026-10-31_
+_Status: updated in CarDash 1.2.7; OBD hardware still needs a real-car test.
+Last updated: 2026-11-02_
 
 ## 1. Scope
 
@@ -17,12 +17,18 @@ or GPS + the Open-Meteo weather provider.
 |---|---|---|
 | Vehicle speed, km/h | `0D` | Digital home readout |
 | Throttle, % | `49`, fallback `11` | Horizontal bar |
-| MAF, g/s | `10` | Rolling fuel-economy input |
+| Engine fuel rate, L/h | `5E` | Preferred fuel-economy input |
+| MAF, g/s | `10` | Fuel-economy fallback input |
 | Ambient temperature, °C | `46` | OBD temperature row |
 
-The economy figure is a **5 km rolling average** in L/100 km. It uses petrol
-constants (stoichiometric AFR 14.7, 745 g/L), ignores MAF on a closed-throttle
-coast above 10 km/h, and shows `—` until at least 0.5 km has accumulated.
+The economy figure is a **5 km rolling average** in L/100 km. It prefers the
+ECU's engine-fuel-rate PID (`5E`), which follows the same injection data as the
+car's trip computer and works for petrol and diesel. If the vehicle does not
+report `5E`, it falls back to MAF with petrol constants (stoichiometric AFR
+14.7, 745 g/L), ignores MAF on a closed-throttle coast above 10 km/h, and shows
+`—` until at least 0.5 km has accumulated. The MAF fallback is an estimate and
+is not suitable for a diesel engine; such a vehicle needs PID `5E` for an
+accurate figure.
 
 ### Out of scope
 
@@ -83,7 +89,7 @@ Kotlin
 Dart
   NativeTelemetryBridge  platform boundary
   ObdController          connection lifecycle, 1 s snapshot polling, status
-  ObdEconomy             MAF/speed integrator with 5 km window
+  ObdEconomy             fuel-rate or MAF/speed integrator with 5 km window
   TelemetrySettingsController
                          persisted speed/temperature visibility and source
   WeatherController      permission, one-shot location, 15 min weather cache
@@ -92,7 +98,8 @@ Dart
   SettingsRepository     adapter address, speed/temperature flags, weather
 ```
 
-The native `ObdService` polls speed, throttle and MAF roughly once per second;
+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
 `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
@@ -192,7 +199,8 @@ coordinates are stored.
 
 ## 9. Testing
 
-- Dart unit tests cover the rolling economy math, the OBD card, adapter
+- Dart unit tests cover the rolling economy math (ECU fuel rate, zero fuel
+  rate, implausible fuel rate and the MAF fallback), the OBD card, adapter
   classification/filtering, bounded-retry state, resume caching and the
   speed/temperature preference controller.
 - Settings persistence covers the adapter address, visibility switches, source
@@ -219,3 +227,6 @@ coordinates are stored.
   all Bluetooth binder calls to a serialized worker, filtered the adapter picker,
   bounded connection attempts to three, added a 12 s connect watchdog and made
   resume reuse the cached bonded list.
+- 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.

+ 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.6';
+const String appVersion = '1.2.7';

+ 5 - 0
lib/data/models/obd_state.dart

@@ -6,6 +6,7 @@ class ObdState {
     this.speedKmh,
     this.throttlePercent,
     this.mafGramsPerSecond,
+    this.fuelRateLph,
     this.ambientCelsius,
     this.error,
     this.updatedAt = 0,
@@ -17,6 +18,9 @@ class ObdState {
   final double? speedKmh;
   final double? throttlePercent;
   final double? mafGramsPerSecond;
+
+  /// ECU-reported engine fuel rate (PID `5E`), when the vehicle supports it.
+  final double? fuelRateLph;
   final double? ambientCelsius;
   final String? error;
   final int updatedAt;
@@ -37,6 +41,7 @@ class ObdState {
       speedKmh: number('speedKmh'),
       throttlePercent: number('throttle'),
       mafGramsPerSecond: number('maf'),
+      fuelRateLph: number('fuelRateLph'),
       ambientCelsius: number('ambient'),
       error: map['error'] as String?,
       updatedAt: (map['updatedAt'] as num?)?.toInt() ?? 0,

+ 1 - 0
lib/features/home/obd_controller.dart

@@ -449,6 +449,7 @@ class ObdController extends ChangeNotifier {
             speedKmh: next.speedKmh,
             mafGps: next.mafGramsPerSecond,
             throttlePercent: next.throttlePercent,
+            fuelRateLph: next.fuelRateLph,
           );
         }
       }

+ 35 - 21
lib/features/home/obd_economy.dart

@@ -1,18 +1,21 @@
-/// Rolling 5 km fuel economy calculated from MAF, speed and throttle.
+/// Rolling 5 km fuel economy calculated from the ECU fuel rate or MAF.
 ///
-/// Petrol constants: stoichiometric AFR 14.7 and 745 g/L. This is deliberately
-/// an estimate; the dashboard presents it as a trend, not a measurement.
+/// The ECU's engine-fuel-rate PID (`5E`) is preferred because it uses the same
+/// injection data as the car's trip computer and works for petrol and diesel.
+/// When that PID is unavailable, MAF and petrol stoichiometric constants
+/// (AFR 14.7, 745 g/L) are used as a fallback. The fallback is deliberately an
+/// estimate; the dashboard presents it as a trend, not a measurement.
 class ObdEconomy {
   static const windowKm = 5.0;
   static const minimumKm = 0.5;
   static const _maxGap = Duration(seconds: 30);
   static const _afr = 14.7;
   static const _fuelDensityGramsPerLitre = 745.0;
+  static const _maxPlausibleFuelRateLph = 200.0;
 
   final List<_Segment> _segments = [];
   DateTime? _lastAt;
-  double? _lastSpeedKmh;
-  double? _lastMafGps;
+  bool _hasPrevious = false;
 
   double? get litresPer100Km {
     if (_segments.isEmpty) return null;
@@ -33,8 +36,7 @@ class ObdEconomy {
   void reset() {
     _segments.clear();
     _lastAt = null;
-    _lastSpeedKmh = null;
-    _lastMafGps = null;
+    _hasPrevious = false;
   }
 
   void add({
@@ -42,35 +44,48 @@ class ObdEconomy {
     required double? speedKmh,
     required double? mafGps,
     required double? throttlePercent,
+    double? fuelRateLph,
   }) {
     final previousAt = _lastAt;
     _lastAt = at;
-    if (speedKmh == null || mafGps == null) {
-      _lastSpeedKmh = null;
-      _lastMafGps = null;
+
+    // A supported PID can briefly return a placeholder or a corrupt frame.
+    // Ignore implausible values and fall back to MAF for this sample.
+    final directFuelRate =
+        fuelRateLph != null &&
+            fuelRateLph >= 0 &&
+            fuelRateLph <= _maxPlausibleFuelRateLph
+        ? fuelRateLph
+        : null;
+
+    if (speedKmh == null || (directFuelRate == null && mafGps == null)) {
+      _hasPrevious = false;
       return;
     }
     if (previousAt == null ||
-        _lastSpeedKmh == null ||
-        _lastMafGps == null ||
+        !_hasPrevious ||
         at.difference(previousAt) > _maxGap ||
         at.isBefore(previousAt)) {
       if (previousAt != null && at.difference(previousAt) > _maxGap) {
         reset();
       }
       _lastAt = at;
-      _lastSpeedKmh = speedKmh;
-      _lastMafGps = mafGps;
+      _hasPrevious = true;
       return;
     }
 
     final deltaSeconds = at.difference(previousAt).inMilliseconds / 1000.0;
     if (deltaSeconds <= 0) return;
-    final fuelCut =
-        throttlePercent != null && throttlePercent < 1 && speedKmh > 10;
-    final litresPerHour = fuelCut
-        ? 0.0
-        : (mafGps / _afr) * 3600 / _fuelDensityGramsPerLitre;
+    final double litresPerHour;
+    if (directFuelRate != null) {
+      litresPerHour = directFuelRate;
+    } else {
+      final fuelCut =
+          throttlePercent != null && throttlePercent < 1 && speedKmh > 10;
+      litresPerHour = fuelCut
+          ? 0.0
+          : (mafGps! / _afr) * 3600 / _fuelDensityGramsPerLitre;
+    }
     final km = speedKmh * deltaSeconds / 3600;
     if (km > 0) {
       _segments.add(
@@ -79,8 +94,7 @@ class ObdEconomy {
       _trim();
     }
     _lastAt = at;
-    _lastSpeedKmh = speedKmh;
-    _lastMafGps = mafGps;
+    _hasPrevious = true;
   }
 
   void _trim() {

+ 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.6+26
+version: 1.2.7+27
 
 environment:
   sdk: ^3.13.7

+ 30 - 0
releases/1.2.7.md

@@ -0,0 +1,30 @@
+# CarDash 1.2.7
+
+## Fuel economy follows the car
+
+The rolling 5 km economy now prefers the ECU's engine-fuel-rate PID (`5E`)
+instead of always estimating fuel from MAF and a petrol stoichiometric ratio.
+That is the same injection data the car's trip computer uses, and it works for
+diesel engines — which run lean and were the reason the MAF estimate read much
+higher than the dash.
+
+- If the vehicle reports `5E`, its fuel rate drives the economy figure.
+- If it does not, CarDash keeps the MAF/AFR fallback for petrol cars and shows
+  the same estimate as before.
+- The native worker checks the supported-PID bitmap first, so a vehicle without
+  `5E` does not pay an extra timeout on every poll.
+- Speed, fuel rate and MAF are sampled next to each other to reduce transient
+  pairing error.
+
+No settings change and no migration are required.
+
+## Install
+
+Download `cardash-android-1.2.7.apk` from the release page or the repository:
+
+`https://git.krasi.net/kkazakov/car-interface-android/raw/main/releases/cardash-android-1.2.7.apk`
+
+SHA-256: `2bd589f7fb503c46e56ddcc872454d76ac1f3a64f7e5d5c8841b23efaa8e50c7`
+
+The build is signed with the same project key as previous releases, so it
+installs over 1.2.6. Android still asks you to confirm the install.

+ 13 - 0
releases/README.md

@@ -1,5 +1,18 @@
 # CarDash releases
 
+## 1.2.7 — fuel economy follows the car
+
+- **File:** `cardash-android-1.2.7.apk`
+- **Version:** 1.2.7 (versionCode 27)
+- **ABIs:** arm64-v8a, armeabi-v7a, x86_64
+- **SHA-256:** `2bd589f7fb503c46e56ddcc872454d76ac1f3a64f7e5d5c8841b23efaa8e50c7`
+- **Signing:** project debug key matching all releases.
+- **Changes:** the rolling 5 km economy prefers the ECU's engine-fuel-rate PID
+  (`5E`), which follows the car's trip computer and works for diesel. Vehicles
+  without `5E` keep the petrol MAF/AFR fallback. Also samples speed, fuel rate
+  and MAF next to each other and checks the supported-PID bitmap first.
+- **Verified:** analyze, formatting, 154 tests and universal release build.
+
 ## 1.2.6 — stacked Jellyfin album layout at every resolution
 
 - **File:** `cardash-android-1.2.6.apk`

BIN
releases/cardash-android-1.2.7.apk


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

@@ -0,0 +1 @@
+2bd589f7fb503c46e56ddcc872454d76ac1f3a64f7e5d5c8841b23efaa8e50c7  releases/cardash-android-1.2.7.apk

+ 10 - 0
test/obd_controller_test.dart

@@ -245,4 +245,14 @@ void main() {
     });
     expect(state.retryExhausted, isTrue);
   });
+
+  test('parses the ECU fuel rate from the native snapshot', () {
+    final state = ObdState.fromMap({
+      'connected': true,
+      'speedKmh': 60,
+      'maf': 12.0,
+      'fuelRateLph': 4.25,
+    });
+    expect(state.fuelRateLph, 4.25);
+  });
 }

+ 46 - 0
test/obd_economy_test.dart

@@ -17,6 +17,52 @@ void main() {
     expect(economy.litresPer100Km, closeTo(6.6, 0.2));
   });
 
+  test('ECU fuel rate takes precedence over MAF', () {
+    final economy = ObdEconomy();
+    final start = DateTime(2026);
+    for (var i = 0; i <= 40; i++) {
+      economy.add(
+        at: start.add(Duration(seconds: i)),
+        speedKmh: 60,
+        mafGps: 12,
+        throttlePercent: 20,
+        fuelRateLph: 4,
+      );
+    }
+    // 4 L/h at 60 km/h is 6.67 L/100 km, not the 6.57 the MAF fallback gives.
+    expect(economy.litresPer100Km, closeTo(6.67, 0.02));
+  });
+
+  test('ECU zero fuel rate during a fuel cut is kept', () {
+    final economy = ObdEconomy();
+    final start = DateTime(2026);
+    for (var i = 0; i <= 40; i++) {
+      economy.add(
+        at: start.add(Duration(seconds: i)),
+        speedKmh: 60,
+        mafGps: 12,
+        throttlePercent: 20,
+        fuelRateLph: 0,
+      );
+    }
+    expect(economy.litresPer100Km, 0);
+  });
+
+  test('implausible ECU fuel rate falls back to MAF', () {
+    final economy = ObdEconomy();
+    final start = DateTime(2026);
+    for (var i = 0; i <= 40; i++) {
+      economy.add(
+        at: start.add(Duration(seconds: i)),
+        speedKmh: 60,
+        mafGps: 12,
+        throttlePercent: 20,
+        fuelRateLph: 500,
+      );
+    }
+    expect(economy.litresPer100Km, closeTo(6.6, 0.2));
+  });
+
   test('idle yields no economy value', () {
     final economy = ObdEconomy();
     final start = DateTime(2026);