浏览代码

fix: remove inaccurate rolling fuel economy

The 5E/MAF estimate did not match the car closely enough across petrol and diesel, so remove the economy calculation, PID 5E/MAF polling, model fields and card row entirely. Keep the OBD connection alive while the app is backgrounded and close it only on detach so returning from navigation keeps OBD speed instead of falling back to GPS. Bump the source to 1.2.10+30; no APK or release is built.
Krasimir Kazakov 31 分钟之前
父节点
当前提交
133eb41f15

+ 3 - 1
AGENTS.md

@@ -111,7 +111,9 @@ 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 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 `—`.
+- The rolling fuel economy was removed in 1.2.10 and must not be reintroduced as an estimate. PID `5E` is not reported by every vehicle, MAF/AFR is not accurate across petrol and diesel, and the dashboard now shows only speed and throttle. Do not add MAF or fuel-rate polling back without a verified direct source.
+- The native OBD worker warms up with `0100` after `ATSP0` and drains stale input before every command; keep that serial-stream handling. Do not add a one-shot `0140` supported-PID query before the first poll (the 1.2.7 regression).
+- The OBD connection stays alive while the app is backgrounded and is stopped only on `AppLifecycleState.detached`. This keeps OBD speed from dropping to GPS after navigation or another app; GPS and weather polling still stop on pause. Do not restore `ObdController.stop()` on `paused`/`hidden`.
 - 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.

+ 33 - 33
README.md

@@ -78,9 +78,10 @@ live vehicle data.
   level and charging bolt.
 - **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
-  fuel economy from the ECU fuel rate (with a petrol MAF fallback), plus ambient
-  temperature when the adapter provides it.
+  Bluetooth actions. The live card shows speed and a throttle bar, plus ambient
+  temperature when the adapter provides it. The adapter stays connected while
+  another app is in front, so returning from navigation does not drop OBD speed
+  to GPS.
 - **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
@@ -439,11 +440,11 @@ flutter test
 ./tool/check.sh
 ```
 
-The 154 tests cover PIN hashing/persistence, settings persistence, theme/accent,
+The 146 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
-equalizer animation, OBD economy, the OBD card, media-key playback, media
+equalizer animation, the OBD card, media-key playback, media
 metadata/progress, the marquee, the play/pause ring, fixed-volume handling,
 volume-write diagnostics, the keep-awake preference, the night overlay, GPS
 speed fallback and its permission prompt, speed/temperature source persistence,
@@ -465,24 +466,24 @@ live Chrome MediaSession. Jellyfin was verified end-to-end on API 34 against a
 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.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.
+path compiles and is covered by the parser design, but no ELM327 adapter is
+available on this machine; socket behavior still needs a car test. The current
+source is 1.2.10; the last published APK is 1.2.9. The source removes the
+rolling fuel-economy estimate because `5E` is not reported by every vehicle and
+the MAF/AFR fallback was not accurate enough across petrol and diesel. The OBD
+card now shows speed and throttle, and the adapter stays connected while the
+app is backgrounded so returning from navigation does not drop to GPS. The
+published 1.2.9 APK still 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
 
@@ -576,15 +577,14 @@ 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, 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.
+  read-only. Speed, throttle and ambient temperature are implemented; RPM,
+  coolant, voltage, MIL/DTCs, fuel economy, 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.
+- **No fuel-economy estimate.** The rolling MAF/`5E` figure was removed because
+  `5E` is not reported by every vehicle and the MAF/AFR fallback was not
+  accurate enough across petrol and diesel. The dashboard shows speed and
+  throttle only.
 - **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.
@@ -615,7 +615,7 @@ numerals and quiet line icons. It is not a copy of that UI.
 ## Roadmap
 
 - Optional compact now-playing control beside the volume card.
-- Real-car ELM327 soak test (speed/throttle/MAF/ambient).
+- Real-car ELM327 soak test (speed/throttle/ambient).
 - Release signing configuration.
 - Real-device soak test on an Android 10 car tablet.
 

+ 3 - 45
android/app/src/main/kotlin/com/wasp/cardash_launcher/ObdService.kt

@@ -16,9 +16,9 @@ import kotlin.math.min
 /**
  * Minimal read-only ELM327 client over Bluetooth Classic SPP.
  *
- * It deliberately only polls the values CarDash displays: speed, throttle, MAF
- * and ambient temperature. All writes happen through one thread; the Flutter
- * side polls [snapshot] for the latest values.
+ * It deliberately only polls the values CarDash displays: speed, throttle and
+ * ambient temperature. All writes happen through one thread; the Flutter side
+ * polls [snapshot] for the latest values.
  *
  * Connection attempts are bounded and have a watchdog. A cheap adapter that is
  * switched off or out of range must not keep the Bluetooth stack in a permanent
@@ -35,8 +35,6 @@ 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()
@@ -52,10 +50,6 @@ class ObdService(
     @Volatile private var retryExhausted = false
     @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
-    private var fuelRateMisses = 0
     @Volatile private var ambientCelsius: Double? = null
     @Volatile private var error: String? = null
     @Volatile private var lastPollAt: Long = 0L
@@ -96,8 +90,6 @@ class ObdService(
             "connecting" to connecting,
             "speedKmh" to speedKmh,
             "throttle" to throttlePercent,
-            "maf" to mafGramsPerSecond,
-            "fuelRateLph" to fuelRateLph,
             "ambient" to ambientCelsius,
             "error" to error,
             "updatedAt" to lastPollAt,
@@ -157,9 +149,6 @@ class ObdService(
                     return
                 }
 
-                fuelRateLph = null
-                fuelRateSupported = null
-                fuelRateMisses = 0
                 handshake()
                 if (!running) {
                     closeSocket()
@@ -248,43 +237,12 @@ class ObdService(
         var tick = 0
         while (running && connected) {
             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.
-            // 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 ->
-                (((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()
             tick++
             try {

+ 0 - 1
android/app/src/main/kotlin/com/wasp/cardash_launcher/TelemetryBridge.kt

@@ -138,7 +138,6 @@ class TelemetryBridge(private val activity: Activity) : MethodChannel.MethodCall
             "connecting" to false,
             "speedKmh" to null,
             "throttle" to null,
-            "maf" to null,
             "ambient" to null,
             "error" to null,
             "updatedAt" to 0L,

+ 12 - 7
docs/IMPLEMENTATION_PLAN.md

@@ -39,8 +39,7 @@ 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 economy (ECU fuel rate, MAF fallback) and ambient
-  temperature.
+  bar and ambient temperature. The rolling fuel-economy estimate was removed.
 - 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
@@ -80,8 +79,9 @@ 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`), 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. |
+| OBD values | Speed, throttle (`49`/`11`), ambient (`46`) | The requested dashboard subset; unsupported PIDs stay hidden. |
+| Fuel economy | Not shown; the rolling MAF/`5E` estimate was removed in 1.2.10 | `5E` is not universal and MAF/AFR was not accurate across fuel types. |
+| OBD lifecycle | Connection stays alive while backgrounded; closed on detach | Returning from navigation keeps OBD speed instead of falling back to GPS. |
 | 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. |
@@ -232,8 +232,7 @@ 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 (ECU fuel rate and MAF fallback); native
-  ELM327 path compiles.
+- [x] OBD card tests pass; 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,
@@ -296,7 +295,7 @@ Android (Kotlin)
    restores the app icon; a chosen stylized icon is a bundled asset and cannot
    become unavailable.
 10. **OBD hardware is untested here.** No ELM327 adapter is available on this
-    machine; the native socket/protocol path compiles and the parser/economy UI is
+    machine; the native socket/protocol path compiles and the parser/UI is
     unit-tested, but a real-car soak test is still required.
 11. **Weather depends on location, permission and network.** Any failure hides
     the row; OBD ambient is always preferred when available.
@@ -506,3 +505,9 @@ Android (Kotlin)
   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.
+- 2026-11-02: Removed the rolling fuel economy completely. `5E` is not reported
+  by every vehicle and the MAF/AFR fallback was not accurate enough across
+  petrol and diesel; the OBD card now shows speed and throttle only. The OBD
+  connection also stays alive while the app is backgrounded and is stopped only
+  on detach, so returning from navigation keeps OBD speed instead of falling
+  back to GPS. Bumped to 1.2.10+30; no APK build was requested.

+ 26 - 26
docs/OBD_TEMPERATURE.md

@@ -1,6 +1,6 @@
 # CarDash — OBD-II and temperature design note
 
-_Status: updated in CarDash 1.2.9; OBD hardware still needs a real-car test.
+_Status: updated in CarDash 1.2.10; OBD hardware still needs a real-car test.
 Last updated: 2026-11-02_
 
 ## 1. Scope
@@ -17,26 +17,23 @@ or GPS + the Open-Meteo weather provider.
 |---|---|---|
 | Vehicle speed, km/h | `0D` | Digital home readout |
 | Throttle, % | `49`, fallback `11` | Horizontal bar |
-| 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 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.
+There is no fuel-economy figure. The previous rolling estimate was removed in
+1.2.10: `5E` is not available on every vehicle, and the MAF/AFR fallback was
+not accurate enough across petrol and diesel engines to present as economy.
+The dashboard shows speed and throttle only.
 
 ### Out of scope
 
-- RPM, coolant, module voltage, MIL/DTC contents, fuel level, range.
+- Fuel economy, fuel level and range.
+- RPM, coolant, module voltage, MIL/DTC contents.
 - Mode `04`/`08` writes; CarDash is read-only.
 - Wi-Fi, USB and BLE adapters.
 - Cabin temperature.
-- Background OBD polling or background location.
+- Background location. The OBD connection is kept alive across short app
+  switches so speed does not drop to GPS; GPS and weather polling still stop
+  when the app is backgrounded.
 
 ## 2. Decisions
 
@@ -44,9 +41,9 @@ accurate figure.
 |---|---|
 | D1 | Bluetooth Classic SPP only; only devices already paired in Android Settings. No scan UI. |
 | D2 | Throttle uses accelerator `49`, falling back to plate `11`. |
-| D3 | Metric L/100 km only. |
+| D3 | No fuel-economy estimate: remove it rather than show an inaccurate figure. |
 | D4 | Small live speed card above the volume card; temperature row below the date. |
-| D5 | Rolling window resets on disconnect or a sample gap over 30 s. |
+| D5 | The OBD connection survives app backgrounding (navigation/app switches) and is closed only on detach. |
 | D6 | No fuel level or range. |
 | D7 | OBD ambient wins over weather while the OBD temperature source is selected. |
 | D8 | Speed and temperature each have an independent show/hide switch in Settings. |
@@ -89,7 +86,6 @@ Kotlin
 Dart
   NativeTelemetryBridge  platform boundary
   ObdController          connection lifecycle, 1 s snapshot polling, status
-  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
@@ -98,11 +94,11 @@ Dart
   SettingsRepository     adapter address, speed/temperature flags, weather
 ```
 
-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
+The native `ObdService` warms the adapter with a `0100` request after `ATSP0`
+and drops any stale input before each command. It polls speed and throttle
+roughly once per second; ambient temperature is refreshed every 30 poll cycles.
+The OBD connection is not closed when the app is backgrounded, so returning from
+navigation does not reconnect or fall back to GPS. 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`;
@@ -128,7 +124,6 @@ on the next resume.
   44 km/h
   Throttle                     22%
   [========------------------]
-  Rolling economy        7.5 L/100 km
   ```
 
   When the GPS source is selected (or OBD is unavailable), the card reads
@@ -201,10 +196,9 @@ coordinates are stored.
 
 ## 9. Testing
 
-- 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.
+- Dart tests cover 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
   choices and weather preference.
 - The API 35 emulator verifies the location permission path, reverse geocoding,
@@ -237,3 +231,9 @@ coordinates are stored.
   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.
+- 2026-11-02 — Removed the rolling fuel economy entirely. `5E` is not reported
+  by every vehicle and the MAF/AFR fallback was not accurate enough across
+  petrol and diesel, so the card now shows only speed and throttle. The OBD
+  connection stays alive while the app is backgrounded and is stopped only on
+  detach, so returning from navigation keeps OBD speed instead of falling back
+  to GPS.

+ 7 - 0
lib/app.dart

@@ -86,6 +86,13 @@ class _CarDashAppState extends State<CarDashApp> with WidgetsBindingObserver {
         break;
       case AppLifecycleState.hidden:
       case AppLifecycleState.paused:
+        // Keep the OBD adapter connected across navigation/app switches so the
+        // speed card does not fall back to GPS and the adapter does not
+        // reconnect from scratch. GPS and weather polling stop normally.
+        system.stop();
+        context.read<GpsSpeedController>().stop();
+        context.read<WeatherController>().stop();
+        context.read<JellyfinPlaybackController>().onAppPaused();
       case AppLifecycleState.detached:
         system.stop();
         context.read<ObdController>().stop();

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

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

@@ -5,8 +5,6 @@ class ObdState {
     this.connecting = false,
     this.speedKmh,
     this.throttlePercent,
-    this.mafGramsPerSecond,
-    this.fuelRateLph,
     this.ambientCelsius,
     this.error,
     this.updatedAt = 0,
@@ -17,10 +15,7 @@ class ObdState {
   final bool connecting;
   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;
@@ -40,8 +35,6 @@ class ObdState {
       connecting: map['connecting'] == true,
       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,

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

@@ -265,7 +265,6 @@ class _HomeScreenState extends State<HomeScreen> {
                                   ObdCard(
                                     speedKmh: obd.speedKmh,
                                     throttlePercent: obd.throttlePercent,
-                                    economyL100: obd.economyL100,
                                     compact: compact,
                                   ),
                                   SizedBox(height: compact ? 12 : 14),

+ 8 - 30
lib/features/home/obd_controller.dart

@@ -7,9 +7,8 @@ import '../../data/models/bluetooth_power_state.dart';
 import '../../data/models/obd_state.dart';
 import '../../data/repositories/settings_repository.dart';
 import '../../data/services/telemetry_bridge.dart';
-import 'obd_economy.dart';
 
-/// Connection lifecycle and rolling economy for the optional ELM327 adapter.
+/// Connection lifecycle for the optional ELM327 adapter.
 ///
 /// Bluetooth Classic SPP only; the adapter must already be paired in Android
 /// Settings. The native side queues the read-only PID requests.
@@ -20,7 +19,6 @@ class ObdController extends ChangeNotifier {
   final TelemetryPlatform _platform;
   final SettingsRepository _settings;
   final DateTime Function() _clock;
-  final ObdEconomy _economy = ObdEconomy();
 
   String? _selectedAddress;
   List<BluetoothDeviceInfo> _devices = const [];
@@ -41,7 +39,6 @@ class ObdController extends ChangeNotifier {
   bool _polling = false;
   bool _connectionFailed = false;
   bool _disposed = false;
-  int? _lastEconomyTimestamp;
 
   String? get selectedAddress => _selectedAddress;
 
@@ -129,14 +126,18 @@ class ObdController extends ChangeNotifier {
   double? get ambientCelsius =>
       _state.connected && isFresh ? _state.ambientCelsius : null;
 
-  double? get economyL100 => isFresh ? _economy.litresPer100Km : null;
-
   void initialize() {
     _selectedAddress = _settings.obdAdapterAddress;
   }
 
   Future<void> start() async {
-    if (_active) return;
+    if (_active) {
+      // The native worker keeps polling while the app is backgrounded. Refresh
+      // the Flutter snapshot immediately on resume so the speed card does not
+      // flash the GPS fallback for a second.
+      unawaited(_poll());
+      return;
+    }
     _active = true;
 
     var attempted = false;
@@ -189,8 +190,6 @@ class ObdController extends ChangeNotifier {
     _bluetoothRetryTimer?.cancel();
     _bluetoothRetryTimer = null;
     _bluetoothRetryAttempts = 0;
-    _lastEconomyTimestamp = null;
-    _economy.reset();
     _state = const ObdState();
     unawaited(_platform.disconnectObd());
     _notify();
@@ -201,8 +200,6 @@ class ObdController extends ChangeNotifier {
     _selectedAddress = address;
     _localError = null;
     _connectionFailed = false;
-    _lastEconomyTimestamp = null;
-    _economy.reset();
     _state = const ObdState();
     await _settings.setObdAdapterAddress(address);
     await _platform.disconnectObd();
@@ -226,8 +223,6 @@ class ObdController extends ChangeNotifier {
     if (_selectedAddress == null) return;
     _connectionFailed = false;
     _localError = null;
-    _lastEconomyTimestamp = null;
-    _economy.reset();
     _state = const ObdState();
     _notify();
     await _platform.disconnectObd();
@@ -427,11 +422,6 @@ class ObdController extends ChangeNotifier {
     _polling = true;
     try {
       final next = await _platform.getObdState();
-      final lostConnection = _state.connected && !next.connected;
-      if (lostConnection) {
-        _economy.reset();
-        _lastEconomyTimestamp = null;
-      }
       _state = next;
       if (next.connected) {
         _connectionFailed = false;
@@ -441,18 +431,6 @@ class ObdController extends ChangeNotifier {
         _pollTimer?.cancel();
         _pollTimer = null;
       }
-      if (next.connected && next.updatedAt > 0) {
-        if (_lastEconomyTimestamp != next.updatedAt) {
-          _lastEconomyTimestamp = next.updatedAt;
-          _economy.add(
-            at: _clock(),
-            speedKmh: next.speedKmh,
-            mafGps: next.mafGramsPerSecond,
-            throttlePercent: next.throttlePercent,
-            fuelRateLph: next.fuelRateLph,
-          );
-        }
-      }
     } catch (_) {
       // Keep the last known state; the next poll may recover.
     } finally {

+ 0 - 113
lib/features/home/obd_economy.dart

@@ -1,113 +0,0 @@
-/// Rolling 5 km fuel economy calculated from the ECU fuel rate or MAF.
-///
-/// 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;
-  bool _hasPrevious = false;
-
-  double? get litresPer100Km {
-    if (_segments.isEmpty) return null;
-    var km = 0.0;
-    var litres = 0.0;
-    for (final segment in _segments) {
-      km += segment.km;
-      litres += segment.litres;
-    }
-    if (km < minimumKm || km <= 0) return null;
-    return (100 * litres / km).clamp(0.0, 99.9);
-  }
-
-  double get accumulatedKm {
-    return _segments.fold(0, (total, segment) => total + segment.km);
-  }
-
-  void reset() {
-    _segments.clear();
-    _lastAt = null;
-    _hasPrevious = false;
-  }
-
-  void add({
-    required DateTime at,
-    required double? speedKmh,
-    required double? mafGps,
-    required double? throttlePercent,
-    double? fuelRateLph,
-  }) {
-    final previousAt = _lastAt;
-    _lastAt = at;
-
-    // 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 ||
-        !_hasPrevious ||
-        at.difference(previousAt) > _maxGap ||
-        at.isBefore(previousAt)) {
-      if (previousAt != null && at.difference(previousAt) > _maxGap) {
-        reset();
-      }
-      _lastAt = at;
-      _hasPrevious = true;
-      return;
-    }
-
-    final deltaSeconds = at.difference(previousAt).inMilliseconds / 1000.0;
-    if (deltaSeconds <= 0) return;
-    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(
-        _Segment(km: km, litres: litresPerHour * deltaSeconds / 3600),
-      );
-      _trim();
-    }
-    _lastAt = at;
-    _hasPrevious = true;
-  }
-
-  void _trim() {
-    var km = accumulatedKm;
-    while (km > windowKm && _segments.isNotEmpty) {
-      km -= _segments.removeAt(0).km;
-    }
-  }
-}
-
-class _Segment {
-  const _Segment({required this.km, required this.litres});
-
-  final double km;
-  final double litres;
-}

+ 0 - 23
lib/features/home/widgets/obd_card.dart

@@ -10,13 +10,11 @@ class ObdCard extends StatelessWidget {
     super.key,
     required this.speedKmh,
     required this.throttlePercent,
-    required this.economyL100,
     this.compact = false,
   });
 
   final double? speedKmh;
   final double? throttlePercent;
-  final double? economyL100;
   final bool compact;
 
   @override
@@ -125,27 +123,6 @@ class ObdCard extends StatelessWidget {
               },
             ),
           ),
-          SizedBox(height: compact ? 7 : 9),
-          Row(
-            children: [
-              Text(
-                'Rolling economy',
-                style: theme.textTheme.bodySmall?.copyWith(
-                  color: colors.inkMuted,
-                ),
-              ),
-              const Spacer(),
-              Text(
-                economyL100 == null
-                    ? '—'
-                    : '${economyL100!.toStringAsFixed(1)} L/100 km',
-                style: theme.textTheme.labelMedium?.copyWith(
-                  color: colors.ink,
-                  fontFeatures: const [FontFeature.tabularFigures()],
-                ),
-              ),
-            ],
-          ),
         ],
       ),
     );

+ 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.9+29
+version: 1.2.10+30
 
 environment:
   sdk: ^3.13.7

+ 3 - 7
test/obd_card_test.dart

@@ -4,7 +4,7 @@ import 'package:flutter/material.dart';
 import 'package:flutter_test/flutter_test.dart';
 
 void main() {
-  testWidgets('OBD card renders speed, throttle and economy', (tester) async {
+  testWidgets('OBD card renders speed and throttle', (tester) async {
     await tester.pumpWidget(
       MaterialApp(
         theme: AppTheme.dark(),
@@ -12,11 +12,7 @@ void main() {
           body: Center(
             child: SizedBox(
               width: 380,
-              child: ObdCard(
-                speedKmh: 44,
-                throttlePercent: 22,
-                economyL100: 7.5,
-              ),
+              child: ObdCard(speedKmh: 44, throttlePercent: 22),
             ),
           ),
         ),
@@ -28,6 +24,6 @@ void main() {
     expect(find.text('44'), findsOneWidget);
     expect(find.text('km/h'), findsOneWidget);
     expect(find.text('Throttle'), findsOneWidget);
-    expect(find.text('7.5 L/100 km'), findsOneWidget);
+    expect(find.text('22%'), findsOneWidget);
   });
 }

+ 0 - 10
test/obd_controller_test.dart

@@ -245,14 +245,4 @@ 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);
-  });
 }

+ 0 - 110
test/obd_economy_test.dart

@@ -1,110 +0,0 @@
-import 'package:cardash_launcher/features/home/obd_economy.dart';
-import 'package:flutter_test/flutter_test.dart';
-
-void main() {
-  test('MAF and speed produce a rolling L/100 km value', () {
-    final economy = ObdEconomy();
-    final start = DateTime(2026, 10, 29, 12);
-    for (var i = 0; i <= 40; i++) {
-      economy.add(
-        at: start.add(Duration(seconds: i)),
-        speedKmh: 60,
-        mafGps: 12,
-        throttlePercent: 20,
-      );
-    }
-    expect(economy.accumulatedKm, closeTo(40 * 60 / 3600, 0.12));
-    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);
-    for (var i = 0; i < 10; i++) {
-      economy.add(
-        at: start.add(Duration(seconds: i)),
-        speedKmh: 0,
-        mafGps: 4,
-        throttlePercent: 2,
-      );
-    }
-    expect(economy.litresPer100Km, isNull);
-  });
-
-  test('fuel cut while coasting reads zero consumption', () {
-    final economy = ObdEconomy();
-    final start = DateTime(2026);
-    for (var i = 0; i <= 35; i++) {
-      economy.add(
-        at: start.add(Duration(seconds: i)),
-        speedKmh: 60,
-        mafGps: 12,
-        throttlePercent: 0,
-      );
-    }
-    expect(economy.litresPer100Km, 0);
-  });
-
-  test('reset clears the rolling window', () {
-    final economy = ObdEconomy();
-    final start = DateTime(2026);
-    for (var i = 0; i <= 35; i++) {
-      economy.add(
-        at: start.add(Duration(seconds: i)),
-        speedKmh: 60,
-        mafGps: 12,
-        throttlePercent: 20,
-      );
-    }
-    expect(economy.litresPer100Km, isNotNull);
-    economy.reset();
-    expect(economy.litresPer100Km, isNull);
-    expect(economy.accumulatedKm, 0);
-  });
-}