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
+2 -2
View File
@@ -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.
@@ -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();
@@ -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
@@ -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 %%"));
+1 -1
View File
@@ -259,7 +259,7 @@
<string name="price_saved">Charging price saved</string>
<string name="price_invalid">Enter a valid €/kWh value</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="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>
@@ -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