Track charging sessions and daily consumption history
This commit is contained in:
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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: [] });
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user