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)