diff --git a/SPECS.md b/SPECS.md index f6489e7..de5b5e6 100644 --- a/SPECS.md +++ b/SPECS.md @@ -118,6 +118,14 @@ tyre pressures, GPS (lat, lon, elevation, heading) and the is_* flags. It does * confirmed car facts, app structure, UI conventions, build/test/ship, documents. It replaces upstream's EVABRPUploader text. The test emulator is now in the flake: `nix run .#car-emulator` (Android 9 AOSP at 1920×720, 160 dpi; the car build installs as the system user — checked). +- 2026-10-23 — App 2.19.7: AC charging is metered too, while the app runs (owner's choice; Fatih + had metered DC only, `e9bfa12`, because an overnight AC charge is slept through). A session runs + whenever power flows into the pack (`ChargingState.isMeteredCharging`: inbound > 0.3 kW and a + charge by `isCharging`), and its curve takes every inbound sample. Gaps where the head unit + slept are dropped, not billed, so an overnight AC session shows the awake part only. The label + says "AC charging" / "DC fast charging"; `isDcCharging` stays for it and ABRP's `is_dcfc`. Server + comment, server README and the Grafana charging dashboard's texts no longer say DC only (the + dashboard ConfigMap changes with them: re-apply `k8s/`). Unit tests; not tried on the car. - 2026-10-23 — App 2.19.6: the CarPlay full-screen switch and the "Export CarPlay data" button are removed (CarPlay's size is fixed, see below). On start, the car build takes the CarPlay entry out of `policy_control` if the switch was left on, and leaves anything else there alone — checked on diff --git a/app/build.gradle b/app/build.gradle index 80cd101..bc684e3 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -34,8 +34,8 @@ android { applicationId "com.stinnesbeck.nilsmg4" minSdk 27 targetSdk 36 - versionCode 105 - versionName "2.19.6" + versionCode 106 + versionName "2.19.7" // Nothing about the server ships in the APK: its URL and ingest token are entered in // the app's Server tab (ServerConfig), and the database is only reachable by the server. diff --git a/app/src/main/java/com/evsuite/abrp/ChargeSessionTracker.java b/app/src/main/java/com/evsuite/abrp/ChargeSessionTracker.java index f204f92..6dd21d1 100644 --- a/app/src/main/java/com/evsuite/abrp/ChargeSessionTracker.java +++ b/app/src/main/java/com/evsuite/abrp/ChargeSessionTracker.java @@ -14,9 +14,10 @@ import java.util.List; import java.util.UUID; /** - * Persistent DC charging session meter and offline upload queue. + * Persistent charging session meter (AC and DC) and offline upload queue. * - * Only DC is metered — see {@link ChargingState#DC_MIN_KW}. Energy and duration are + * A session is metered while power flows in — see {@link ChargingState#isMeteredCharging} — + * and only while the app runs: gaps are dropped, not billed. Energy and duration are * integrated on {@link SystemClock#elapsedRealtime()} for the same reason * {@link ConsumptionTracker} is: the head unit's wall clock jumps when it picks up GPS or * NTP time, and one forward jump mid-charge used to invent both kWh and hours. Wall-clock @@ -104,7 +105,7 @@ final class ChargeSessionTracker { long elapsed = SystemClock.elapsedRealtime(); Float power = snapshot.getBatteryPowerKw(); Float soc = snapshot.getSocPercent(); - boolean charging = ChargingState.isDcCharging(snapshot.getChargingStatus(), + boolean charging = ChargingState.isMeteredCharging(snapshot.getChargingStatus(), snapshot.getChargePortConnected(), power, snapshot.getSpeedKmh()); if (charging) { @@ -237,7 +238,7 @@ final class ChargeSessionTracker { } if (soc != null) lastSoc = soc; if (power != null) lastPower = power; - if (power != null && -power >= ChargingState.DC_MIN_KW && + if (power != null && power < ChargingState.CHARGING_POWER_KW && (points.isEmpty() || nowMs - points.get(points.size() - 1).timestampMs >= curveIntervalMs)) { points.add(new Point(nowMs, soc, Math.abs(power))); decimateIfFull(); diff --git a/app/src/main/java/com/evsuite/abrp/ChargingState.java b/app/src/main/java/com/evsuite/abrp/ChargingState.java index b91695f..ca6511d 100644 --- a/app/src/main/java/com/evsuite/abrp/ChargingState.java +++ b/app/src/main/java/com/evsuite/abrp/ChargingState.java @@ -31,12 +31,9 @@ final class ChargingState { * @param speedKmh road speed, null when unreadable */ /** - * Inbound power at or above which a charge is metered as a session. - * - * AC charging on this car tops out around 7 kW; anything past 10 kW can only be DC. - * The session meter only runs on DC by the owner's choice: an AC charge is an - * overnight affair the head unit sleeps through, so its curve, its duration and its - * energy total are all guesses, whereas a DC stop happens with the car awake. + * Inbound power at or above which a charge is DC: AC charging on this car tops out + * around 7 kW, so anything past 10 kW can only be DC. Used for the "DC fast charging" + * label and ABRP's {@code is_dcfc}, no longer to decide what is metered. */ static final float DC_MIN_KW = 10f; @@ -53,7 +50,21 @@ final class ChargingState { } /** - * Is this a DC fast charge — the only kind the session meter records? + * Should the session meter run? Any charge, AC or DC, with power actually flowing in. + * + * Since 2.19.7 AC is metered too, while the app sees it (owner's choice, 2026-10-23): + * an overnight AC charge the head unit sleeps through shows only the awake part, because + * the meter drops gaps rather than bill them (ChargeSessionTracker.MAX_STEP_HOURS). + * Stricter than {@link #isCharging}: it gates a measurement, so an unreadable power value, + * or a status code still saying "charging" with no power flowing, is a no. + */ + static boolean isMeteredCharging(Integer status, Boolean portConnected, Float powerKw, Float speedKmh) { + if (powerKw == null || !Float.isFinite(powerKw) || powerKw >= CHARGING_POWER_KW) return false; + return isCharging(status, portConnected, powerKw, speedKmh); + } + + /** + * Is this a DC fast charge? * * Deliberately stricter than {@link #isCharging}: the general test exists so the * consumption counters do not book a charge as a drive, and it must stay permissive diff --git a/app/src/main/java/com/evsuite/abrp/MainActivity.java b/app/src/main/java/com/evsuite/abrp/MainActivity.java index 90fcf21..cb4bd6b 100644 --- a/app/src/main/java/com/evsuite/abrp/MainActivity.java +++ b/app/src/main/java/com/evsuite/abrp/MainActivity.java @@ -1590,12 +1590,11 @@ public class MainActivity extends AppCompatActivity { boolean charging = ChargingState.isCharging( code, s.getChargePortConnected(), s.getBatteryPowerKw(), s.getSpeedKmh()); ChargeSessionTracker.Snapshot session = chargeSessionTracker.snapshot(); - // Only DC is metered, so an AC charge must say so rather than let the readouts - // below — which then describe the previous DC session — be read as this charge. + // AC and DC are both metered while the app runs; the label only says which. boolean dc = ChargingState.isDcCharging( code, s.getChargePortConnected(), s.getBatteryPowerKw(), s.getSpeedKmh()); chargeStatus.setText(dc ? R.string.charge_dc - : charging ? R.string.charge_ac_not_metered + : charging ? R.string.charge_ac : (session.startedAtMs > 0 ? R.string.charge_last : R.string.charging_not_charging)); chargeSoc.setText(fmt(s.getSocPercent(), "%.1f %%")); chargeStartSoc.setText(fmt(session.startSocPercent, "%.1f %%")); diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 70531ab..ed5bfab 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -259,7 +259,7 @@ Charging price saved Enter a valid €/kWh value DC fast charging - AC charging (not metered) + AC charging Last charge No records for this date Distance %1$.1f km\nEnergy %2$.2f kWh\nAverage %3$s\nDuration %4$d h %5$02d min\nSOC change %6$.1f %% diff --git a/app/src/test/java/com/evsuite/abrp/ChargingStateTest.java b/app/src/test/java/com/evsuite/abrp/ChargingStateTest.java index 6193e9e..5003c38 100644 --- a/app/src/test/java/com/evsuite/abrp/ChargingStateTest.java +++ b/app/src/test/java/com/evsuite/abrp/ChargingStateTest.java @@ -73,14 +73,30 @@ public class ChargingStateTest { } @Test - public void onlyDcSpeedChargingIsMetered() { - // The session meter runs on DC alone: 7 kW is a home charge the head unit sleeps - // through, so its curve and duration would be fiction. + public void dcIsTenKilowattsOrMore() { + // The label and ABRP's is_dcfc: 7 kW is AC, 10 kW and up can only be DC. assertFalse(ChargingState.isDcCharging(1, Boolean.TRUE, -7f, 0f)); assertTrue(ChargingState.isDcCharging(10, Boolean.TRUE, -50f, 0f)); assertTrue(ChargingState.isDcCharging(null, null, -10f, 0f)); } + @Test + public void acAndDcAreBothMetered() { + assertTrue(ChargingState.isMeteredCharging(1, Boolean.TRUE, -7f, 0f)); + assertTrue(ChargingState.isMeteredCharging(null, null, -2.3f, 0f)); // granny lead + assertTrue(ChargingState.isMeteredCharging(10, Boolean.TRUE, -50f, 0f)); + } + + @Test + public void meteringNeedsPowerFlowingIn() { + // A status code still saying "charging" at the end, with nothing flowing, is no session. + assertFalse(ChargingState.isMeteredCharging(1, Boolean.TRUE, 0f, 0f)); + assertFalse(ChargingState.isMeteredCharging(1, Boolean.TRUE, -0.2f, 0f)); + assertFalse(ChargingState.isMeteredCharging(1, Boolean.TRUE, null, 0f)); + // Regeneration while driving is not a charge. + assertFalse(ChargingState.isMeteredCharging(null, null, -15f, 50f)); + } + @Test public void dcMeteringNeedsARealPowerReading() { // Unlike isCharging, which stays permissive so the consumption counters do not diff --git a/k8s/grafana/dashboards/charging.json b/k8s/grafana/dashboards/charging.json index 9550abd..dfbc1e9 100644 --- a/k8s/grafana/dashboards/charging.json +++ b/k8s/grafana/dashboards/charging.json @@ -1,7 +1,7 @@ { "uid": "mg4-charging", "title": "MG4 - Charging", - "description": "DC charging sessions metered by the car. Prices in euro, as entered in the car's Charging tab.", + "description": "Charging sessions (AC and DC) metered by the car while its head unit was awake. Prices in euro, as entered in the car's Charging tab.", "tags": [ "mg4" ], @@ -43,7 +43,7 @@ { "id": 1, "type": "stat", - "title": "DC sessions", + "title": "Charging sessions", "datasource": { "type": "influxdb", "uid": "mg4-influxdb" @@ -511,7 +511,7 @@ "mode": "multi" } }, - "description": "Only DC charges above 10 kW are metered. Pick a time range around a session to see its curve." + "description": "AC and DC charges are metered while the head unit is awake; up to app 2.19.6 only DC above 10 kW was. Pick a time range around a session to see its curve." } ] } diff --git a/server/README.md b/server/README.md index ba23ee7..64bd583 100644 --- a/server/README.md +++ b/server/README.md @@ -38,7 +38,7 @@ with (403 otherwise); the shared token works for any. and the answer is `200 {stored, droppedCount, dropped}`, even when nothing was usable, since the car would only send it again. - `PUT /v1/consumption/daily` — one delta chunk of a day's consumption. -- `PUT /v1/charging-sessions/{sessionId}` — a finished DC session with its curve. +- `PUT /v1/charging-sessions/{sessionId}` — a finished charging session (AC or DC, as far as the car was awake) with its curve. - `GET /v1/consumption/history/{installationId}` — the car reads its own totals back. - `GET /v1/health` — `{"status":"ok"}`. diff --git a/server/api.go b/server/api.go index a9d380e..db28993 100644 --- a/server/api.go +++ b/server/api.go @@ -15,7 +15,7 @@ import ( // // POST /v1/telemetry live samples, stored // PUT /v1/consumption/daily one delta chunk of a day's consumption -// PUT /v1/charging-sessions/{sessionId} one finished DC charging session +// PUT /v1/charging-sessions/{sessionId} one finished charging session (AC or DC) // GET /v1/consumption/history/{installationId} the car reads its own totals back // GET /v1/health unauthenticated liveness probe // /v1/settings/…, /settings settings typed on a phone (handoff_http.go)