diff --git a/CHANGELOG.md b/CHANGELOG.md index a621cd6..39cd547 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,36 @@ # 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 - Added persistent charging sessions with SOC/power curves, duration, energy, unit price, diff --git a/FatihsMG4-unstable-2.2.36.apk.sha256 b/FatihsMG4-unstable-2.2.36.apk.sha256 new file mode 100644 index 0000000..0a3f6d0 --- /dev/null +++ b/FatihsMG4-unstable-2.2.36.apk.sha256 @@ -0,0 +1 @@ +a3625bed186ce33a062ac6168f1bbde01d4b80027346e647ba4d97a266bf6ae0 FatihsMG4-unstable-2.2.36.apk diff --git a/app/build.gradle b/app/build.gradle index c9c971b..c5bdef7 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -34,8 +34,8 @@ android { applicationId "com.evsuite.abrp" minSdk 27 targetSdk 36 - versionCode 52 - versionName "2.2.35" + versionCode 53 + versionName "2.2.36" // MongoDB credentials never ship in the APK. The app only talks to the small // authenticated API in server/, which owns the database connection. diff --git a/app/src/main/java/com/evsuite/abrp/ChargeSessionTracker.java b/app/src/main/java/com/evsuite/abrp/ChargeSessionTracker.java index de86ed4..2460407 100644 --- a/app/src/main/java/com/evsuite/abrp/ChargeSessionTracker.java +++ b/app/src/main/java/com/evsuite/abrp/ChargeSessionTracker.java @@ -2,6 +2,7 @@ package com.evsuite.abrp; import android.content.Context; import android.content.SharedPreferences; +import android.os.SystemClock; import com.evsuite.hardware.telemetry.EnergySnapshot; @@ -12,7 +13,16 @@ import java.util.ArrayList; import java.util.List; 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 { private static final String PREFS = "charging_sessions_v1"; 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 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; static synchronized ChargeSessionTracker get(Context context) { if (instance == null) instance = new ChargeSessionTracker(context.getApplicationContext()); @@ -75,39 +97,38 @@ final class ChargeSessionTracker { synchronized void sample(EnergySnapshot snapshot) { long now = snapshot.getTimestampMs(); + long elapsed = SystemClock.elapsedRealtime(); Float power = snapshot.getBatteryPowerKw(); Float soc = snapshot.getSocPercent(); - boolean charging = ChargingState.isCharging(snapshot.getChargingStatus(), + boolean charging = ChargingState.isDcCharging(snapshot.getChargingStatus(), snapshot.getChargePortConnected(), power, snapshot.getSpeedKmh()); if (charging) { endCandidateMs = 0L; if (active == null) active = new Session(UUID.randomUUID().toString(), now, soc); - active.add(now, soc, power); + active.add(now, elapsed, soc, power); persistActive(); return; } if (active == null) return; - if (endCandidateMs == 0L) endCandidateMs = now; - if (now - endCandidateMs < END_GRACE_MS) return; - active.finish(endCandidateMs, soc, pricePerKwh()); + if (endCandidateMs == 0L) endCandidateMs = elapsed; + if (elapsed - endCandidateMs < END_GRACE_MS) return; + active.finish(now, soc, pricePerKwh()); JSONObject completed = active.toJson(); enqueue(completed); - prefs.edit().putString(LAST, completed.toString()).commit(); + prefs.edit().putString(LAST, completed.toString()).remove(ACTIVE).commit(); active = null; endCandidateMs = 0L; - prefs.edit().remove(ACTIVE).commit(); } synchronized Snapshot snapshot() { Session session = active != null ? active : Session.fromJson(prefs.getString(LAST, null)); if (session == null) return new Snapshot(false, 0L, 0L, null, null, 0d, pricePerKwh(), new ArrayList<>()); - long end = session.endedAtMs > 0 ? session.endedAtMs : session.lastSampleMs; double price = active != null ? pricePerKwh() : session.pricePerKwh; - return new Snapshot(active != null, session.startedAtMs, - Math.max(0L, (end - session.startedAtMs) / 1000L), session.startSoc, - session.lastSoc, session.energyKwh, price, new ArrayList<>(session.points)); + return new Snapshot(active != null, session.startedAtMs, session.activeSeconds(), + session.startSoc, session.lastSoc, session.energyKwh, price, + new ArrayList<>(session.points)); } synchronized boolean setPricePerKwh(double value) { @@ -143,10 +164,21 @@ final class ChargeSessionTracker { 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) { JSONArray array = pendingArray(); 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() { @@ -159,39 +191,68 @@ final class ChargeSessionTracker { final long startedAtMs; final Float startSoc; final List points = new ArrayList<>(); - long lastSampleMs; Float lastSoc; Float lastPower; double energyKwh; + double meteredSeconds; long endedAtMs; 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) { 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) { - if (now > lastSampleMs && power != null && lastPower != null) { - double hours = (now - lastSampleMs) / 3_600_000d; - energyKwh += Math.max(0d, -(power + lastPower) / 2d) * hours; + void add(long nowMs, long elapsedMs, Float soc, Float power) { + long previous = lastElapsedMs; + 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; + } } - if (now > lastSampleMs) lastSampleMs = now; if (soc != null) lastSoc = soc; if (power != null) lastPower = power; - if (power != null && power < ChargingState.CHARGING_POWER_KW && - (points.isEmpty() || now - points.get(points.size() - 1).timestampMs >= CURVE_INTERVAL_MS)) { - points.add(new Point(now, soc, Math.abs(power))); - if (points.size() > MAX_CURVE_POINTS) points.remove(0); + if (power != null && -power >= ChargingState.DC_MIN_KW && + (points.isEmpty() || nowMs - points.get(points.size() - 1).timestampMs >= curveIntervalMs)) { + points.add(new Point(nowMs, soc, Math.abs(power))); + 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) { this.endedAtMs = endedAtMs; if (finalSoc != null) lastSoc = finalSoc; 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() { try { JSONArray curve = new JSONArray(); @@ -202,11 +263,11 @@ final class ChargeSessionTracker { curve.put(p); } JSONObject json = new JSONObject().put("sessionId", id).put("startedAt", startedAtMs) - .put("endedAt", endedAtMs).put("durationSeconds", - Math.max(0L, ((endedAtMs > 0 ? endedAtMs : lastSampleMs) - startedAtMs) / 1000L)) + .put("endedAt", endedAtMs).put("durationSeconds", activeSeconds()) .put("energyKwh", energyKwh).put("pricePerKwh", pricePerKwh) .put("totalCost", energyKwh * pricePerKwh).put("curve", curve) - .put("lastSampleMs", lastSampleMs); + .put("meteredSeconds", meteredSeconds) + .put("curveIntervalMs", curveIntervalMs); if (startSoc != null) json.put("startSocPercent", startSoc); if (lastSoc != null) json.put("endSocPercent", lastSoc); if (lastPower != null) json.put("lastPower", lastPower); @@ -222,12 +283,13 @@ final class ChargeSessionTracker { JSONObject json = new JSONObject(raw); Session session = new Session(json.getString("sessionId"), json.getLong("startedAt"), 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.lastPower = json.has("lastPower") ? (float) json.getDouble("lastPower") : null; session.energyKwh = json.optDouble("energyKwh", 0d); + session.meteredSeconds = json.optDouble("meteredSeconds", json.optLong("durationSeconds", 0L)); session.endedAtMs = json.optLong("endedAt", 0L); session.pricePerKwh = json.optDouble("pricePerKwh", 0d); + session.curveIntervalMs = json.optLong("curveIntervalMs", CURVE_INTERVAL_MS); JSONArray curve = json.optJSONArray("curve"); if (curve != null) for (int i = 0; i < curve.length(); i++) { JSONObject p = curve.getJSONObject(i); diff --git a/app/src/main/java/com/evsuite/abrp/ChargingState.java b/app/src/main/java/com/evsuite/abrp/ChargingState.java index 3c5f130..b91695f 100644 --- a/app/src/main/java/com/evsuite/abrp/ChargingState.java +++ b/app/src/main/java/com/evsuite/abrp/ChargingState.java @@ -30,6 +30,16 @@ final class ChargingState { * @param powerKw battery power, negative into the pack, 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) { if (status != null && (status == STATUS_AC_CHARGING || status == STATUS_DC_CHARGING)) { return true; @@ -42,5 +52,17 @@ final class ChargingState { 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() { } } diff --git a/app/src/main/java/com/evsuite/abrp/ConsumptionCloudClient.java b/app/src/main/java/com/evsuite/abrp/ConsumptionCloudClient.java index b184a13..e6b16f3 100644 --- a/app/src/main/java/com/evsuite/abrp/ConsumptionCloudClient.java +++ b/app/src/main/java/com/evsuite/abrp/ConsumptionCloudClient.java @@ -23,6 +23,8 @@ final class ConsumptionCloudClient { private static final String PREFS = "consumption_cloud"; private static final String INSTALLATION_ID = "installation_id"; 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 token; @@ -42,9 +44,16 @@ final class ConsumptionCloudClient { 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) { - if (endpoint.isEmpty() || token.isEmpty() || nowMs - lastAttemptMs < SYNC_INTERVAL_MS) return; - lastAttemptMs = nowMs; + long elapsed = android.os.SystemClock.elapsedRealtime(); + if (endpoint.isEmpty() || token.isEmpty() + || (lastAttemptMs != 0L && elapsed - lastAttemptMs < SYNC_INTERVAL_MS)) return; + lastAttemptMs = elapsed; try { syncDailyConsumption(tracker, nowMs); syncPendingCharges(charges); @@ -53,23 +62,26 @@ 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 { String saved = prefs.getString(LAST_CONSUMPTION_DATE, ""); LocalDate lastUploaded = null; try { if (saved != null && !saved.isEmpty()) lastUploaded = LocalDate.parse(saved); } catch (RuntimeException ignored) { } - LocalDate target = tracker.nextStoredDayAfter(lastUploaded); - if (target == null) return; - ConsumptionTracker.CloudSnapshot snapshot = tracker.cloudSnapshot(target); - JSONObject body = new JSONObject() - .put("installationId", installationId) - .put("recordedAt", nowMs) - .put("date", snapshot.date) - .put("day", totals(snapshot.day)) - .put("lifetime", totals(snapshot.lifetime)); - if (snapshot.sohPercent != null) body.put("sohPercent", snapshot.sohPercent); - if (send("/v1/consumption/daily", "PUT", body)) { + for (int sent = 0; sent < MAX_DAYS_PER_SYNC; sent++) { + LocalDate target = tracker.nextStoredDayAfter(lastUploaded); + if (target == null) return; + ConsumptionTracker.CloudSnapshot snapshot = tracker.cloudSnapshot(target); + JSONObject body = new JSONObject() + .put("installationId", installationId) + .put("recordedAt", nowMs) + .put("date", snapshot.date) + .put("day", totals(snapshot.day)) + .put("lifetime", totals(snapshot.lifetime)); + if (snapshot.sohPercent != null) body.put("sohPercent", snapshot.sohPercent); + if (!send("/v1/consumption/daily", "PUT", body)) return; prefs.edit().putString(LAST_CONSUMPTION_DATE, target.toString()).commit(); + lastUploaded = target; } } @@ -81,7 +93,7 @@ final class ConsumptionCloudClient { String id = session.getString("sessionId"); if (!send("/v1/charging-sessions/" + id, "PUT", body)) return; charges.markUploaded(id); - if (++sent >= 3) return; + if (++sent >= MAX_CHARGES_PER_SYNC) return; } } diff --git a/app/src/main/java/com/evsuite/abrp/ConsumptionTracker.java b/app/src/main/java/com/evsuite/abrp/ConsumptionTracker.java index 3547d31..7cb3016 100644 --- a/app/src/main/java/com/evsuite/abrp/ConsumptionTracker.java +++ b/app/src/main/java/com/evsuite/abrp/ConsumptionTracker.java @@ -129,6 +129,10 @@ final class ConsumptionTracker { tripA.save(e, "a"); tripB.save(e, "b"); 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(); } @@ -141,7 +145,15 @@ final class ConsumptionTracker { synchronized CloudSnapshot cloudSnapshot(LocalDate date) { 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) { @@ -249,6 +261,9 @@ final class ConsumptionTracker { private static String dayKey(LocalDate date) { return "day_" + date; } + /** Deliberately shares the {@code day_} prefix so pruning reaches it too. */ + private static String lifeKey(LocalDate date) { return "day_" + date + "_life"; } + private Totals loadTotals(String key) { return new Totals(bits(key, "km"), bits(key, "kwh"), bits(key, "h"), bits(key, "soc")); } diff --git a/app/src/main/java/com/evsuite/abrp/MainActivity.java b/app/src/main/java/com/evsuite/abrp/MainActivity.java index f9e5f69..ceb79e9 100644 --- a/app/src/main/java/com/evsuite/abrp/MainActivity.java +++ b/app/src/main/java/com/evsuite/abrp/MainActivity.java @@ -967,7 +967,12 @@ public class MainActivity extends AppCompatActivity { boolean charging = ChargingState.isCharging( code, s.getChargePortConnected(), s.getBatteryPowerKw(), s.getSpeedKmh()); 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")); chargeSoc.setText(fmt(s.getSocPercent(), "%.1f %%")); chargeStartSoc.setText(fmt(session.startSocPercent, "%.1f %%")); diff --git a/app/src/test/java/com/evsuite/abrp/ChargingStateTest.java b/app/src/test/java/com/evsuite/abrp/ChargingStateTest.java index bf84959..6193e9e 100644 --- a/app/src/test/java/com/evsuite/abrp/ChargingStateTest.java +++ b/app/src/test/java/com/evsuite/abrp/ChargingStateTest.java @@ -71,4 +71,21 @@ public class ChargingStateTest { assertTrue(ChargingState.isCharging(null, null, -7f, 1f)); 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)); + } }