simplify: backend derives lifetime from day totals, app stops sending it
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<DayRecord> fetchHistoryIfDue() {
|
||||
synchronized List<DayRecord> 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<DayRecord> 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<DayRecord> 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<DayRecord> 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 {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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<UploadLog.Entry> entries = AbrpUploadService.log().recent();
|
||||
if (entries.isEmpty()) {
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" android:fillViewport="true">
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="24dp">
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="20dp" android:background="@drawable/bg_glass">
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="TÜKETİM DÖNEMİ" android:textAppearance="@style/Text.EV.Eyebrow"/>
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical">
|
||||
<TextView android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content" android:text="TÜKETİM DÖNEMİ" android:textAppearance="@style/Text.EV.Eyebrow"/>
|
||||
<com.google.android.material.button.MaterialButton android:id="@+id/consumption_refresh" style="@style/Widget.EV.Button.Outlined" android:layout_width="48dp" android:layout_height="48dp" android:minWidth="0dp" android:padding="0dp" android:contentDescription="Yenile" app:icon="@drawable/ic_refresh" app:iconGravity="textStart" app:iconPadding="0dp" app:iconTint="@color/ev_accent"/>
|
||||
</LinearLayout>
|
||||
<HorizontalScrollView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:scrollbars="none">
|
||||
<LinearLayout android:layout_width="wrap_content" android:layout_height="wrap_content" android:orientation="horizontal">
|
||||
<com.google.android.material.button.MaterialButton android:id="@+id/consumption_lifetime" style="@style/Widget.EV.Button.Outlined" android:layout_width="wrap_content" android:layout_height="56dp" android:text="Tüm zamanlar"/>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:fillViewport="true" android:clipToPadding="false">
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="24dp" android:background="@drawable/bg_glass">
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/label_call_log" android:textAppearance="@style/Text.EV.Eyebrow" />
|
||||
<com.google.android.material.button.MaterialButton android:id="@+id/flush_now_button" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:text="@string/btn_flush_now" app:icon="@drawable/ic_refresh" app:iconGravity="textStart" style="@style/Widget.EV.Button.Outlined" xmlns:app="http://schemas.android.com/apk/res-auto" />
|
||||
<TextView android:id="@+id/call_log_text" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="20dp" android:minHeight="180dp" android:text="@string/call_log_empty" android:textAppearance="@style/Text.EV.Body" android:fontFamily="monospace" android:textSize="17sp" android:lineSpacingExtra="12dp" android:textIsSelectable="true" />
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
|
||||
@@ -107,6 +107,7 @@
|
||||
<!-- Call log -->
|
||||
<string name="label_call_log">Recent uploads</string>
|
||||
<string name="call_log_empty">No upload attempted yet.</string>
|
||||
<string name="btn_flush_now">Şimdi yükle</string>
|
||||
<string name="status_starting">Starting…</string>
|
||||
<string name="status_running_no_upload">Running — waiting for first upload</string>
|
||||
<string name="status_last_upload">Last upload: %1$s (%2$s)</string>
|
||||
|
||||
Reference in New Issue
Block a user