diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c7782d..a621cd6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 2.2.35 + +- Added persistent charging sessions with SOC/power curves, duration, energy, unit price, + and total cost. +- Added an offline queue that retries completed charging sessions when connectivity returns. +- Added once-daily MongoDB consumption history with ordered catch-up after offline periods. +- Added authenticated history endpoints for a future personal mobile client. + All notable changes to this project are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). diff --git a/app/build.gradle b/app/build.gradle index c076913..c9c971b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -34,8 +34,8 @@ android { applicationId "com.evsuite.abrp" minSdk 27 targetSdk 36 - versionCode 51 - versionName "2.2.34" + versionCode 52 + versionName "2.2.35" // 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/AbrpUploadService.java b/app/src/main/java/com/evsuite/abrp/AbrpUploadService.java index 6ff63c6..965ebd4 100644 --- a/app/src/main/java/com/evsuite/abrp/AbrpUploadService.java +++ b/app/src/main/java/com/evsuite/abrp/AbrpUploadService.java @@ -131,6 +131,7 @@ public class AbrpUploadService extends Service { private EnergyTelemetryReader energyReader; private ConsumptionTracker consumptionTracker; private ConsumptionCloudClient consumptionCloudClient; + private ChargeSessionTracker chargeSessionTracker; /** * Keeps the unit on the phone hotspot without a trip into Settings. Attempted on the * upload tick because that is exactly when being offline costs something, and it is @@ -198,6 +199,7 @@ public class AbrpUploadService extends Service { energyReader = new EnergyTelemetryReader(getApplicationContext()); consumptionTracker = ConsumptionTracker.get(this); consumptionCloudClient = new ConsumptionCloudClient(getApplicationContext()); + chargeSessionTracker = ChargeSessionTracker.get(getApplicationContext()); wifi = new WifiAutoConnect(getApplicationContext()); // The head unit's own weather service, bound for one reason: ENV_OUTSIDE_TEMPERATURE // is not implemented on this vehicle, so the car cannot say how warm it is outside @@ -326,7 +328,8 @@ public class AbrpUploadService extends Service { long sampleMs = System.currentTimeMillis(); EnergySnapshot vehicle = energyReader.read(sampleMs); consumptionTracker.sample(vehicle); - consumptionCloudClient.syncIfDue(consumptionTracker.cloudSnapshot(), sampleMs); + chargeSessionTracker.sample(vehicle); + consumptionCloudClient.syncIfDue(consumptionTracker, chargeSessionTracker, sampleMs); // Keep local consumption history alive while another app owns the ABRP feed. if (!settings.abrpUploadEnabled) { diff --git a/app/src/main/java/com/evsuite/abrp/ChargeSessionTracker.java b/app/src/main/java/com/evsuite/abrp/ChargeSessionTracker.java new file mode 100644 index 0000000..de86ed4 --- /dev/null +++ b/app/src/main/java/com/evsuite/abrp/ChargeSessionTracker.java @@ -0,0 +1,242 @@ +package com.evsuite.abrp; + +import android.content.Context; +import android.content.SharedPreferences; + +import com.evsuite.hardware.telemetry.EnergySnapshot; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +/** Persistent charging session meter and offline upload queue. */ +final class ChargeSessionTracker { + private static final String PREFS = "charging_sessions_v1"; + private static final String ACTIVE = "active"; + private static final String LAST = "last_completed"; + private static final String PENDING = "pending"; + private static final String PRICE = "price_per_kwh"; + private static final long END_GRACE_MS = 60_000L; + private static final long CURVE_INTERVAL_MS = 60_000L; + private static final int MAX_CURVE_POINTS = 720; + + private static ChargeSessionTracker instance; + static synchronized ChargeSessionTracker get(Context context) { + if (instance == null) instance = new ChargeSessionTracker(context.getApplicationContext()); + return instance; + } + + static final class Point { + final long timestampMs; + final Float socPercent; + final float powerKw; + Point(long timestampMs, Float socPercent, float powerKw) { + this.timestampMs = timestampMs; this.socPercent = socPercent; this.powerKw = powerKw; + } + } + + static final class Snapshot { + final boolean charging; + final long startedAtMs; + final long durationSeconds; + final Float startSocPercent; + final Float currentSocPercent; + final double energyKwh; + final double pricePerKwh; + final double totalCost; + final List points; + + Snapshot(boolean charging, long startedAtMs, long durationSeconds, + Float startSocPercent, Float currentSocPercent, double energyKwh, + double pricePerKwh, List points) { + this.charging = charging; + this.startedAtMs = startedAtMs; + this.durationSeconds = durationSeconds; + this.startSocPercent = startSocPercent; + this.currentSocPercent = currentSocPercent; + this.energyKwh = energyKwh; + this.pricePerKwh = pricePerKwh; + this.totalCost = energyKwh * pricePerKwh; + this.points = points; + } + } + + private final SharedPreferences prefs; + private Session active; + private long endCandidateMs; + + private ChargeSessionTracker(Context context) { + prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE); + active = Session.fromJson(prefs.getString(ACTIVE, null)); + } + + synchronized void sample(EnergySnapshot snapshot) { + long now = snapshot.getTimestampMs(); + Float power = snapshot.getBatteryPowerKw(); + Float soc = snapshot.getSocPercent(); + boolean charging = ChargingState.isCharging(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); + persistActive(); + return; + } + if (active == null) return; + if (endCandidateMs == 0L) endCandidateMs = now; + if (now - endCandidateMs < END_GRACE_MS) return; + active.finish(endCandidateMs, soc, pricePerKwh()); + JSONObject completed = active.toJson(); + enqueue(completed); + prefs.edit().putString(LAST, completed.toString()).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)); + } + + synchronized boolean setPricePerKwh(double value) { + if (!Double.isFinite(value) || value < 0d || value > 1_000_000d) return false; + return prefs.edit().putLong(PRICE, Double.doubleToRawLongBits(value)).commit(); + } + + synchronized double pricePerKwh() { + return Double.longBitsToDouble(prefs.getLong(PRICE, 0L)); + } + + synchronized List pendingUploads() { + JSONArray array = pendingArray(); + List result = new ArrayList<>(); + for (int i = 0; i < array.length(); i++) { + JSONObject item = array.optJSONObject(i); + if (item != null) result.add(item); + } + return result; + } + + synchronized void markUploaded(String sessionId) { + JSONArray old = pendingArray(); + JSONArray kept = new JSONArray(); + for (int i = 0; i < old.length(); i++) { + JSONObject item = old.optJSONObject(i); + if (item != null && !sessionId.equals(item.optString("sessionId"))) kept.put(item); + } + prefs.edit().putString(PENDING, kept.toString()).commit(); + } + + private void persistActive() { + prefs.edit().putString(ACTIVE, active.toJson().toString()).commit(); + } + + private void enqueue(JSONObject session) { + JSONArray array = pendingArray(); + array.put(session); + prefs.edit().putString(PENDING, array.toString()).commit(); + } + + private JSONArray pendingArray() { + try { return new JSONArray(prefs.getString(PENDING, "[]")); } + catch (Exception ignored) { return new JSONArray(); } + } + + private static final class Session { + final String id; + final long startedAtMs; + final Float startSoc; + final List points = new ArrayList<>(); + long lastSampleMs; + Float lastSoc; + Float lastPower; + double energyKwh; + long endedAtMs; + double pricePerKwh; + + Session(String id, long startedAtMs, Float startSoc) { + this.id = id; this.startedAtMs = startedAtMs; this.startSoc = startSoc; + this.lastSampleMs = startedAtMs; 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; + } + 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); + } + } + + void finish(long endedAtMs, Float finalSoc, double price) { + this.endedAtMs = endedAtMs; + if (finalSoc != null) lastSoc = finalSoc; + pricePerKwh = price; + } + + JSONObject toJson() { + try { + JSONArray curve = new JSONArray(); + for (Point point : points) { + JSONObject p = new JSONObject().put("timestampMs", point.timestampMs) + .put("powerKw", point.powerKw); + if (point.socPercent != null) p.put("socPercent", point.socPercent); + 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("energyKwh", energyKwh).put("pricePerKwh", pricePerKwh) + .put("totalCost", energyKwh * pricePerKwh).put("curve", curve) + .put("lastSampleMs", lastSampleMs); + if (startSoc != null) json.put("startSocPercent", startSoc); + if (lastSoc != null) json.put("endSocPercent", lastSoc); + if (lastPower != null) json.put("lastPower", lastPower); + return json; + } catch (org.json.JSONException e) { + throw new IllegalStateException("Could not serialize charging session", e); + } + } + + static Session fromJson(String raw) { + if (raw == null || raw.isEmpty()) return null; + try { + 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.endedAtMs = json.optLong("endedAt", 0L); + session.pricePerKwh = json.optDouble("pricePerKwh", 0d); + JSONArray curve = json.optJSONArray("curve"); + if (curve != null) for (int i = 0; i < curve.length(); i++) { + JSONObject p = curve.getJSONObject(i); + session.points.add(new Point(p.getLong("timestampMs"), + p.has("socPercent") ? (float) p.getDouble("socPercent") : null, + (float) p.getDouble("powerKw"))); + } + return session; + } catch (Exception ignored) { return null; } + } + } +} diff --git a/app/src/main/java/com/evsuite/abrp/ChargingGraphView.java b/app/src/main/java/com/evsuite/abrp/ChargingGraphView.java index bfb61bc..9db5b47 100644 --- a/app/src/main/java/com/evsuite/abrp/ChargingGraphView.java +++ b/app/src/main/java/com/evsuite/abrp/ChargingGraphView.java @@ -22,6 +22,16 @@ public final class ChargingGraphView extends View { points.add(new float[]{soc,p}); if (points.size() > 500) points.remove(0); socPower[Math.max(0, Math.min(100, Math.round(soc)))] = p; invalidate(); } + synchronized void setPoints(List values) { + points.clear(); + java.util.Arrays.fill(socPower, Float.NaN); + for (ChargeSessionTracker.Point value : values) { + if (value.socPercent == null) continue; + points.add(new float[]{value.socPercent, value.powerKw}); + socPower[Math.max(0, Math.min(100, Math.round(value.socPercent)))] = value.powerKw; + } + invalidate(); + } synchronized String tableText() { StringBuilder out = new StringBuilder("SOC Şarj gücü\n"); boolean found = false; diff --git a/app/src/main/java/com/evsuite/abrp/ConsumptionCloudClient.java b/app/src/main/java/com/evsuite/abrp/ConsumptionCloudClient.java index 2911a5b..b184a13 100644 --- a/app/src/main/java/com/evsuite/abrp/ConsumptionCloudClient.java +++ b/app/src/main/java/com/evsuite/abrp/ConsumptionCloudClient.java @@ -11,6 +11,8 @@ import java.io.OutputStream; import java.net.HttpURLConnection; import java.net.URL; import java.nio.charset.StandardCharsets; +import java.time.LocalDate; +import java.util.List; import java.util.UUID; /** Best-effort MongoDB sync through the API in server/. Never blocks local persistence. */ @@ -20,16 +22,18 @@ final class ConsumptionCloudClient { private static final int TIMEOUT_MS = 8_000; 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 final String endpoint; private final String token; private final String installationId; + private final SharedPreferences prefs; private long lastAttemptMs; ConsumptionCloudClient(Context context) { endpoint = BuildConfig.CONSUMPTION_API_URL.trim(); token = BuildConfig.CONSUMPTION_API_TOKEN.trim(); - SharedPreferences prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE); + prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE); String existing = prefs.getString(INSTALLATION_ID, ""); if (existing == null || existing.isEmpty()) { existing = UUID.randomUUID().toString(); @@ -38,19 +42,55 @@ final class ConsumptionCloudClient { installationId = existing; } - void syncIfDue(ConsumptionTracker.CloudSnapshot snapshot, long nowMs) { + void syncIfDue(ConsumptionTracker tracker, ChargeSessionTracker charges, long nowMs) { if (endpoint.isEmpty() || token.isEmpty() || nowMs - lastAttemptMs < SYNC_INTERVAL_MS) return; lastAttemptMs = nowMs; + try { + syncDailyConsumption(tracker, nowMs); + syncPendingCharges(charges); + } catch (Exception e) { + Log.w(TAG, "Cloud sync unavailable"); + } + } + + 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)) { + prefs.edit().putString(LAST_CONSUMPTION_DATE, target.toString()).commit(); + } + } + + private void syncPendingCharges(ChargeSessionTracker charges) throws Exception { + List pending = charges.pendingUploads(); + int sent = 0; + for (JSONObject session : pending) { + JSONObject body = new JSONObject(session.toString()).put("installationId", installationId); + String id = session.getString("sessionId"); + if (!send("/v1/charging-sessions/" + id, "PUT", body)) return; + charges.markUploaded(id); + if (++sent >= 3) return; + } + } + + private boolean send(String path, String method, JSONObject body) throws Exception { HttpURLConnection connection = null; try { - JSONObject body = new JSONObject() - .put("installationId", installationId) - .put("recordedAt", nowMs) - .put("lifetime", totals(snapshot.lifetime)); - if (snapshot.sohPercent != null) body.put("sohPercent", snapshot.sohPercent); byte[] bytes = body.toString().getBytes(StandardCharsets.UTF_8); - connection = (HttpURLConnection) new URL(trimSlash(endpoint) + "/v1/consumption").openConnection(); - connection.setRequestMethod("PUT"); + connection = (HttpURLConnection) new URL(trimSlash(endpoint) + path).openConnection(); + connection.setRequestMethod(method); connection.setConnectTimeout(TIMEOUT_MS); connection.setReadTimeout(TIMEOUT_MS); connection.setDoOutput(true); @@ -59,11 +99,13 @@ final class ConsumptionCloudClient { connection.setRequestProperty("Authorization", "Bearer " + token); try (OutputStream out = connection.getOutputStream()) { out.write(bytes); } int status = connection.getResponseCode(); - try (InputStream ignored = status < 400 - ? connection.getInputStream() : connection.getErrorStream()) { } - if (status < 200 || status >= 300) Log.w(TAG, "Sync rejected with HTTP " + status); - } catch (Exception e) { - Log.w(TAG, "Consumption sync unavailable"); + InputStream response = status < 400 ? connection.getInputStream() : connection.getErrorStream(); + if (response != null) try (InputStream ignored = response) { } + if (status < 200 || status >= 300) { + Log.w(TAG, "Sync rejected with HTTP " + status); + return false; + } + return true; } finally { if (connection != null) connection.disconnect(); } diff --git a/app/src/main/java/com/evsuite/abrp/ConsumptionTracker.java b/app/src/main/java/com/evsuite/abrp/ConsumptionTracker.java index 0fd8d7a..3547d31 100644 --- a/app/src/main/java/com/evsuite/abrp/ConsumptionTracker.java +++ b/app/src/main/java/com/evsuite/abrp/ConsumptionTracker.java @@ -139,15 +139,35 @@ final class ConsumptionTracker { return loadTotals(dayKey(date)); } - synchronized CloudSnapshot cloudSnapshot() { - return new CloudSnapshot(lifetime.snapshot(), sohPercent()); + synchronized CloudSnapshot cloudSnapshot(LocalDate date) { + rollDayIfNeeded(); + return new CloudSnapshot(date.toString(), totalsForDay(date), lifetime.snapshot(), sohPercent()); + } + + synchronized LocalDate nextStoredDayAfter(LocalDate lastUploaded) { + rollDayIfNeeded(); + LocalDate latestComplete = LocalDate.now().minusDays(1); + LocalDate best = null; + for (String key : prefs.getAll().keySet()) { + if (!key.startsWith("day_") || key.length() < 14) continue; + try { + LocalDate date = LocalDate.parse(key.substring(4, 14)); + if (date.isAfter(latestComplete) || (lastUploaded != null && !date.isAfter(lastUploaded))) continue; + if (best == null || date.isBefore(best)) best = date; + } catch (RuntimeException ignored) { } + } + return best; } static final class CloudSnapshot { + final String date; + final Totals day; final Totals lifetime; final Float sohPercent; - CloudSnapshot(Totals lifetime, Float sohPercent) { + CloudSnapshot(String date, Totals day, Totals lifetime, Float sohPercent) { + this.date = date; + this.day = day; this.lifetime = lifetime; this.sohPercent = sohPercent; } diff --git a/app/src/main/java/com/evsuite/abrp/MainActivity.java b/app/src/main/java/com/evsuite/abrp/MainActivity.java index c7be7d7..f9e5f69 100644 --- a/app/src/main/java/com/evsuite/abrp/MainActivity.java +++ b/app/src/main/java/com/evsuite/abrp/MainActivity.java @@ -118,11 +118,12 @@ public class MainActivity extends AppCompatActivity { private TextInputLayout consumptionSohLayout; private TextInputEditText consumptionSohInput; private java.time.LocalDate selectedConsumptionDate = java.time.LocalDate.now(); - private TextView chargeStatus, chargeSoc, chargeVoltage, chargeCurrent, chargePower, - chargeEnergy, chargeDuration, chargeCurveTable; + private TextView chargeStatus, chargeSoc, chargeStartSoc, chargeVoltage, chargeCurrent, chargePower, + chargeEnergy, chargeDuration, chargeTotalCost, chargeCurveTable; + private TextInputLayout chargePriceLayout; + private TextInputEditText chargePriceInput; + private ChargeSessionTracker chargeSessionTracker; private ChargingGraphView chargeGraph; - private long chargeStartMs, lastChargeMs; - private double chargedKwh; /** * Inflates a page with no parent, then gives it the MATCH_PARENT/MATCH_PARENT layout @@ -927,40 +928,58 @@ public class MainActivity extends AppCompatActivity { } private void bindChargingPane() { + chargeSessionTracker = ChargeSessionTracker.get(this); chargeStatus = chargingPane.findViewById(R.id.charge_status); chargeSoc = chargingPane.findViewById(R.id.charge_soc); + chargeStartSoc = chargingPane.findViewById(R.id.charge_start_soc); chargeVoltage = chargingPane.findViewById(R.id.charge_voltage); chargeCurrent = chargingPane.findViewById(R.id.charge_current); chargePower = chargingPane.findViewById(R.id.charge_power); chargeEnergy = chargingPane.findViewById(R.id.charge_energy); chargeDuration = chargingPane.findViewById(R.id.charge_duration); + chargeTotalCost = chargingPane.findViewById(R.id.charge_total_cost); chargeGraph = chargingPane.findViewById(R.id.charge_graph); chargeCurveTable = chargingPane.findViewById(R.id.charge_curve_table); + chargePriceLayout = chargingPane.findViewById(R.id.charge_price_layout); + chargePriceInput = chargingPane.findViewById(R.id.charge_price_input); + chargePriceInput.setText(String.format(java.util.Locale.US, "%.2f", + chargeSessionTracker.pricePerKwh())); + chargingPane.findViewById(R.id.charge_price_save).setOnClickListener(v -> saveChargePrice()); + } + + private void saveChargePrice() { + String raw = textOf(chargePriceInput).replace(',', '.'); + try { + double value = Double.parseDouble(raw); + if (!chargeSessionTracker.setPricePerKwh(value)) throw new NumberFormatException(); + chargePriceLayout.setError(null); + chargePriceInput.setText(String.format(java.util.Locale.US, "%.2f", value)); + Toast.makeText(this, "Şarj fiyatı kaydedildi", Toast.LENGTH_SHORT).show(); + } catch (NumberFormatException e) { + chargePriceLayout.setError("Geçerli bir ₺/kWh değeri girin"); + } } private void refreshCharging(EnergySnapshot s) { if (chargeStatus == null) return; + chargeSessionTracker.sample(s); Integer code = s.getChargingStatus(); boolean charging = ChargingState.isCharging( code, s.getChargePortConnected(), s.getBatteryPowerKw(), s.getSpeedKmh()); - long now = s.getTimestampMs(); - if (charging && chargeStartMs == 0) { chargeStartMs = now; chargedKwh = 0; } - if (!charging) { chargeStartMs = 0; lastChargeMs = 0; chargedKwh = 0; } - if (charging && lastChargeMs > 0 && s.getBatteryPowerKw() != null) - chargedKwh += Math.max(0, -s.getBatteryPowerKw()) * (now-lastChargeMs) / 3_600_000d; - if (charging) lastChargeMs = now; - chargeStatus.setText(charging ? (code != null && code == 10 ? "DC hızlı şarj" : "AC şarj") : "Şarj yok"); + ChargeSessionTracker.Snapshot session = chargeSessionTracker.snapshot(); + chargeStatus.setText(charging ? (code != null && code == 10 ? "DC hızlı şarj" : "AC şarj") + : (session.startedAtMs > 0 ? "Son şarj" : "Şarj yok")); chargeSoc.setText(fmt(s.getSocPercent(), "%.1f %%")); + chargeStartSoc.setText(fmt(session.startSocPercent, "%.1f %%")); chargeVoltage.setText(fmt(s.getBatteryVoltageV(), "%.1f V")); chargeCurrent.setText(fmt(s.getBatteryCurrentA(), "%.1f A")); chargePower.setText(s.getBatteryPowerKw() == null ? "—" : String.format(java.util.Locale.getDefault(), "%.1f kW", Math.abs(s.getBatteryPowerKw()))); - chargeEnergy.setText(charging ? String.format(java.util.Locale.getDefault(), "%.2f kWh", chargedKwh) : "—"); - long sec = chargeStartMs == 0 ? 0 : (now-chargeStartMs)/1000; - chargeDuration.setText(charging ? String.format(java.util.Locale.getDefault(), "%02d:%02d:%02d", sec/3600,(sec%3600)/60,sec%60) : "—"); - if (charging) { - chargeGraph.add(s.getSocPercent(), s.getBatteryPowerKw()); - chargeCurveTable.setText(chargeGraph.tableText()); - } + chargeEnergy.setText(session.startedAtMs > 0 ? String.format(java.util.Locale.getDefault(), "%.2f kWh", session.energyKwh) : "—"); + long sec = session.durationSeconds; + chargeDuration.setText(session.startedAtMs > 0 ? String.format(java.util.Locale.getDefault(), "%02d:%02d:%02d", sec/3600,(sec%3600)/60,sec%60) : "—"); + chargeTotalCost.setText(session.startedAtMs > 0 ? String.format(java.util.Locale.getDefault(), "%.2f ₺", session.totalCost) : "—"); + chargeGraph.setPoints(session.points); + chargeCurveTable.setText(chargeGraph.tableText()); } private void refreshConsumption() { diff --git a/app/src/main/res/layout/pane_charging.xml b/app/src/main/res/layout/pane_charging.xml index 5f8bcea..55356de 100644 --- a/app/src/main/res/layout/pane_charging.xml +++ b/app/src/main/res/layout/pane_charging.xml @@ -1,17 +1,26 @@ - + + + + + + + + + + diff --git a/server/README.md b/server/README.md index 15821f0..24a56ef 100644 --- a/server/README.md +++ b/server/README.md @@ -1,7 +1,8 @@ # FatihsMG4 consumption API The Android app never connects to MongoDB directly and never contains a MongoDB URI. This -service accepts authenticated all-time totals and upserts them into the `consumption` collection. +service stores completed daily totals in `daily_consumption` and each completed charge, +including its SOC/power curve and price, in `charging_sessions`. ## Run @@ -17,5 +18,10 @@ service accepts authenticated all-time totals and upserts them into the `consump ``` If either Android build variable is missing, cloud sync is disabled and local consumption -tracking continues unchanged. MongoDB receives one continuously updated document per installation, -containing all-time `km`, `kwh`, `hours`, and `socDrop` totals plus the manually entered `sohPercent`. +tracking continues unchanged. The car retries locally queued charging sessions every five minutes. +Daily consumption is uploaded once after the day closes; missed days are retried in order. + +The future mobile client can read authenticated history from: + +- `GET /v1/consumption/:installationId` +- `GET /v1/charging-sessions/:installationId` diff --git a/server/src/app.js b/server/src/app.js index 7cc4ef4..e280f4f 100644 --- a/server/src/app.js +++ b/server/src/app.js @@ -1,43 +1,47 @@ import crypto from "node:crypto"; import express from "express"; -const MAX_BODY_BYTES = "16kb"; - -export function createApp({ collection, apiToken }) { - if (!collection) throw new Error("collection is required"); +export function createApp({ consumptionCollection, chargingCollection, apiToken }) { + if (!consumptionCollection || !chargingCollection) throw new Error("collections are required"); if (!apiToken || apiToken.length < 24) throw new Error("API_TOKEN must contain at least 24 characters"); - const app = express(); app.disable("x-powered-by"); - app.use(express.json({ limit: MAX_BODY_BYTES, strict: true })); - + app.use(express.json({ limit: "256kb", strict: true })); + const auth = authenticate(apiToken); app.get("/health", (_request, response) => response.json({ status: "ok" })); - app.put("/v1/consumption", authenticate(apiToken), async (request, response, next) => { - try { - const record = validateRecord(request.body); - const updatedAt = new Date(); - await collection.updateOne( - { installationId: record.installationId }, - { - $set: { ...record, updatedAt }, - $setOnInsert: { createdAt: updatedAt } - }, - { upsert: true } - ); - response.status(204).end(); - } catch (error) { - if (error instanceof ValidationError) { - response.status(400).json({ error: error.message }); - return; - } - next(error); - } - }); + app.put("/v1/consumption/daily", auth, route(async (request, response) => { + const record = validateConsumption(request.body); + const updatedAt = new Date(); + await consumptionCollection.updateOne( + { installationId: record.installationId, date: record.date }, + { $set: { ...record, updatedAt }, $setOnInsert: { createdAt: updatedAt } }, { upsert: true }); + response.status(204).end(); + })); + + app.put("/v1/charging-sessions/:sessionId", auth, route(async (request, response) => { + const record = validateChargingSession(request.body, request.params.sessionId); + const updatedAt = new Date(); + await chargingCollection.updateOne( + { installationId: record.installationId, sessionId: record.sessionId }, + { $set: { ...record, updatedAt }, $setOnInsert: { createdAt: updatedAt } }, { upsert: true }); + response.status(204).end(); + })); + + app.get("/v1/consumption/:installationId", auth, route(async (request, response) => { + const installationId = validateUuid(request.params.installationId, "installationId"); + const records = await consumptionCollection.find({ installationId }).sort({ date: -1 }).limit(400).toArray(); + response.json({ records }); + })); + app.get("/v1/charging-sessions/:installationId", auth, route(async (request, response) => { + const installationId = validateUuid(request.params.installationId, "installationId"); + const records = await chargingCollection.find({ installationId }).sort({ startedAt: -1 }).limit(200).toArray(); + response.json({ records }); + })); app.use((error, _request, response, _next) => { - if (error?.type === "entity.parse.failed" || error?.type === "entity.too.large") { - response.status(400).json({ error: "invalid JSON body" }); + if (error instanceof ValidationError || error?.type === "entity.parse.failed" || error?.type === "entity.too.large") { + response.status(400).json({ error: error.message || "invalid request" }); return; } console.error("Request failed", error); @@ -46,16 +50,16 @@ export function createApp({ collection, apiToken }) { return app; } +function route(handler) { return (request, response, next) => Promise.resolve(handler(request, response)).catch(next); } + function authenticate(expected) { const expectedBytes = Buffer.from(expected); return (request, response, next) => { const header = request.get("authorization") ?? ""; const supplied = header.startsWith("Bearer ") ? header.slice(7) : ""; const suppliedBytes = Buffer.from(supplied); - if (suppliedBytes.length !== expectedBytes.length || - !crypto.timingSafeEqual(suppliedBytes, expectedBytes)) { - response.status(401).json({ error: "unauthorized" }); - return; + if (suppliedBytes.length !== expectedBytes.length || !crypto.timingSafeEqual(suppliedBytes, expectedBytes)) { + response.status(401).json({ error: "unauthorized" }); return; } next(); }; @@ -63,37 +67,63 @@ function authenticate(expected) { class ValidationError extends Error {} -function validateRecord(body) { - if (!body || typeof body !== "object" || Array.isArray(body)) throw new ValidationError("body is required"); - if (!/^[0-9a-f]{8}-[0-9a-f-]{27}$/i.test(body.installationId ?? "")) { - throw new ValidationError("installationId is invalid"); - } - if (!Number.isSafeInteger(body.recordedAt) || body.recordedAt < 0) { - throw new ValidationError("recordedAt is invalid"); - } +function validateConsumption(body) { + validateBody(body); + if (!/^\d{4}-\d{2}-\d{2}$/.test(body.date ?? "")) throw new ValidationError("date is invalid"); return { - installationId: body.installationId, - recordedAt: new Date(body.recordedAt), + installationId: validateUuid(body.installationId, "installationId"), date: body.date, + recordedAt: validateDate(body.recordedAt, "recordedAt"), day: validateTotals(body.day, "day"), lifetime: validateTotals(body.lifetime, "lifetime"), - ...(body.sohPercent === undefined ? {} : { sohPercent: validateSoh(body.sohPercent) }) + ...(body.sohPercent === undefined ? {} : { sohPercent: bounded(body.sohPercent, 0, 100, "sohPercent") }) }; } -function validateSoh(value) { - if (typeof value !== "number" || !Number.isFinite(value) || value <= 0 || value > 100) { - throw new ValidationError("sohPercent is invalid"); - } - return value; +function validateChargingSession(body, pathSessionId) { + validateBody(body); + const sessionId = validateUuid(body.sessionId, "sessionId"); + if (sessionId !== pathSessionId) throw new ValidationError("sessionId does not match path"); + if (!Array.isArray(body.curve) || body.curve.length > 720) throw new ValidationError("curve is invalid"); + const curve = body.curve.map((point) => ({ + timestamp: validateDate(point.timestampMs, "curve.timestampMs"), + powerKw: bounded(point.powerKw, 0, 1000, "curve.powerKw"), + ...(point.socPercent === undefined ? {} : { socPercent: bounded(point.socPercent, 0, 100, "curve.socPercent") }) + })); + return { + installationId: validateUuid(body.installationId, "installationId"), sessionId, + startedAt: validateDate(body.startedAt, "startedAt"), endedAt: validateDate(body.endedAt, "endedAt"), + durationSeconds: bounded(body.durationSeconds, 0, 604800, "durationSeconds"), + energyKwh: bounded(body.energyKwh, 0, 1000, "energyKwh"), + pricePerKwh: bounded(body.pricePerKwh, 0, 1_000_000, "pricePerKwh"), + totalCost: bounded(body.totalCost, 0, 1_000_000_000, "totalCost"), + ...(body.startSocPercent === undefined ? {} : { startSocPercent: bounded(body.startSocPercent, 0, 100, "startSocPercent") }), + ...(body.endSocPercent === undefined ? {} : { endSocPercent: bounded(body.endSocPercent, 0, 100, "endSocPercent") }), curve + }; } +function validateBody(body) { + if (!body || typeof body !== "object" || Array.isArray(body)) throw new ValidationError("body is required"); +} +function validateUuid(value, name) { + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value ?? "")) + throw new ValidationError(`${name} is invalid`); + return value; +} +function validateDate(value, name) { + if (!Number.isSafeInteger(value) || value < 0) throw new ValidationError(`${name} is invalid`); + return new Date(value); +} +function bounded(value, min, max, name) { + if (typeof value !== "number" || !Number.isFinite(value) || value < min || value > max) + throw new ValidationError(`${name} is invalid`); + return value; +} function validateTotals(value, name) { if (!value || typeof value !== "object" || Array.isArray(value)) throw new ValidationError(`${name} is invalid`); const result = {}; for (const field of ["km", "kwh", "hours", "socDrop"]) { const number = value[field]; - if (typeof number !== "number" || !Number.isFinite(number) || Math.abs(number) > 10_000_000) { + if (typeof number !== "number" || !Number.isFinite(number) || Math.abs(number) > 10_000_000) throw new ValidationError(`${name}.${field} is invalid`); - } result[field] = number; } return result; diff --git a/server/src/server.js b/server/src/server.js index bc85f3f..2beff3c 100644 --- a/server/src/server.js +++ b/server/src/server.js @@ -11,11 +11,15 @@ if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("PORT i const client = new MongoClient(MONGODB_URI, { serverSelectionTimeoutMS: 10_000 }); await client.connect(); -const collection = client.db(databaseName).collection("consumption"); -await collection.createIndex({ installationId: 1 }, { unique: true }); -await collection.createIndex({ updatedAt: -1 }); +const database = client.db(databaseName); +const consumptionCollection = database.collection("daily_consumption"); +const chargingCollection = database.collection("charging_sessions"); +await consumptionCollection.createIndex({ installationId: 1, date: 1 }, { unique: true }); +await consumptionCollection.createIndex({ installationId: 1, date: -1 }); +await chargingCollection.createIndex({ installationId: 1, sessionId: 1 }, { unique: true }); +await chargingCollection.createIndex({ installationId: 1, startedAt: -1 }); -const app = createApp({ collection, apiToken: API_TOKEN }); +const app = createApp({ consumptionCollection, chargingCollection, apiToken: API_TOKEN }); const server = app.listen(port, () => console.log(`FatihsMG4 API listening on ${port}`)); async function shutdown() { diff --git a/server/test/app.test.js b/server/test/app.test.js index cbc7285..dd6014b 100644 --- a/server/test/app.test.js +++ b/server/test/app.test.js @@ -3,50 +3,55 @@ import { after, before, test } from "node:test"; import { createApp } from "../src/app.js"; const token = "a-secure-test-token-with-24-chars"; -const writes = []; -const collection = { - async updateOne(filter, update, options) { writes.push({ filter, update, options }); } -}; -let server; -let baseUrl; +const consumptionWrites = [], chargingWrites = []; +const readable = (writes) => ({ + async updateOne(filter, update, options) { writes.push({ filter, update, options }); }, + find() { return { sort() { return this; }, limit() { return this; }, async toArray() { return []; } }; } +}); +let server, baseUrl; before(async () => { - const app = createApp({ collection, apiToken: token }); + const app = createApp({ consumptionCollection: readable(consumptionWrites), + chargingCollection: readable(chargingWrites), apiToken: token }); await new Promise((resolve) => { server = app.listen(0, "127.0.0.1", resolve); }); baseUrl = `http://127.0.0.1:${server.address().port}`; }); after(() => new Promise((resolve) => server.close(resolve))); -const validBody = { - installationId: "123e4567-e89b-12d3-a456-426614174000", - recordedAt: 1791417600000, - lifetime: { km: 1000, kwh: 170, hours: 30, socDrop: 400 }, - sohPercent: 91.5 -}; +const auth = { "content-type": "application/json", authorization: `Bearer ${token}` }; +const installationId = "123e4567-e89b-42d3-a456-426614174000"; +const consumption = { installationId, recordedAt: 1791417600000, date: "2026-10-08", + day: { km: 12, kwh: 2.1, hours: .4, socDrop: 4 }, + lifetime: { km: 1000, kwh: 170, hours: 30, socDrop: 400 }, sohPercent: 91.5 }; test("rejects requests without the API token", async () => { - const response = await fetch(`${baseUrl}/v1/consumption`, { method: "PUT", body: JSON.stringify(validBody), headers: { "content-type": "application/json" } }); + const response = await fetch(`${baseUrl}/v1/consumption/daily`, { method: "PUT", body: JSON.stringify(consumption), headers: { "content-type": "application/json" } }); assert.equal(response.status, 401); }); -test("validates malformed all-time totals", async () => { - const response = await fetch(`${baseUrl}/v1/consumption`, { - method: "PUT", - headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, - body: JSON.stringify({ ...validBody, lifetime: { ...validBody.lifetime, km: "12" } }) - }); - assert.equal(response.status, 400); +test("upserts one daily consumption record", async () => { + const response = await fetch(`${baseUrl}/v1/consumption/daily`, { method: "PUT", headers: auth, body: JSON.stringify(consumption) }); + assert.equal(response.status, 204); + assert.deepEqual(consumptionWrites.at(-1).filter, { installationId, date: "2026-10-08" }); + assert.equal(consumptionWrites.at(-1).update.$set.day.kwh, 2.1); }); -test("upserts one all-time record per installation", async () => { - const response = await fetch(`${baseUrl}/v1/consumption`, { - method: "PUT", - headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, - body: JSON.stringify(validBody) - }); +test("upserts a complete charging session with its curve and price", async () => { + const sessionId = "223e4567-e89b-42d3-a456-426614174001"; + const session = { installationId, sessionId, startedAt: 1791417600000, endedAt: 1791421200000, + durationSeconds: 3600, energyKwh: 50, pricePerKwh: 8.5, totalCost: 425, + startSocPercent: 10, endSocPercent: 90, + curve: [{ timestampMs: 1791417600000, socPercent: 10, powerKw: 72 }] }; + const response = await fetch(`${baseUrl}/v1/charging-sessions/${sessionId}`, + { method: "PUT", headers: auth, body: JSON.stringify(session) }); assert.equal(response.status, 204); - assert.deepEqual(writes.at(-1).filter, { installationId: validBody.installationId }); - assert.equal(writes.at(-1).options.upsert, true); - assert.equal(writes.at(-1).update.$set.lifetime.kwh, 170); - assert.equal(writes.at(-1).update.$set.sohPercent, 91.5); + assert.deepEqual(chargingWrites.at(-1).filter, { installationId, sessionId }); + assert.equal(chargingWrites.at(-1).update.$set.totalCost, 425); + assert.equal(chargingWrites.at(-1).update.$set.curve[0].powerKw, 72); +}); + +test("read APIs are authenticated and mobile-client ready", async () => { + const response = await fetch(`${baseUrl}/v1/charging-sessions/${installationId}`, { headers: { authorization: `Bearer ${token}` } }); + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { records: [] }); });