Track charging sessions and daily consumption history

This commit is contained in:
TCFDONMEZ
2026-10-08 13:39:40 +03:00
parent 9a6c121d09
commit 1a37e52eda
13 changed files with 527 additions and 129 deletions
+8
View File
@@ -1,5 +1,13 @@
# Changelog # 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 All notable changes to this project are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow
[Semantic Versioning](https://semver.org/spec/v2.0.0.html). [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+2 -2
View File
@@ -34,8 +34,8 @@ android {
applicationId "com.evsuite.abrp" applicationId "com.evsuite.abrp"
minSdk 27 minSdk 27
targetSdk 36 targetSdk 36
versionCode 51 versionCode 52
versionName "2.2.34" versionName "2.2.35"
// MongoDB credentials never ship in the APK. The app only talks to the small // MongoDB credentials never ship in the APK. The app only talks to the small
// authenticated API in server/, which owns the database connection. // authenticated API in server/, which owns the database connection.
@@ -131,6 +131,7 @@ public class AbrpUploadService extends Service {
private EnergyTelemetryReader energyReader; private EnergyTelemetryReader energyReader;
private ConsumptionTracker consumptionTracker; private ConsumptionTracker consumptionTracker;
private ConsumptionCloudClient consumptionCloudClient; private ConsumptionCloudClient consumptionCloudClient;
private ChargeSessionTracker chargeSessionTracker;
/** /**
* Keeps the unit on the phone hotspot without a trip into Settings. Attempted on the * 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 * 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()); energyReader = new EnergyTelemetryReader(getApplicationContext());
consumptionTracker = ConsumptionTracker.get(this); consumptionTracker = ConsumptionTracker.get(this);
consumptionCloudClient = new ConsumptionCloudClient(getApplicationContext()); consumptionCloudClient = new ConsumptionCloudClient(getApplicationContext());
chargeSessionTracker = ChargeSessionTracker.get(getApplicationContext());
wifi = new WifiAutoConnect(getApplicationContext()); wifi = new WifiAutoConnect(getApplicationContext());
// The head unit's own weather service, bound for one reason: ENV_OUTSIDE_TEMPERATURE // 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 // 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(); long sampleMs = System.currentTimeMillis();
EnergySnapshot vehicle = energyReader.read(sampleMs); EnergySnapshot vehicle = energyReader.read(sampleMs);
consumptionTracker.sample(vehicle); 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. // Keep local consumption history alive while another app owns the ABRP feed.
if (!settings.abrpUploadEnabled) { if (!settings.abrpUploadEnabled) {
@@ -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<Point> points;
Snapshot(boolean charging, long startedAtMs, long durationSeconds,
Float startSocPercent, Float currentSocPercent, double energyKwh,
double pricePerKwh, List<Point> 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<JSONObject> pendingUploads() {
JSONArray array = pendingArray();
List<JSONObject> 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<Point> 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; }
}
}
}
@@ -22,6 +22,16 @@ public final class ChargingGraphView extends View {
points.add(new float[]{soc,p}); if (points.size() > 500) points.remove(0); 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(); socPower[Math.max(0, Math.min(100, Math.round(soc)))] = p; invalidate();
} }
synchronized void setPoints(List<ChargeSessionTracker.Point> 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() { synchronized String tableText() {
StringBuilder out = new StringBuilder("SOC Şarj gücü\n"); StringBuilder out = new StringBuilder("SOC Şarj gücü\n");
boolean found = false; boolean found = false;
@@ -11,6 +11,8 @@ import java.io.OutputStream;
import java.net.HttpURLConnection; import java.net.HttpURLConnection;
import java.net.URL; import java.net.URL;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID; import java.util.UUID;
/** Best-effort MongoDB sync through the API in server/. Never blocks local persistence. */ /** 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 int TIMEOUT_MS = 8_000;
private static final String PREFS = "consumption_cloud"; private static final String PREFS = "consumption_cloud";
private static final String INSTALLATION_ID = "installation_id"; private static final String INSTALLATION_ID = "installation_id";
private static final String LAST_CONSUMPTION_DATE = "last_consumption_date";
private final String endpoint; private final String endpoint;
private final String token; private final String token;
private final String installationId; private final String installationId;
private final SharedPreferences prefs;
private long lastAttemptMs; private long lastAttemptMs;
ConsumptionCloudClient(Context context) { ConsumptionCloudClient(Context context) {
endpoint = BuildConfig.CONSUMPTION_API_URL.trim(); endpoint = BuildConfig.CONSUMPTION_API_URL.trim();
token = BuildConfig.CONSUMPTION_API_TOKEN.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, ""); String existing = prefs.getString(INSTALLATION_ID, "");
if (existing == null || existing.isEmpty()) { if (existing == null || existing.isEmpty()) {
existing = UUID.randomUUID().toString(); existing = UUID.randomUUID().toString();
@@ -38,19 +42,55 @@ final class ConsumptionCloudClient {
installationId = existing; 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; if (endpoint.isEmpty() || token.isEmpty() || nowMs - lastAttemptMs < SYNC_INTERVAL_MS) return;
lastAttemptMs = nowMs; lastAttemptMs = nowMs;
HttpURLConnection connection = null;
try { 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() JSONObject body = new JSONObject()
.put("installationId", installationId) .put("installationId", installationId)
.put("recordedAt", nowMs) .put("recordedAt", nowMs)
.put("date", snapshot.date)
.put("day", totals(snapshot.day))
.put("lifetime", totals(snapshot.lifetime)); .put("lifetime", totals(snapshot.lifetime));
if (snapshot.sohPercent != null) body.put("sohPercent", snapshot.sohPercent); if (snapshot.sohPercent != null) body.put("sohPercent", snapshot.sohPercent);
if (send("/v1/consumption/daily", "PUT", body)) {
prefs.edit().putString(LAST_CONSUMPTION_DATE, target.toString()).commit();
}
}
private void syncPendingCharges(ChargeSessionTracker charges) throws Exception {
List<JSONObject> 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 {
byte[] bytes = body.toString().getBytes(StandardCharsets.UTF_8); byte[] bytes = body.toString().getBytes(StandardCharsets.UTF_8);
connection = (HttpURLConnection) new URL(trimSlash(endpoint) + "/v1/consumption").openConnection(); connection = (HttpURLConnection) new URL(trimSlash(endpoint) + path).openConnection();
connection.setRequestMethod("PUT"); connection.setRequestMethod(method);
connection.setConnectTimeout(TIMEOUT_MS); connection.setConnectTimeout(TIMEOUT_MS);
connection.setReadTimeout(TIMEOUT_MS); connection.setReadTimeout(TIMEOUT_MS);
connection.setDoOutput(true); connection.setDoOutput(true);
@@ -59,11 +99,13 @@ final class ConsumptionCloudClient {
connection.setRequestProperty("Authorization", "Bearer " + token); connection.setRequestProperty("Authorization", "Bearer " + token);
try (OutputStream out = connection.getOutputStream()) { out.write(bytes); } try (OutputStream out = connection.getOutputStream()) { out.write(bytes); }
int status = connection.getResponseCode(); int status = connection.getResponseCode();
try (InputStream ignored = status < 400 InputStream response = status < 400 ? connection.getInputStream() : connection.getErrorStream();
? connection.getInputStream() : connection.getErrorStream()) { } if (response != null) try (InputStream ignored = response) { }
if (status < 200 || status >= 300) Log.w(TAG, "Sync rejected with HTTP " + status); if (status < 200 || status >= 300) {
} catch (Exception e) { Log.w(TAG, "Sync rejected with HTTP " + status);
Log.w(TAG, "Consumption sync unavailable"); return false;
}
return true;
} finally { } finally {
if (connection != null) connection.disconnect(); if (connection != null) connection.disconnect();
} }
@@ -139,15 +139,35 @@ final class ConsumptionTracker {
return loadTotals(dayKey(date)); return loadTotals(dayKey(date));
} }
synchronized CloudSnapshot cloudSnapshot() { synchronized CloudSnapshot cloudSnapshot(LocalDate date) {
return new CloudSnapshot(lifetime.snapshot(), sohPercent()); 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 { static final class CloudSnapshot {
final String date;
final Totals day;
final Totals lifetime; final Totals lifetime;
final Float sohPercent; 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.lifetime = lifetime;
this.sohPercent = sohPercent; this.sohPercent = sohPercent;
} }
@@ -118,11 +118,12 @@ public class MainActivity extends AppCompatActivity {
private TextInputLayout consumptionSohLayout; private TextInputLayout consumptionSohLayout;
private TextInputEditText consumptionSohInput; private TextInputEditText consumptionSohInput;
private java.time.LocalDate selectedConsumptionDate = java.time.LocalDate.now(); private java.time.LocalDate selectedConsumptionDate = java.time.LocalDate.now();
private TextView chargeStatus, chargeSoc, chargeVoltage, chargeCurrent, chargePower, private TextView chargeStatus, chargeSoc, chargeStartSoc, chargeVoltage, chargeCurrent, chargePower,
chargeEnergy, chargeDuration, chargeCurveTable; chargeEnergy, chargeDuration, chargeTotalCost, chargeCurveTable;
private TextInputLayout chargePriceLayout;
private TextInputEditText chargePriceInput;
private ChargeSessionTracker chargeSessionTracker;
private ChargingGraphView chargeGraph; 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 * Inflates a page with no parent, then gives it the MATCH_PARENT/MATCH_PARENT layout
@@ -927,41 +928,59 @@ public class MainActivity extends AppCompatActivity {
} }
private void bindChargingPane() { private void bindChargingPane() {
chargeSessionTracker = ChargeSessionTracker.get(this);
chargeStatus = chargingPane.findViewById(R.id.charge_status); chargeStatus = chargingPane.findViewById(R.id.charge_status);
chargeSoc = chargingPane.findViewById(R.id.charge_soc); chargeSoc = chargingPane.findViewById(R.id.charge_soc);
chargeStartSoc = chargingPane.findViewById(R.id.charge_start_soc);
chargeVoltage = chargingPane.findViewById(R.id.charge_voltage); chargeVoltage = chargingPane.findViewById(R.id.charge_voltage);
chargeCurrent = chargingPane.findViewById(R.id.charge_current); chargeCurrent = chargingPane.findViewById(R.id.charge_current);
chargePower = chargingPane.findViewById(R.id.charge_power); chargePower = chargingPane.findViewById(R.id.charge_power);
chargeEnergy = chargingPane.findViewById(R.id.charge_energy); chargeEnergy = chargingPane.findViewById(R.id.charge_energy);
chargeDuration = chargingPane.findViewById(R.id.charge_duration); chargeDuration = chargingPane.findViewById(R.id.charge_duration);
chargeTotalCost = chargingPane.findViewById(R.id.charge_total_cost);
chargeGraph = chargingPane.findViewById(R.id.charge_graph); chargeGraph = chargingPane.findViewById(R.id.charge_graph);
chargeCurveTable = chargingPane.findViewById(R.id.charge_curve_table); 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) { private void refreshCharging(EnergySnapshot s) {
if (chargeStatus == null) return; if (chargeStatus == null) return;
chargeSessionTracker.sample(s);
Integer code = s.getChargingStatus(); Integer code = s.getChargingStatus();
boolean charging = ChargingState.isCharging( boolean charging = ChargingState.isCharging(
code, s.getChargePortConnected(), s.getBatteryPowerKw(), s.getSpeedKmh()); code, s.getChargePortConnected(), s.getBatteryPowerKw(), s.getSpeedKmh());
long now = s.getTimestampMs(); ChargeSessionTracker.Snapshot session = chargeSessionTracker.snapshot();
if (charging && chargeStartMs == 0) { chargeStartMs = now; chargedKwh = 0; } chargeStatus.setText(charging ? (code != null && code == 10 ? "DC hızlı şarj" : "AC şarj")
if (!charging) { chargeStartMs = 0; lastChargeMs = 0; chargedKwh = 0; } : (session.startedAtMs > 0 ? "Son şarj" : "Şarj yok"));
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");
chargeSoc.setText(fmt(s.getSocPercent(), "%.1f %%")); chargeSoc.setText(fmt(s.getSocPercent(), "%.1f %%"));
chargeStartSoc.setText(fmt(session.startSocPercent, "%.1f %%"));
chargeVoltage.setText(fmt(s.getBatteryVoltageV(), "%.1f V")); chargeVoltage.setText(fmt(s.getBatteryVoltageV(), "%.1f V"));
chargeCurrent.setText(fmt(s.getBatteryCurrentA(), "%.1f A")); chargeCurrent.setText(fmt(s.getBatteryCurrentA(), "%.1f A"));
chargePower.setText(s.getBatteryPowerKw() == null ? "—" : String.format(java.util.Locale.getDefault(), "%.1f kW", Math.abs(s.getBatteryPowerKw()))); 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) : "—"); chargeEnergy.setText(session.startedAtMs > 0 ? String.format(java.util.Locale.getDefault(), "%.2f kWh", session.energyKwh) : "—");
long sec = chargeStartMs == 0 ? 0 : (now-chargeStartMs)/1000; long sec = session.durationSeconds;
chargeDuration.setText(charging ? String.format(java.util.Locale.getDefault(), "%02d:%02d:%02d", sec/3600,(sec%3600)/60,sec%60) : "—"); chargeDuration.setText(session.startedAtMs > 0 ? String.format(java.util.Locale.getDefault(), "%02d:%02d:%02d", sec/3600,(sec%3600)/60,sec%60) : "—");
if (charging) { chargeTotalCost.setText(session.startedAtMs > 0 ? String.format(java.util.Locale.getDefault(), "%.2f ₺", session.totalCost) : "—");
chargeGraph.add(s.getSocPercent(), s.getBatteryPowerKw()); chargeGraph.setPoints(session.points);
chargeCurveTable.setText(chargeGraph.tableText()); chargeCurveTable.setText(chargeGraph.tableText());
} }
}
private void refreshConsumption() { private void refreshConsumption() {
if (consumptionTracker == null) return; if (consumptionTracker == null) return;
+10 -1
View File
@@ -1,17 +1,26 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:fillViewport="true"> <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="24dp">
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="20dp" android:background="@drawable/bg_glass"> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="20dp" android:background="@drawable/bg_glass">
<TextView android:id="@+id/charge_status" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="Şarj yok" android:textAppearance="@style/Text.EV.Title" android:textColor="@color/ev_cyan"/> <TextView android:id="@+id/charge_status" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="Şarj yok" android:textAppearance="@style/Text.EV.Title" android:textColor="@color/ev_cyan"/>
<GridLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:columnCount="2" android:layout_marginTop="12dp"> <GridLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:columnCount="2" android:layout_marginTop="12dp">
<TextView android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="SOC" android:textAppearance="@style/DataLabel"/><TextView android:id="@+id/charge_soc" android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="—" android:textAppearance="@style/DataValue"/> <TextView android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="SOC" android:textAppearance="@style/DataLabel"/><TextView android:id="@+id/charge_soc" android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="—" android:textAppearance="@style/DataValue"/>
<TextView android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="Başlangıç SOC" android:textAppearance="@style/DataLabel"/><TextView android:id="@+id/charge_start_soc" android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="—" android:textAppearance="@style/DataValue"/>
<TextView android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="Batarya voltajı" android:textAppearance="@style/DataLabel"/><TextView android:id="@+id/charge_voltage" android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="—" android:textAppearance="@style/DataValue"/> <TextView android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="Batarya voltajı" android:textAppearance="@style/DataLabel"/><TextView android:id="@+id/charge_voltage" android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="—" android:textAppearance="@style/DataValue"/>
<TextView android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="Batarya akımı" android:textAppearance="@style/DataLabel"/><TextView android:id="@+id/charge_current" android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="—" android:textAppearance="@style/DataValue"/> <TextView android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="Batarya akımı" android:textAppearance="@style/DataLabel"/><TextView android:id="@+id/charge_current" android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="—" android:textAppearance="@style/DataValue"/>
<TextView android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="Şarj gücü" android:textAppearance="@style/DataLabel"/><TextView android:id="@+id/charge_power" android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="—" android:textAppearance="@style/DataValue"/> <TextView android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="Şarj gücü" android:textAppearance="@style/DataLabel"/><TextView android:id="@+id/charge_power" android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="—" android:textAppearance="@style/DataValue"/>
<TextView android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="Alınan enerji" android:textAppearance="@style/DataLabel"/><TextView android:id="@+id/charge_energy" android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="—" android:textAppearance="@style/DataValue"/> <TextView android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="Alınan enerji" android:textAppearance="@style/DataLabel"/><TextView android:id="@+id/charge_energy" android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="—" android:textAppearance="@style/DataValue"/>
<TextView android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="Şarj süresi" android:textAppearance="@style/DataLabel"/><TextView android:id="@+id/charge_duration" android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="—" android:textAppearance="@style/DataValue"/> <TextView android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="Şarj süresi" android:textAppearance="@style/DataLabel"/><TextView android:id="@+id/charge_duration" android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="—" android:textAppearance="@style/DataValue"/>
<TextView android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="Toplam ücret" android:textAppearance="@style/DataLabel"/><TextView android:id="@+id/charge_total_cost" android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="—" android:textAppearance="@style/DataValue"/>
</GridLayout> </GridLayout>
</LinearLayout> </LinearLayout>
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="20dp" android:layout_marginTop="16dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="ŞARJ FİYATI" android:textAppearance="@style/Text.EV.Eyebrow"/>
<com.google.android.material.textfield.TextInputLayout android:id="@+id/charge_price_layout" style="@style/Widget.Material3.TextInputLayout.OutlinedBox" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:hint="Birim fiyat" app:suffixText="₺/kWh">
<com.google.android.material.textfield.TextInputEditText android:id="@+id/charge_price_input" android:layout_width="match_parent" android:layout_height="wrap_content" android:inputType="numberDecimal" android:maxLength="10" android:minHeight="72dp"/>
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton android:id="@+id/charge_price_save" style="@style/Widget.EV.Button" android:layout_width="match_parent" android:layout_height="72dp" android:layout_marginTop="8dp" android:text="Fiyatı kaydet"/>
</LinearLayout>
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="20dp" android:layout_marginTop="16dp" android:background="@drawable/bg_glass"> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="20dp" android:layout_marginTop="16dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="ŞARJ EĞRİSİ" android:textAppearance="@style/Text.EV.Eyebrow"/> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="ŞARJ EĞRİSİ" android:textAppearance="@style/Text.EV.Eyebrow"/>
<com.evsuite.abrp.ChargingGraphView android:id="@+id/charge_graph" android:layout_width="match_parent" android:layout_height="280dp" android:layout_marginTop="12dp"/> <com.evsuite.abrp.ChargingGraphView android:id="@+id/charge_graph" android:layout_width="match_parent" android:layout_height="280dp" android:layout_marginTop="12dp"/>
+9 -3
View File
@@ -1,7 +1,8 @@
# FatihsMG4 consumption API # FatihsMG4 consumption API
The Android app never connects to MongoDB directly and never contains a MongoDB URI. This 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 ## 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 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, tracking continues unchanged. The car retries locally queued charging sessions every five minutes.
containing all-time `km`, `kwh`, `hours`, and `socDrop` totals plus the manually entered `sohPercent`. 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`
+80 -50
View File
@@ -1,43 +1,47 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
import express from "express"; import express from "express";
const MAX_BODY_BYTES = "16kb"; export function createApp({ consumptionCollection, chargingCollection, apiToken }) {
if (!consumptionCollection || !chargingCollection) throw new Error("collections are required");
export function createApp({ collection, apiToken }) {
if (!collection) throw new Error("collection is required");
if (!apiToken || apiToken.length < 24) throw new Error("API_TOKEN must contain at least 24 characters"); if (!apiToken || apiToken.length < 24) throw new Error("API_TOKEN must contain at least 24 characters");
const app = express(); const app = express();
app.disable("x-powered-by"); 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.get("/health", (_request, response) => response.json({ status: "ok" }));
app.put("/v1/consumption", authenticate(apiToken), async (request, response, next) => { app.put("/v1/consumption/daily", auth, route(async (request, response) => {
try { const record = validateConsumption(request.body);
const record = validateRecord(request.body);
const updatedAt = new Date(); const updatedAt = new Date();
await collection.updateOne( await consumptionCollection.updateOne(
{ installationId: record.installationId }, { installationId: record.installationId, date: record.date },
{ { $set: { ...record, updatedAt }, $setOnInsert: { createdAt: updatedAt } }, { upsert: true });
$set: { ...record, updatedAt },
$setOnInsert: { createdAt: updatedAt }
},
{ upsert: true }
);
response.status(204).end(); response.status(204).end();
} catch (error) { }));
if (error instanceof ValidationError) {
response.status(400).json({ error: error.message }); app.put("/v1/charging-sessions/:sessionId", auth, route(async (request, response) => {
return; const record = validateChargingSession(request.body, request.params.sessionId);
} const updatedAt = new Date();
next(error); 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) => { app.use((error, _request, response, _next) => {
if (error?.type === "entity.parse.failed" || error?.type === "entity.too.large") { if (error instanceof ValidationError || error?.type === "entity.parse.failed" || error?.type === "entity.too.large") {
response.status(400).json({ error: "invalid JSON body" }); response.status(400).json({ error: error.message || "invalid request" });
return; return;
} }
console.error("Request failed", error); console.error("Request failed", error);
@@ -46,16 +50,16 @@ export function createApp({ collection, apiToken }) {
return app; return app;
} }
function route(handler) { return (request, response, next) => Promise.resolve(handler(request, response)).catch(next); }
function authenticate(expected) { function authenticate(expected) {
const expectedBytes = Buffer.from(expected); const expectedBytes = Buffer.from(expected);
return (request, response, next) => { return (request, response, next) => {
const header = request.get("authorization") ?? ""; const header = request.get("authorization") ?? "";
const supplied = header.startsWith("Bearer ") ? header.slice(7) : ""; const supplied = header.startsWith("Bearer ") ? header.slice(7) : "";
const suppliedBytes = Buffer.from(supplied); const suppliedBytes = Buffer.from(supplied);
if (suppliedBytes.length !== expectedBytes.length || if (suppliedBytes.length !== expectedBytes.length || !crypto.timingSafeEqual(suppliedBytes, expectedBytes)) {
!crypto.timingSafeEqual(suppliedBytes, expectedBytes)) { response.status(401).json({ error: "unauthorized" }); return;
response.status(401).json({ error: "unauthorized" });
return;
} }
next(); next();
}; };
@@ -63,37 +67,63 @@ function authenticate(expected) {
class ValidationError extends Error {} class ValidationError extends Error {}
function validateRecord(body) { function validateConsumption(body) {
if (!body || typeof body !== "object" || Array.isArray(body)) throw new ValidationError("body is required"); validateBody(body);
if (!/^[0-9a-f]{8}-[0-9a-f-]{27}$/i.test(body.installationId ?? "")) { if (!/^\d{4}-\d{2}-\d{2}$/.test(body.date ?? "")) throw new ValidationError("date is invalid");
throw new ValidationError("installationId is invalid");
}
if (!Number.isSafeInteger(body.recordedAt) || body.recordedAt < 0) {
throw new ValidationError("recordedAt is invalid");
}
return { return {
installationId: body.installationId, installationId: validateUuid(body.installationId, "installationId"), date: body.date,
recordedAt: new Date(body.recordedAt), recordedAt: validateDate(body.recordedAt, "recordedAt"), day: validateTotals(body.day, "day"),
lifetime: validateTotals(body.lifetime, "lifetime"), 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) { function validateChargingSession(body, pathSessionId) {
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0 || value > 100) { validateBody(body);
throw new ValidationError("sohPercent is invalid"); const sessionId = validateUuid(body.sessionId, "sessionId");
} if (sessionId !== pathSessionId) throw new ValidationError("sessionId does not match path");
return value; 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) { function validateTotals(value, name) {
if (!value || typeof value !== "object" || Array.isArray(value)) throw new ValidationError(`${name} is invalid`); if (!value || typeof value !== "object" || Array.isArray(value)) throw new ValidationError(`${name} is invalid`);
const result = {}; const result = {};
for (const field of ["km", "kwh", "hours", "socDrop"]) { for (const field of ["km", "kwh", "hours", "socDrop"]) {
const number = value[field]; 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`); throw new ValidationError(`${name}.${field} is invalid`);
}
result[field] = number; result[field] = number;
} }
return result; return result;
+8 -4
View File
@@ -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 }); const client = new MongoClient(MONGODB_URI, { serverSelectionTimeoutMS: 10_000 });
await client.connect(); await client.connect();
const collection = client.db(databaseName).collection("consumption"); const database = client.db(databaseName);
await collection.createIndex({ installationId: 1 }, { unique: true }); const consumptionCollection = database.collection("daily_consumption");
await collection.createIndex({ updatedAt: -1 }); 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}`)); const server = app.listen(port, () => console.log(`FatihsMG4 API listening on ${port}`));
async function shutdown() { async function shutdown() {
+36 -31
View File
@@ -3,50 +3,55 @@ import { after, before, test } from "node:test";
import { createApp } from "../src/app.js"; import { createApp } from "../src/app.js";
const token = "a-secure-test-token-with-24-chars"; const token = "a-secure-test-token-with-24-chars";
const writes = []; const consumptionWrites = [], chargingWrites = [];
const collection = { const readable = (writes) => ({
async updateOne(filter, update, options) { writes.push({ filter, update, options }); } async updateOne(filter, update, options) { writes.push({ filter, update, options }); },
}; find() { return { sort() { return this; }, limit() { return this; }, async toArray() { return []; } }; }
let server; });
let baseUrl; let server, baseUrl;
before(async () => { 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); }); await new Promise((resolve) => { server = app.listen(0, "127.0.0.1", resolve); });
baseUrl = `http://127.0.0.1:${server.address().port}`; baseUrl = `http://127.0.0.1:${server.address().port}`;
}); });
after(() => new Promise((resolve) => server.close(resolve))); after(() => new Promise((resolve) => server.close(resolve)));
const validBody = { const auth = { "content-type": "application/json", authorization: `Bearer ${token}` };
installationId: "123e4567-e89b-12d3-a456-426614174000", const installationId = "123e4567-e89b-42d3-a456-426614174000";
recordedAt: 1791417600000, const consumption = { installationId, recordedAt: 1791417600000, date: "2026-10-08",
lifetime: { km: 1000, kwh: 170, hours: 30, socDrop: 400 }, day: { km: 12, kwh: 2.1, hours: .4, socDrop: 4 },
sohPercent: 91.5 lifetime: { km: 1000, kwh: 170, hours: 30, socDrop: 400 }, sohPercent: 91.5 };
};
test("rejects requests without the API token", async () => { 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); assert.equal(response.status, 401);
}); });
test("validates malformed all-time totals", async () => { test("upserts one daily consumption record", async () => {
const response = await fetch(`${baseUrl}/v1/consumption`, { const response = await fetch(`${baseUrl}/v1/consumption/daily`, { method: "PUT", headers: auth, body: JSON.stringify(consumption) });
method: "PUT", assert.equal(response.status, 204);
headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, assert.deepEqual(consumptionWrites.at(-1).filter, { installationId, date: "2026-10-08" });
body: JSON.stringify({ ...validBody, lifetime: { ...validBody.lifetime, km: "12" } }) assert.equal(consumptionWrites.at(-1).update.$set.day.kwh, 2.1);
});
assert.equal(response.status, 400);
}); });
test("upserts one all-time record per installation", async () => { test("upserts a complete charging session with its curve and price", async () => {
const response = await fetch(`${baseUrl}/v1/consumption`, { const sessionId = "223e4567-e89b-42d3-a456-426614174001";
method: "PUT", const session = { installationId, sessionId, startedAt: 1791417600000, endedAt: 1791421200000,
headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, durationSeconds: 3600, energyKwh: 50, pricePerKwh: 8.5, totalCost: 425,
body: JSON.stringify(validBody) 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.equal(response.status, 204);
assert.deepEqual(writes.at(-1).filter, { installationId: validBody.installationId }); assert.deepEqual(chargingWrites.at(-1).filter, { installationId, sessionId });
assert.equal(writes.at(-1).options.upsert, true); assert.equal(chargingWrites.at(-1).update.$set.totalCost, 425);
assert.equal(writes.at(-1).update.$set.lifetime.kwh, 170); assert.equal(chargingWrites.at(-1).update.$set.curve[0].powerKw, 72);
assert.equal(writes.at(-1).update.$set.sohPercent, 91.5); });
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: [] });
}); });