App 2.19.7: meter AC charging too, while the app runs

A session runs whenever power flows into the pack, AC or DC; gaps the
head unit slept through are dropped as before. Dashboard and server
texts no longer say DC only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
This commit is contained in:
2026-10-23 22:07:03 +02:00
co-authored by Claude Opus 5.5
parent e6f8258203
commit 93e7e4cb63
10 changed files with 60 additions and 25 deletions
+8
View File
@@ -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 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` 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). (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 - 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 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 of `policy_control` if the switch was left on, and leaves anything else there alone — checked on
+2 -2
View File
@@ -34,8 +34,8 @@ android {
applicationId "com.stinnesbeck.nilsmg4" applicationId "com.stinnesbeck.nilsmg4"
minSdk 27 minSdk 27
targetSdk 36 targetSdk 36
versionCode 105 versionCode 106
versionName "2.19.6" versionName "2.19.7"
// Nothing about the server ships in the APK: its URL and ingest token are entered in // 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. // the app's Server tab (ServerConfig), and the database is only reachable by the server.
@@ -14,9 +14,10 @@ import java.util.List;
import java.util.UUID; 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 * 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 * {@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 * 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(); long elapsed = SystemClock.elapsedRealtime();
Float power = snapshot.getBatteryPowerKw(); Float power = snapshot.getBatteryPowerKw();
Float soc = snapshot.getSocPercent(); Float soc = snapshot.getSocPercent();
boolean charging = ChargingState.isDcCharging(snapshot.getChargingStatus(), boolean charging = ChargingState.isMeteredCharging(snapshot.getChargingStatus(),
snapshot.getChargePortConnected(), power, snapshot.getSpeedKmh()); snapshot.getChargePortConnected(), power, snapshot.getSpeedKmh());
if (charging) { if (charging) {
@@ -237,7 +238,7 @@ final class ChargeSessionTracker {
} }
if (soc != null) lastSoc = soc; if (soc != null) lastSoc = soc;
if (power != null) lastPower = power; 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.isEmpty() || nowMs - points.get(points.size() - 1).timestampMs >= curveIntervalMs)) {
points.add(new Point(nowMs, soc, Math.abs(power))); points.add(new Point(nowMs, soc, Math.abs(power)));
decimateIfFull(); decimateIfFull();
@@ -31,12 +31,9 @@ final class ChargingState {
* @param speedKmh road speed, null when unreadable * @param speedKmh road speed, null when unreadable
*/ */
/** /**
* Inbound power at or above which a charge is metered as a session. * 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"
* AC charging on this car tops out around 7 kW; anything past 10 kW can only be DC. * label and ABRP's {@code is_dcfc}, no longer to decide what is metered.
* 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.
*/ */
static final float DC_MIN_KW = 10f; 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 * 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 * consumption counters do not book a charge as a drive, and it must stay permissive
@@ -1590,12 +1590,11 @@ public class MainActivity extends AppCompatActivity {
boolean charging = ChargingState.isCharging( boolean charging = ChargingState.isCharging(
code, s.getChargePortConnected(), s.getBatteryPowerKw(), s.getSpeedKmh()); code, s.getChargePortConnected(), s.getBatteryPowerKw(), s.getSpeedKmh());
ChargeSessionTracker.Snapshot session = chargeSessionTracker.snapshot(); ChargeSessionTracker.Snapshot session = chargeSessionTracker.snapshot();
// Only DC is metered, so an AC charge must say so rather than let the readouts // AC and DC are both metered while the app runs; the label only says which.
// below — which then describe the previous DC session — be read as this charge.
boolean dc = ChargingState.isDcCharging( boolean dc = ChargingState.isDcCharging(
code, s.getChargePortConnected(), s.getBatteryPowerKw(), s.getSpeedKmh()); code, s.getChargePortConnected(), s.getBatteryPowerKw(), s.getSpeedKmh());
chargeStatus.setText(dc ? R.string.charge_dc 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)); : (session.startedAtMs > 0 ? R.string.charge_last : R.string.charging_not_charging));
chargeSoc.setText(fmt(s.getSocPercent(), "%.1f %%")); chargeSoc.setText(fmt(s.getSocPercent(), "%.1f %%"));
chargeStartSoc.setText(fmt(session.startSocPercent, "%.1f %%")); chargeStartSoc.setText(fmt(session.startSocPercent, "%.1f %%"));
+1 -1
View File
@@ -259,7 +259,7 @@
<string name="price_saved">Charging price saved</string> <string name="price_saved">Charging price saved</string>
<string name="price_invalid">Enter a valid €/kWh value</string> <string name="price_invalid">Enter a valid €/kWh value</string>
<string name="charge_dc">DC fast charging</string> <string name="charge_dc">DC fast charging</string>
<string name="charge_ac_not_metered">AC charging (not metered)</string> <string name="charge_ac">AC charging</string>
<string name="charge_last">Last charge</string> <string name="charge_last">Last charge</string>
<string name="consumption_no_records">No records for this date</string> <string name="consumption_no_records">No records for this date</string>
<string name="consumption_day_summary">Distance %1$.1f km\nEnergy %2$.2f kWh\nAverage %3$s\nDuration %4$d h %5$02d min\nSOC change %6$.1f %%</string> <string name="consumption_day_summary">Distance %1$.1f km\nEnergy %2$.2f kWh\nAverage %3$s\nDuration %4$d h %5$02d min\nSOC change %6$.1f %%</string>
@@ -73,14 +73,30 @@ public class ChargingStateTest {
} }
@Test @Test
public void onlyDcSpeedChargingIsMetered() { public void dcIsTenKilowattsOrMore() {
// The session meter runs on DC alone: 7 kW is a home charge the head unit sleeps // The label and ABRP's is_dcfc: 7 kW is AC, 10 kW and up can only be DC.
// through, so its curve and duration would be fiction.
assertFalse(ChargingState.isDcCharging(1, Boolean.TRUE, -7f, 0f)); assertFalse(ChargingState.isDcCharging(1, Boolean.TRUE, -7f, 0f));
assertTrue(ChargingState.isDcCharging(10, Boolean.TRUE, -50f, 0f)); assertTrue(ChargingState.isDcCharging(10, Boolean.TRUE, -50f, 0f));
assertTrue(ChargingState.isDcCharging(null, null, -10f, 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 @Test
public void dcMeteringNeedsARealPowerReading() { public void dcMeteringNeedsARealPowerReading() {
// Unlike isCharging, which stays permissive so the consumption counters do not // Unlike isCharging, which stays permissive so the consumption counters do not
+3 -3
View File
@@ -1,7 +1,7 @@
{ {
"uid": "mg4-charging", "uid": "mg4-charging",
"title": "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": [ "tags": [
"mg4" "mg4"
], ],
@@ -43,7 +43,7 @@
{ {
"id": 1, "id": 1,
"type": "stat", "type": "stat",
"title": "DC sessions", "title": "Charging sessions",
"datasource": { "datasource": {
"type": "influxdb", "type": "influxdb",
"uid": "mg4-influxdb" "uid": "mg4-influxdb"
@@ -511,7 +511,7 @@
"mode": "multi" "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."
} }
] ]
} }
+1 -1
View File
@@ -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 and the answer is `200 {stored, droppedCount, dropped}`, even when nothing was usable, since
the car would only send it again. the car would only send it again.
- `PUT /v1/consumption/daily` — one delta chunk of a day's consumption. - `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/consumption/history/{installationId}` — the car reads its own totals back.
- `GET /v1/health` — `{"status":"ok"}`. - `GET /v1/health` — `{"status":"ok"}`.
+1 -1
View File
@@ -15,7 +15,7 @@ import (
// //
// POST /v1/telemetry live samples, stored // POST /v1/telemetry live samples, stored
// PUT /v1/consumption/daily one delta chunk of a day's consumption // 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/consumption/history/{installationId} the car reads its own totals back
// GET /v1/health unauthenticated liveness probe // GET /v1/health unauthenticated liveness probe
// /v1/settings/…, /settings settings typed on a phone (handoff_http.go) // /v1/settings/…, /settings settings typed on a phone (handoff_http.go)