Meter DC charging only, on a monotonic clock

The charging session meter used the head unit's wall clock, which jumps when it
picks up GPS or NTP time. One forward jump mid-charge invented both kWh and
hours. The consumption counters already integrate on elapsedRealtime for exactly
this reason; the session meter now does too, and keeps wall-clock values only as
the start and end stamps a reader needs to place a session on a timeline.

Intervals longer than five minutes are no longer bridged. A head unit that slept
mid-charge came back and billed the whole gap at the last power reading, and a
session interrupted overnight reported the night as charging time. Reported
duration is metered charging time now, not the span from start to end.

Sessions are metered on DC alone, at or above 10 kW. AC charging on this car is
an overnight affair the head unit sleeps through, so its curve, duration and
energy were guesses either way. The charging screen says so rather than letting
the previous DC session's figures be read as the current AC charge.

Also:

- A daily consumption record uploaded after an offline spell carries the
  lifetime total as it stood at the close of that day. Every backlogged day used
  to be stamped with the current lifetime, which plots as a flat line and one
  cliff.
- The offline session queue is capped at 50. It is a single SharedPreferences
  string read into memory in full, and nothing drained it while the cloud
  endpoint was unconfigured.
- A long charge keeps the start of its curve: at 720 points the sample interval
  doubles and the curve is thinned, rather than dropping the oldest point.
- The cloud sync throttle runs on the monotonic clock, and catch-up sends up to
  10 days and 10 sessions per cycle rather than one and three.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
TCFDONMEZ
2026-10-08 14:20:29 +03:00
co-authored by Claude Opus 5
parent 1a37e52eda
commit e9bfa1217b
9 changed files with 211 additions and 46 deletions
+31
View File
@@ -1,5 +1,36 @@
# Changelog # Changelog
## 2.2.36
### Changed
- Charging sessions are now metered on DC only (10 kW and above). An AC charge is an
overnight affair the head unit sleeps through, so its curve, duration and energy were
guesses; the charging screen labels one as `AC şarj (ölçülmüyor)` rather than showing
the previous DC session's figures next to it.
### Fixed
- Charging energy and duration are integrated on the monotonic clock. They used the head
unit's wall clock, which jumps when it picks up GPS or NTP time — one forward jump
mid-charge invented both kWh and hours. This is the same fix the consumption counters
already carried.
- Intervals longer than five minutes are no longer integrated into a charging session. A
head unit that slept mid-charge came back and billed the whole gap at the last power
reading; a session interrupted overnight reported the night as charging time. Reported
duration is now metered charging time, not the wall-clock span from start to end.
- A daily consumption record uploaded after an offline spell carries the lifetime total as
it stood at the close of *that* day. Every backlogged day used to be stamped with the
current lifetime, which plots as a flat line and one cliff.
- The offline charging-session queue is capped at 50 sessions. It is a single
SharedPreferences string read into memory in full, and with the cloud endpoint
unconfigured nothing ever drained it.
- A long charge no longer loses the start of its curve. At 720 points the sample interval
doubles and the curve is thinned, instead of dropping the oldest point.
- The cloud sync throttle runs on the monotonic clock, so a backwards clock correction
cannot stall syncing. Catch-up now sends up to 10 days and 10 sessions per cycle rather
than one and three.
## 2.2.35 ## 2.2.35
- Added persistent charging sessions with SOC/power curves, duration, energy, unit price, - Added persistent charging sessions with SOC/power curves, duration, energy, unit price,
+1
View File
@@ -0,0 +1 @@
a3625bed186ce33a062ac6168f1bbde01d4b80027346e647ba4d97a266bf6ae0 FatihsMG4-unstable-2.2.36.apk
+2 -2
View File
@@ -34,8 +34,8 @@ android {
applicationId "com.evsuite.abrp" applicationId "com.evsuite.abrp"
minSdk 27 minSdk 27
targetSdk 36 targetSdk 36
versionCode 52 versionCode 53
versionName "2.2.35" versionName "2.2.36"
// MongoDB credentials never ship in the APK. The app only talks to the small // MongoDB credentials never ship in the APK. The app only talks to the small
// authenticated API in server/, which owns the database connection. // authenticated API in server/, which owns the database connection.
@@ -2,6 +2,7 @@ package com.evsuite.abrp;
import android.content.Context; import android.content.Context;
import android.content.SharedPreferences; import android.content.SharedPreferences;
import android.os.SystemClock;
import com.evsuite.hardware.telemetry.EnergySnapshot; import com.evsuite.hardware.telemetry.EnergySnapshot;
@@ -12,7 +13,16 @@ import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
/** Persistent charging session meter and offline upload queue. */ /**
* Persistent DC charging session meter and offline upload queue.
*
* Only DC is metered — see {@link ChargingState#DC_MIN_KW}. 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
* values are still recorded as the session's start and end, because that is what a phone
* reading the database needs to place it on a timeline — but nothing is measured from them.
*/
final class ChargeSessionTracker { final class ChargeSessionTracker {
private static final String PREFS = "charging_sessions_v1"; private static final String PREFS = "charging_sessions_v1";
private static final String ACTIVE = "active"; private static final String ACTIVE = "active";
@@ -23,6 +33,18 @@ final class ChargeSessionTracker {
private static final long CURVE_INTERVAL_MS = 60_000L; private static final long CURVE_INTERVAL_MS = 60_000L;
private static final int MAX_CURVE_POINTS = 720; private static final int MAX_CURVE_POINTS = 720;
/**
* Longest interval still integrated, in hours (5 minutes against a 15-second tick).
*
* A head unit that slept or a service that was killed comes back with an arbitrarily
* large gap. Charging almost certainly stopped somewhere inside it, so bridging it
* would bill the whole gap at the last power reading. The gap is dropped instead.
*/
private static final double MAX_STEP_HOURS = 5d / 60d;
/** Sessions kept while offline. Each carries a curve, so the queue is not free. */
private static final int MAX_PENDING = 50;
private static ChargeSessionTracker instance; private static ChargeSessionTracker instance;
static synchronized ChargeSessionTracker get(Context context) { static synchronized ChargeSessionTracker get(Context context) {
if (instance == null) instance = new ChargeSessionTracker(context.getApplicationContext()); if (instance == null) instance = new ChargeSessionTracker(context.getApplicationContext());
@@ -75,39 +97,38 @@ final class ChargeSessionTracker {
synchronized void sample(EnergySnapshot snapshot) { synchronized void sample(EnergySnapshot snapshot) {
long now = snapshot.getTimestampMs(); long now = snapshot.getTimestampMs();
long elapsed = SystemClock.elapsedRealtime();
Float power = snapshot.getBatteryPowerKw(); Float power = snapshot.getBatteryPowerKw();
Float soc = snapshot.getSocPercent(); Float soc = snapshot.getSocPercent();
boolean charging = ChargingState.isCharging(snapshot.getChargingStatus(), boolean charging = ChargingState.isDcCharging(snapshot.getChargingStatus(),
snapshot.getChargePortConnected(), power, snapshot.getSpeedKmh()); snapshot.getChargePortConnected(), power, snapshot.getSpeedKmh());
if (charging) { if (charging) {
endCandidateMs = 0L; endCandidateMs = 0L;
if (active == null) active = new Session(UUID.randomUUID().toString(), now, soc); if (active == null) active = new Session(UUID.randomUUID().toString(), now, soc);
active.add(now, soc, power); active.add(now, elapsed, soc, power);
persistActive(); persistActive();
return; return;
} }
if (active == null) return; if (active == null) return;
if (endCandidateMs == 0L) endCandidateMs = now; if (endCandidateMs == 0L) endCandidateMs = elapsed;
if (now - endCandidateMs < END_GRACE_MS) return; if (elapsed - endCandidateMs < END_GRACE_MS) return;
active.finish(endCandidateMs, soc, pricePerKwh()); active.finish(now, soc, pricePerKwh());
JSONObject completed = active.toJson(); JSONObject completed = active.toJson();
enqueue(completed); enqueue(completed);
prefs.edit().putString(LAST, completed.toString()).commit(); prefs.edit().putString(LAST, completed.toString()).remove(ACTIVE).commit();
active = null; active = null;
endCandidateMs = 0L; endCandidateMs = 0L;
prefs.edit().remove(ACTIVE).commit();
} }
synchronized Snapshot snapshot() { synchronized Snapshot snapshot() {
Session session = active != null ? active : Session.fromJson(prefs.getString(LAST, null)); Session session = active != null ? active : Session.fromJson(prefs.getString(LAST, null));
if (session == null) return new Snapshot(false, 0L, 0L, null, null, if (session == null) return new Snapshot(false, 0L, 0L, null, null,
0d, pricePerKwh(), new ArrayList<>()); 0d, pricePerKwh(), new ArrayList<>());
long end = session.endedAtMs > 0 ? session.endedAtMs : session.lastSampleMs;
double price = active != null ? pricePerKwh() : session.pricePerKwh; double price = active != null ? pricePerKwh() : session.pricePerKwh;
return new Snapshot(active != null, session.startedAtMs, return new Snapshot(active != null, session.startedAtMs, session.activeSeconds(),
Math.max(0L, (end - session.startedAtMs) / 1000L), session.startSoc, session.startSoc, session.lastSoc, session.energyKwh, price,
session.lastSoc, session.energyKwh, price, new ArrayList<>(session.points)); new ArrayList<>(session.points));
} }
synchronized boolean setPricePerKwh(double value) { synchronized boolean setPricePerKwh(double value) {
@@ -143,10 +164,21 @@ final class ChargeSessionTracker {
prefs.edit().putString(ACTIVE, active.toJson().toString()).commit(); prefs.edit().putString(ACTIVE, active.toJson().toString()).commit();
} }
/**
* Appends a completed session, dropping the oldest once the queue is full.
*
* Unbounded, this grows forever whenever the cloud endpoint is unconfigured or
* unreachable — and it is one SharedPreferences string, read into memory in full.
*/
private void enqueue(JSONObject session) { private void enqueue(JSONObject session) {
JSONArray array = pendingArray(); JSONArray array = pendingArray();
array.put(session); array.put(session);
prefs.edit().putString(PENDING, array.toString()).commit(); JSONArray kept = array;
if (array.length() > MAX_PENDING) {
kept = new JSONArray();
for (int i = array.length() - MAX_PENDING; i < array.length(); i++) kept.put(array.opt(i));
}
prefs.edit().putString(PENDING, kept.toString()).commit();
} }
private JSONArray pendingArray() { private JSONArray pendingArray() {
@@ -159,39 +191,68 @@ final class ChargeSessionTracker {
final long startedAtMs; final long startedAtMs;
final Float startSoc; final Float startSoc;
final List<Point> points = new ArrayList<>(); final List<Point> points = new ArrayList<>();
long lastSampleMs;
Float lastSoc; Float lastSoc;
Float lastPower; Float lastPower;
double energyKwh; double energyKwh;
double meteredSeconds;
long endedAtMs; long endedAtMs;
double pricePerKwh; double pricePerKwh;
long curveIntervalMs = CURVE_INTERVAL_MS;
/**
* Monotonic anchor of the previous sample, or 0 when there is none.
*
* Never persisted: {@code elapsedRealtime} is only comparable within one boot, so
* a session restored from disk starts a fresh interval rather than integrating
* against a reading from before the restart.
*/
private long lastElapsedMs;
Session(String id, long startedAtMs, Float startSoc) { Session(String id, long startedAtMs, Float startSoc) {
this.id = id; this.startedAtMs = startedAtMs; this.startSoc = startSoc; this.id = id; this.startedAtMs = startedAtMs; this.startSoc = startSoc;
this.lastSampleMs = startedAtMs; this.lastSoc = startSoc; this.lastSoc = startSoc;
} }
void add(long now, Float soc, Float power) { void add(long nowMs, long elapsedMs, Float soc, Float power) {
if (now > lastSampleMs && power != null && lastPower != null) { long previous = lastElapsedMs;
double hours = (now - lastSampleMs) / 3_600_000d; lastElapsedMs = elapsedMs;
double hours = previous > 0L ? (elapsedMs - previous) / 3_600_000d : 0d;
if (hours > 0d && hours <= MAX_STEP_HOURS) {
meteredSeconds += hours * 3600d;
if (power != null && lastPower != null) {
energyKwh += Math.max(0d, -(power + lastPower) / 2d) * hours; energyKwh += Math.max(0d, -(power + lastPower) / 2d) * hours;
} }
if (now > lastSampleMs) lastSampleMs = now; }
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.CHARGING_POWER_KW && if (power != null && -power >= ChargingState.DC_MIN_KW &&
(points.isEmpty() || now - points.get(points.size() - 1).timestampMs >= CURVE_INTERVAL_MS)) { (points.isEmpty() || nowMs - points.get(points.size() - 1).timestampMs >= curveIntervalMs)) {
points.add(new Point(now, soc, Math.abs(power))); points.add(new Point(nowMs, soc, Math.abs(power)));
if (points.size() > MAX_CURVE_POINTS) points.remove(0); decimateIfFull();
} }
} }
/**
* Halves the sample rate instead of dropping the oldest point.
*
* Dropping the head cost the beginning of the curve — on a long charge the part
* that actually has shape. Thinning keeps the whole session, at lower resolution.
*/
private void decimateIfFull() {
if (points.size() <= MAX_CURVE_POINTS) return;
for (int i = points.size() - 2; i >= 0; i -= 2) points.remove(i);
curveIntervalMs *= 2;
}
void finish(long endedAtMs, Float finalSoc, double price) { void finish(long endedAtMs, Float finalSoc, double price) {
this.endedAtMs = endedAtMs; this.endedAtMs = endedAtMs;
if (finalSoc != null) lastSoc = finalSoc; if (finalSoc != null) lastSoc = finalSoc;
pricePerKwh = price; pricePerKwh = price;
} }
/** Metered charging time, not wall-clock span: a dropped gap is not charging. */
long activeSeconds() { return Math.max(0L, Math.round(meteredSeconds)); }
JSONObject toJson() { JSONObject toJson() {
try { try {
JSONArray curve = new JSONArray(); JSONArray curve = new JSONArray();
@@ -202,11 +263,11 @@ final class ChargeSessionTracker {
curve.put(p); curve.put(p);
} }
JSONObject json = new JSONObject().put("sessionId", id).put("startedAt", startedAtMs) JSONObject json = new JSONObject().put("sessionId", id).put("startedAt", startedAtMs)
.put("endedAt", endedAtMs).put("durationSeconds", .put("endedAt", endedAtMs).put("durationSeconds", activeSeconds())
Math.max(0L, ((endedAtMs > 0 ? endedAtMs : lastSampleMs) - startedAtMs) / 1000L))
.put("energyKwh", energyKwh).put("pricePerKwh", pricePerKwh) .put("energyKwh", energyKwh).put("pricePerKwh", pricePerKwh)
.put("totalCost", energyKwh * pricePerKwh).put("curve", curve) .put("totalCost", energyKwh * pricePerKwh).put("curve", curve)
.put("lastSampleMs", lastSampleMs); .put("meteredSeconds", meteredSeconds)
.put("curveIntervalMs", curveIntervalMs);
if (startSoc != null) json.put("startSocPercent", startSoc); if (startSoc != null) json.put("startSocPercent", startSoc);
if (lastSoc != null) json.put("endSocPercent", lastSoc); if (lastSoc != null) json.put("endSocPercent", lastSoc);
if (lastPower != null) json.put("lastPower", lastPower); if (lastPower != null) json.put("lastPower", lastPower);
@@ -222,12 +283,13 @@ final class ChargeSessionTracker {
JSONObject json = new JSONObject(raw); JSONObject json = new JSONObject(raw);
Session session = new Session(json.getString("sessionId"), json.getLong("startedAt"), Session session = new Session(json.getString("sessionId"), json.getLong("startedAt"),
json.has("startSocPercent") ? (float) json.getDouble("startSocPercent") : null); json.has("startSocPercent") ? (float) json.getDouble("startSocPercent") : null);
session.lastSampleMs = json.optLong("lastSampleMs", session.startedAtMs);
session.lastSoc = json.has("endSocPercent") ? (float) json.getDouble("endSocPercent") : session.startSoc; session.lastSoc = json.has("endSocPercent") ? (float) json.getDouble("endSocPercent") : session.startSoc;
session.lastPower = json.has("lastPower") ? (float) json.getDouble("lastPower") : null; session.lastPower = json.has("lastPower") ? (float) json.getDouble("lastPower") : null;
session.energyKwh = json.optDouble("energyKwh", 0d); session.energyKwh = json.optDouble("energyKwh", 0d);
session.meteredSeconds = json.optDouble("meteredSeconds", json.optLong("durationSeconds", 0L));
session.endedAtMs = json.optLong("endedAt", 0L); session.endedAtMs = json.optLong("endedAt", 0L);
session.pricePerKwh = json.optDouble("pricePerKwh", 0d); session.pricePerKwh = json.optDouble("pricePerKwh", 0d);
session.curveIntervalMs = json.optLong("curveIntervalMs", CURVE_INTERVAL_MS);
JSONArray curve = json.optJSONArray("curve"); JSONArray curve = json.optJSONArray("curve");
if (curve != null) for (int i = 0; i < curve.length(); i++) { if (curve != null) for (int i = 0; i < curve.length(); i++) {
JSONObject p = curve.getJSONObject(i); JSONObject p = curve.getJSONObject(i);
@@ -30,6 +30,16 @@ final class ChargingState {
* @param powerKw battery power, negative into the pack, null when unreadable * @param powerKw battery power, negative into the pack, null when unreadable
* @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.
*
* 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.
*/
static final float DC_MIN_KW = 10f;
static boolean isCharging(Integer status, Boolean portConnected, Float powerKw, Float speedKmh) { static boolean isCharging(Integer status, Boolean portConnected, Float powerKw, Float speedKmh) {
if (status != null && (status == STATUS_AC_CHARGING || status == STATUS_DC_CHARGING)) { if (status != null && (status == STATUS_AC_CHARGING || status == STATUS_DC_CHARGING)) {
return true; return true;
@@ -42,5 +52,17 @@ final class ChargingState {
return speedKmh == null || Math.abs(speedKmh) < STANDSTILL_KMH; return speedKmh == null || Math.abs(speedKmh) < STANDSTILL_KMH;
} }
/**
* Is this a DC fast charge — the only kind the session meter records?
*
* 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
* for that. This one gates a measurement, so an unreadable power value is a no.
*/
static boolean isDcCharging(Integer status, Boolean portConnected, Float powerKw, Float speedKmh) {
if (powerKw == null || !Float.isFinite(powerKw) || -powerKw < DC_MIN_KW) return false;
return isCharging(status, portConnected, powerKw, speedKmh);
}
private ChargingState() { } private ChargingState() { }
} }
@@ -23,6 +23,8 @@ final class ConsumptionCloudClient {
private static final String PREFS = "consumption_cloud"; private static final String PREFS = "consumption_cloud";
private static final String INSTALLATION_ID = "installation_id"; private static final String INSTALLATION_ID = "installation_id";
private static final String LAST_CONSUMPTION_DATE = "last_consumption_date"; private static final String LAST_CONSUMPTION_DATE = "last_consumption_date";
private static final int MAX_DAYS_PER_SYNC = 10;
private static final int MAX_CHARGES_PER_SYNC = 10;
private final String endpoint; private final String endpoint;
private final String token; private final String token;
@@ -42,9 +44,16 @@ final class ConsumptionCloudClient {
installationId = existing; installationId = existing;
} }
/**
* @param nowMs wall clock, recorded with the payload — the throttle below runs on a
* monotonic clock instead, so a backwards NTP correction cannot stall
* syncing for however long the jump was.
*/
void syncIfDue(ConsumptionTracker tracker, ChargeSessionTracker charges, long nowMs) { void syncIfDue(ConsumptionTracker tracker, ChargeSessionTracker charges, long nowMs) {
if (endpoint.isEmpty() || token.isEmpty() || nowMs - lastAttemptMs < SYNC_INTERVAL_MS) return; long elapsed = android.os.SystemClock.elapsedRealtime();
lastAttemptMs = nowMs; if (endpoint.isEmpty() || token.isEmpty()
|| (lastAttemptMs != 0L && elapsed - lastAttemptMs < SYNC_INTERVAL_MS)) return;
lastAttemptMs = elapsed;
try { try {
syncDailyConsumption(tracker, nowMs); syncDailyConsumption(tracker, nowMs);
syncPendingCharges(charges); syncPendingCharges(charges);
@@ -53,11 +62,13 @@ final class ConsumptionCloudClient {
} }
} }
/** Drains the backlog a few days per cycle; one a cycle took an hour per fortnight. */
private void syncDailyConsumption(ConsumptionTracker tracker, long nowMs) throws Exception { private void syncDailyConsumption(ConsumptionTracker tracker, long nowMs) throws Exception {
String saved = prefs.getString(LAST_CONSUMPTION_DATE, ""); String saved = prefs.getString(LAST_CONSUMPTION_DATE, "");
LocalDate lastUploaded = null; LocalDate lastUploaded = null;
try { if (saved != null && !saved.isEmpty()) lastUploaded = LocalDate.parse(saved); } try { if (saved != null && !saved.isEmpty()) lastUploaded = LocalDate.parse(saved); }
catch (RuntimeException ignored) { } catch (RuntimeException ignored) { }
for (int sent = 0; sent < MAX_DAYS_PER_SYNC; sent++) {
LocalDate target = tracker.nextStoredDayAfter(lastUploaded); LocalDate target = tracker.nextStoredDayAfter(lastUploaded);
if (target == null) return; if (target == null) return;
ConsumptionTracker.CloudSnapshot snapshot = tracker.cloudSnapshot(target); ConsumptionTracker.CloudSnapshot snapshot = tracker.cloudSnapshot(target);
@@ -68,8 +79,9 @@ final class ConsumptionCloudClient {
.put("day", totals(snapshot.day)) .put("day", totals(snapshot.day))
.put("lifetime", totals(snapshot.lifetime)); .put("lifetime", totals(snapshot.lifetime));
if (snapshot.sohPercent != null) body.put("sohPercent", snapshot.sohPercent); if (snapshot.sohPercent != null) body.put("sohPercent", snapshot.sohPercent);
if (send("/v1/consumption/daily", "PUT", body)) { if (!send("/v1/consumption/daily", "PUT", body)) return;
prefs.edit().putString(LAST_CONSUMPTION_DATE, target.toString()).commit(); prefs.edit().putString(LAST_CONSUMPTION_DATE, target.toString()).commit();
lastUploaded = target;
} }
} }
@@ -81,7 +93,7 @@ final class ConsumptionCloudClient {
String id = session.getString("sessionId"); String id = session.getString("sessionId");
if (!send("/v1/charging-sessions/" + id, "PUT", body)) return; if (!send("/v1/charging-sessions/" + id, "PUT", body)) return;
charges.markUploaded(id); charges.markUploaded(id);
if (++sent >= 3) return; if (++sent >= MAX_CHARGES_PER_SYNC) return;
} }
} }
@@ -129,6 +129,10 @@ final class ConsumptionTracker {
tripA.save(e, "a"); tripA.save(e, "a");
tripB.save(e, "b"); tripB.save(e, "b");
today.save(e, dayKey(todayDate)); today.save(e, dayKey(todayDate));
// Lifetime as it stood at the end of this day, so a backlog uploaded days later
// does not stamp every one of those days with today's total — which would draw a
// flat line and then one cliff in anything plotting it.
lifetime.save(e, lifeKey(todayDate));
e.commit(); e.commit();
} }
@@ -141,7 +145,15 @@ final class ConsumptionTracker {
synchronized CloudSnapshot cloudSnapshot(LocalDate date) { synchronized CloudSnapshot cloudSnapshot(LocalDate date) {
rollDayIfNeeded(); rollDayIfNeeded();
return new CloudSnapshot(date.toString(), totalsForDay(date), lifetime.snapshot(), sohPercent()); return new CloudSnapshot(date.toString(), totalsForDay(date), lifetimeAtEndOf(date), sohPercent());
}
/** Lifetime as of the close of {@code date}, falling back to the live counter. */
private Totals lifetimeAtEndOf(LocalDate date) {
if (date.equals(todayDate)) return lifetime.snapshot();
Totals stored = loadTotals(lifeKey(date));
boolean missing = stored.km == 0 && stored.kwh == 0 && stored.hours == 0 && stored.soc == 0;
return missing ? lifetime.snapshot() : stored;
} }
synchronized LocalDate nextStoredDayAfter(LocalDate lastUploaded) { synchronized LocalDate nextStoredDayAfter(LocalDate lastUploaded) {
@@ -249,6 +261,9 @@ final class ConsumptionTracker {
private static String dayKey(LocalDate date) { return "day_" + date; } private static String dayKey(LocalDate date) { return "day_" + date; }
/** Deliberately shares the {@code day_<date>} prefix so pruning reaches it too. */
private static String lifeKey(LocalDate date) { return "day_" + date + "_life"; }
private Totals loadTotals(String key) { private Totals loadTotals(String key) {
return new Totals(bits(key, "km"), bits(key, "kwh"), bits(key, "h"), bits(key, "soc")); return new Totals(bits(key, "km"), bits(key, "kwh"), bits(key, "h"), bits(key, "soc"));
} }
@@ -967,7 +967,12 @@ 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();
chargeStatus.setText(charging ? (code != null && code == 10 ? "DC hızlı şarj" : "AC şarj") // 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.
boolean dc = ChargingState.isDcCharging(
code, s.getChargePortConnected(), s.getBatteryPowerKw(), s.getSpeedKmh());
chargeStatus.setText(dc ? "DC hızlı şarj"
: charging ? "AC şarj (ölçülmüyor)"
: (session.startedAtMs > 0 ? "Son şarj" : "Şarj yok")); : (session.startedAtMs > 0 ? "Son şarj" : "Şarj yok"));
chargeSoc.setText(fmt(s.getSocPercent(), "%.1f %%")); chargeSoc.setText(fmt(s.getSocPercent(), "%.1f %%"));
chargeStartSoc.setText(fmt(session.startSocPercent, "%.1f %%")); chargeStartSoc.setText(fmt(session.startSocPercent, "%.1f %%"));
@@ -71,4 +71,21 @@ public class ChargingStateTest {
assertTrue(ChargingState.isCharging(null, null, -7f, 1f)); assertTrue(ChargingState.isCharging(null, null, -7f, 1f));
assertFalse(ChargingState.isCharging(null, null, -7f, 10f)); assertFalse(ChargingState.isCharging(null, null, -7f, 10f));
} }
@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.
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 dcMeteringNeedsARealPowerReading() {
// Unlike isCharging, which stays permissive so the consumption counters do not
// book a charge as a drive, this gates a measurement. No reading, no session.
assertFalse(ChargingState.isDcCharging(10, Boolean.TRUE, null, 0f));
assertFalse(ChargingState.isDcCharging(null, Boolean.TRUE, 20f, 0f));
}
} }