From 06265bacc9fbc954f5db1523b1f3fc70ff080cbc Mon Sep 17 00:00:00 2001 From: TCFDONMEZ Date: Fri, 9 Oct 2026 21:41:15 +0300 Subject: [PATCH] simplify: backend derives lifetime from day totals, app stops sending it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - backend daily PUT: no longer $set lifetime (app's local counter is unreliable after APK wipe) - backend history GET: derives lifetime as sum of all days' totals, returns it at response level - app: stops sending lifetime in chunks; reads it from history response - app: refresh button on consumption pane + 'Şimdi yükle' on log pane (manual cache flush) Co-Authored-By: Claude --- .../evsuite/abrp/ConsumptionCloudClient.java | 84 ++++++++++++++----- .../com/evsuite/abrp/ConsumptionTracker.java | 24 ++++++ .../java/com/evsuite/abrp/MainActivity.java | 35 ++++++++ app/src/main/res/layout/pane_consumption.xml | 5 +- app/src/main/res/layout/pane_log.xml | 1 + app/src/main/res/values/strings.xml | 1 + web/src/app/v1/consumption/daily/route.ts | 8 +- .../history/[installationId]/route.ts | 13 ++- 8 files changed, 144 insertions(+), 27 deletions(-) diff --git a/app/src/main/java/com/evsuite/abrp/ConsumptionCloudClient.java b/app/src/main/java/com/evsuite/abrp/ConsumptionCloudClient.java index ac68f69..0ef1b9f 100644 --- a/app/src/main/java/com/evsuite/abrp/ConsumptionCloudClient.java +++ b/app/src/main/java/com/evsuite/abrp/ConsumptionCloudClient.java @@ -29,7 +29,7 @@ import java.util.List; */ final class ConsumptionCloudClient { private static final String TAG = "FatihsMG4.Mongo"; - private static final long CONSUMPTION_SYNC_INTERVAL_MS = 30 * 60_000L; + private static final long CONSUMPTION_SYNC_INTERVAL_MS = 2 * 60_000L; private static final long CHARGE_RETRY_INTERVAL_MS = 5 * 60_000L; private static final long HISTORY_FETCH_INTERVAL_MS = 5 * 60_000L; private static final int TIMEOUT_MS = 8_000; @@ -137,14 +137,16 @@ final class ConsumptionCloudClient { } /** - * Sends every cached chunk; removes each one that succeeds. Returns how many were sent. - * A chunk that fails stops the drain — the rest stay in order for the next cycle. + * Sends every cached chunk; removes each one that succeeds. A chunk that fails stays in + * the cache for the next attempt, but does NOT stop the drain — the next chunk is tried + * too, because a 5xx on one day's document is not a reason to hold back the rest. */ private int drainChunkCache() { JSONArray cache = loadChunkCache(); if (cache.length() == 0) return 0; JSONArray remaining = new JSONArray(); int sent = 0; + int failed = 0; for (int i = 0; i < cache.length(); i++) { try { JSONObject body = cache.getJSONObject(i); @@ -153,20 +155,18 @@ final class ConsumptionCloudClient { sent++; Log.d(TAG, "Cached chunk for " + date + " sent"); } else { - // Server rejected — keep this and everything after it. - for (int j = i; j < cache.length(); j++) remaining.put(cache.get(j)); - break; + // Server rejected — keep it, try the next one too. + remaining.put(body); + failed++; } } catch (Exception e) { - // Network error — keep this and everything after it, stop draining. - for (int j = i; j < cache.length(); j++) { - try { remaining.put(cache.get(j)); } catch (Exception ignored) { } - } - Log.w(TAG, "Cache drain stopped: " + e.getMessage()); - break; + // Network error — keep it, try the next one too. + try { remaining.put(cache.get(i)); } catch (Exception ignored) { } + failed++; } } saveChunkCache(remaining); + if (failed > 0) Log.w(TAG, "Cache drain: " + sent + " sent, " + failed + " still pending"); return sent; } @@ -203,8 +203,7 @@ final class ConsumptionCloudClient { .put("installationId", installationId) .put("recordedAt", nowMs) .put("date", snapshot.date) - .put("day", totals(snapshot.day)) - .put("lifetime", totals(snapshot.lifetime)); + .put("day", totals(snapshot.day)); if (snapshot.sohPercent != null) body.put("sohPercent", snapshot.sohPercent); return body; } @@ -290,26 +289,32 @@ final class ConsumptionCloudClient { /** * Returns the cached cloud history, fetching a fresh copy at most every 5 minutes. * Called from the UI poll loop (every 2 s), so the throttle is what keeps the head unit - * off the network. On a fetch failure the previous cache is kept — a transient outage - * must not blank the display. + * off the network. The fetch itself runs on a background thread — this method is called + * from the UI thread and a network call there throws NetworkOnMainThreadException. + * Returns the current cache immediately; the fresh copy lands on the next poll. */ - List fetchHistoryIfDue() { + synchronized List fetchHistoryIfDue() { if (endpoint.isEmpty() || token.isEmpty()) return cachedHistory; long elapsed = android.os.SystemClock.elapsedRealtime(); if (lastHistoryFetchMs != 0L && elapsed - lastHistoryFetchMs < HISTORY_FETCH_INTERVAL_MS) { return cachedHistory; } lastHistoryFetchMs = elapsed; + new Thread(this::fetchHistoryAsync, "history-fetch").start(); + return cachedHistory; + } + + private void fetchHistoryAsync() { try { List fresh = fetchHistory(); if (!fresh.isEmpty()) cachedHistory = fresh; Log.d(TAG, "Fetched " + fresh.size() + " history records from cloud"); + logEvent(true, 200, "History çekildi: " + fresh.size() + " gün"); } catch (Exception e) { String msg = e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage(); Log.w(TAG, "History fetch unavailable: " + msg); logEvent(false, 0, "History fetch hatası: " + msg); } - return cachedHistory; } /** Forces a refresh on the next {@link #fetchHistoryIfDue} — call after an upload. */ @@ -317,6 +322,37 @@ final class ConsumptionCloudClient { lastHistoryFetchMs = 0L; } + /** + * Immediately attempts to send every cached chunk and take a fresh one, ignoring the + * 2-minute throttle. Called from the UI "Şimdi yükle" button. Returns a one-line + * status for the log. + */ + String flushNow(ConsumptionTracker tracker, long nowMs) { + if (endpoint.isEmpty() || token.isEmpty()) return "API yapılandırılmamış"; + int cached = drainChunkCache(); + ConsumptionTracker.CloudSnapshot chunk = tracker.takeChunkDelta(); + int total = cached; + if (chunk != null) { + try { + JSONObject body = consumptionBody(chunk, nowMs); + if (send("/v1/consumption/daily", "PUT", body)) { + total++; + invalidateHistory(); + } else { + cacheChunk(body); + } + } catch (Exception e) { + try { cacheChunk(consumptionBody(chunk, nowMs)); } catch (Exception ignored) { } + } + } + int pending = pendingChunkCount(); + String msg = total > 0 + ? total + " chunk yüklendi" + (pending > 0 ? ", " + pending + " hâlâ bekliyor" : "") + : (pending > 0 ? pending + " chunk yüklenemedi (internet?)" : "Yüklenecek yeni veri yok"); + logEvent(total > 0, total > 0 ? 200 : 0, "Manuel sync: " + msg); + return msg; + } + private List fetchHistory() throws Exception { HttpURLConnection connection = null; try { @@ -327,23 +363,29 @@ final class ConsumptionCloudClient { connection.setReadTimeout(TIMEOUT_MS); connection.setRequestProperty("Authorization", "Bearer " + token); int status = connection.getResponseCode(); + Log.d(TAG, "History GET response: HTTP " + status); if (status < 200 || status >= 300) { - Log.w(TAG, "History fetch rejected with HTTP " + status); + String err = readErrorBody(connection); + Log.w(TAG, "History fetch rejected with HTTP " + status + (err.isEmpty() ? "" : ": " + err)); + logEvent(false, status, "History GET HTTP " + status + (err.isEmpty() ? "" : " — " + err)); return cachedHistory; } String body = readBody(connection); JSONObject json = new JSONObject(body); JSONArray records = json.optJSONArray("records"); if (records == null) return Collections.emptyList(); + // Lifetime is derived by the backend (sum of all days), sent once at the response + // level. Each DayRecord carries it so the UI's LIFETIME period can read it. + ConsumptionTracker.Totals lifetime = json.has("lifetime") && !json.isNull("lifetime") + ? parseTotals(json.getJSONObject("lifetime")) : null; List result = new ArrayList<>(records.length()); for (int i = 0; i < records.length(); i++) { JSONObject r = records.getJSONObject(i); LocalDate date = LocalDate.parse(r.getString("date")); ConsumptionTracker.Totals day = parseTotals(r.getJSONObject("day")); - ConsumptionTracker.Totals life = parseTotals(r.getJSONObject("lifetime")); Float soh = r.has("sohPercent") && !r.isNull("sohPercent") ? (float) r.getDouble("sohPercent") : null; - result.add(new DayRecord(date, day, life, soh)); + result.add(new DayRecord(date, day, lifetime, soh)); } return result; } finally { diff --git a/app/src/main/java/com/evsuite/abrp/ConsumptionTracker.java b/app/src/main/java/com/evsuite/abrp/ConsumptionTracker.java index 5ab3ec3..2f9395a 100644 --- a/app/src/main/java/com/evsuite/abrp/ConsumptionTracker.java +++ b/app/src/main/java/com/evsuite/abrp/ConsumptionTracker.java @@ -3,6 +3,7 @@ package com.evsuite.abrp; import android.content.Context; import android.content.SharedPreferences; import android.os.SystemClock; +import android.util.Log; import com.evsuite.hardware.telemetry.EnergySnapshot; @@ -204,6 +205,22 @@ final class ConsumptionTracker { return missing ? lifetime.snapshot() : stored; } + /** + * Sets the local lifetime baseline from the cloud's last known lifetime, but only when + * the cloud value is higher — an APK wipe zeroes the local counter, and without this the + * car would re-upload a small lifetime that overwrites the real one in the cloud. + * Called once after the first history fetch succeeds. + */ + synchronized void syncLifetimeBaseline(Totals cloudLifetime) { + Totals local = lifetime.snapshot(); + if (cloudLifetime.km > local.km) { + lifetime.set(cloudLifetime.km, cloudLifetime.kwh, cloudLifetime.hours, cloudLifetime.soc); + persist(); + Log.i("FatihsMG4.Consumption", "Lifetime baseline synced from cloud: " + + cloudLifetime.km + " km (was " + local.km + " km)"); + } + } + synchronized LocalDate nextStoredDayAfter(LocalDate lastUploaded) { rollDayIfNeeded(); LocalDate latestComplete = LocalDate.now().minusDays(1); @@ -355,6 +372,13 @@ final class ConsumptionTracker { km.reset(); kwh.reset(); hours.reset(); soc.reset(); } + void set(double kmVal, double kwhVal, double hoursVal, double socVal) { + km.setTotal(kmVal); + kwh.setTotal(kwhVal); + hours.setTotal(hoursVal); + soc.setTotal(socVal); + } + Totals snapshot() { return new Totals(km.get(), kwh.get(), hours.get(), soc.get()); } diff --git a/app/src/main/java/com/evsuite/abrp/MainActivity.java b/app/src/main/java/com/evsuite/abrp/MainActivity.java index 7c9df39..530b3ed 100644 --- a/app/src/main/java/com/evsuite/abrp/MainActivity.java +++ b/app/src/main/java/com/evsuite/abrp/MainActivity.java @@ -215,6 +215,7 @@ public class MainActivity extends AppCompatActivity { statusText = servicePane.findViewById(R.id.status_text); callLogText = logPane.findViewById(R.id.call_log_text); + logPane.findViewById(R.id.flush_now_button).setOnClickListener(v -> manualFlush()); setUpPager(); findViewById(R.id.about_button).setOnClickListener(v -> showAbout()); @@ -440,6 +441,7 @@ public class MainActivity extends AppCompatActivity { consumptionDateButton = consumptionPane.findViewById(R.id.consumption_date_button); consumptionDateButton.setText(selectedConsumptionDate.toString()); consumptionDateButton.setOnClickListener(v -> showConsumptionDatePicker()); + consumptionPane.findViewById(R.id.consumption_refresh).setOnClickListener(v -> refreshFromCloud()); consumptionReset = consumptionPane.findViewById(R.id.consumption_reset); consumptionSohLayout = consumptionPane.findViewById(R.id.consumption_soh_layout); consumptionSohInput = consumptionPane.findViewById(R.id.consumption_soh_input); @@ -686,6 +688,39 @@ public class MainActivity extends AppCompatActivity { // ---------- Call log ---------- + /** + * "Şimdi yükle" button — forces a cache drain + fresh chunk on a background thread, + * ignoring the 2-minute throttle. The result lands in the upload log (Kayıtlar tab). + */ + private void manualFlush() { + if (consumptionCloudClient == null || consumptionTracker == null) return; + AbrpUploadService.log().record(new UploadLog.Entry( + System.currentTimeMillis(), 0, true, "Manuel sync başlatıldı…")); + new Thread(() -> { + String result = consumptionCloudClient.flushNow( + consumptionTracker, System.currentTimeMillis()); + runOnUiThread(() -> Toast.makeText(this, result, Toast.LENGTH_SHORT).show()); + }, "manual-flush").start(); + } + + /** + * Tüketim sekmesi "Yenile" button — sends cached chunks + fresh delta, invalidates the + * history cache so the next poll fetches fresh data from the cloud, then refreshes the UI. + */ + private void refreshFromCloud() { + if (consumptionCloudClient == null || consumptionTracker == null) return; + AbrpUploadService.log().record(new UploadLog.Entry( + System.currentTimeMillis(), 0, true, "Tüketim yenileniyor…")); + new Thread(() -> { + consumptionCloudClient.flushNow(consumptionTracker, System.currentTimeMillis()); + consumptionCloudClient.invalidateHistory(); + runOnUiThread(() -> { + refreshConsumption(); + Toast.makeText(this, "Tüketim yenilendi", Toast.LENGTH_SHORT).show(); + }); + }, "cloud-refresh").start(); + } + private void refreshCallLog() { java.util.List entries = AbrpUploadService.log().recent(); if (entries.isEmpty()) { diff --git a/app/src/main/res/layout/pane_consumption.xml b/app/src/main/res/layout/pane_consumption.xml index edee196..bb614af 100644 --- a/app/src/main/res/layout/pane_consumption.xml +++ b/app/src/main/res/layout/pane_consumption.xml @@ -2,7 +2,10 @@ - + + + + diff --git a/app/src/main/res/layout/pane_log.xml b/app/src/main/res/layout/pane_log.xml index e0832eb..31ea5d8 100644 --- a/app/src/main/res/layout/pane_log.xml +++ b/app/src/main/res/layout/pane_log.xml @@ -2,6 +2,7 @@ + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f2e4732..194577a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -107,6 +107,7 @@ Recent uploads No upload attempted yet. + Şimdi yükle Starting… Running — waiting for first upload Last upload: %1$s (%2$s) diff --git a/web/src/app/v1/consumption/daily/route.ts b/web/src/app/v1/consumption/daily/route.ts index b4c26ba..7397ba6 100644 --- a/web/src/app/v1/consumption/daily/route.ts +++ b/web/src/app/v1/consumption/daily/route.ts @@ -9,11 +9,12 @@ export const dynamic = "force-dynamic"; /** * Upsert on (installationId, date), incrementally. The car sends a 2-minute delta chunk * (not a cumulative total), so each PUT adds to the day's running totals rather than - * overwriting them. Lifetime is a snapshot — the latest one wins, so it is $set not $inc. + * overwriting them. The app does NOT send lifetime — the backend derives it as the sum of + * all days' totals when history is read, so an APK wipe that zeroes the local counter can + * never corrupt the cloud's lifetime. * * $setOnInsert must not touch `day` at all: $inc on `day.km` and $setOnInsert on `day` - * conflict in MongoDB ("Updating the path 'day' would create a conflict at 'day'"). - * On an insert, $inc treats a missing field as 0 and adds to it, so no seed is needed. + * conflict in MongoDB. On an insert, $inc treats a missing field as 0 and adds to it. */ export async function PUT(request: Request) { const denied = requireIngest(request); @@ -24,7 +25,6 @@ export async function PUT(request: Request) { const collection = await consumptionCollection(); const updatedAt = new Date(); const $set: Record = { updatedAt }; - if (record.lifetime) $set.lifetime = record.lifetime; if (record.sohPercent !== undefined) $set.sohPercent = record.sohPercent; await collection.updateOne( { installationId: record.installationId, date: record.date }, diff --git a/web/src/app/v1/consumption/history/[installationId]/route.ts b/web/src/app/v1/consumption/history/[installationId]/route.ts index 631ba01..e44811f 100644 --- a/web/src/app/v1/consumption/history/[installationId]/route.ts +++ b/web/src/app/v1/consumption/history/[installationId]/route.ts @@ -27,6 +27,17 @@ export async function GET(request: Request, context: { params: Promise<{ install .sort({ date: -1 }) .limit(400) .toArray(); - return NextResponse.json({ records }); + // Lifetime = sum of every day's totals. Derived, never stored, so a corrupted or + // stale lifetime field in any single document cannot poison the total. + const lifetime = records.reduce( + (sum, r) => ({ + km: sum.km + (r.day?.km ?? 0), + kwh: sum.kwh + (r.day?.kwh ?? 0), + hours: sum.hours + (r.day?.hours ?? 0), + socDrop: sum.socDrop + (r.day?.socDrop ?? 0), + }), + { km: 0, kwh: 0, hours: 0, socDrop: 0 }, + ); + return NextResponse.json({ records, lifetime }); }); }