Rename app and persist all-time consumption to MongoDB
This commit is contained in:
@@ -19,6 +19,10 @@ local.properties
|
|||||||
mise.local.toml
|
mise.local.toml
|
||||||
.mise.local.toml
|
.mise.local.toml
|
||||||
|
|
||||||
|
# Local API configuration and dependencies.
|
||||||
|
/server/.env
|
||||||
|
/server/node_modules
|
||||||
|
|
||||||
# Signed build output. Rebuilt from source by ./gradlew assembleUnstableDebug and
|
# Signed build output. Rebuilt from source by ./gradlew assembleUnstableDebug and
|
||||||
# signed locally — a checked-in APK is stale the moment anything above it changes.
|
# signed locally — a checked-in APK is stale the moment anything above it changes.
|
||||||
*.apk
|
*.apk
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ vehicle's shared UID; do not use that preview APK for the MG4 upgrade.
|
|||||||
|
|
||||||
## What the updater actually looks for
|
## What the updater actually looks for
|
||||||
|
|
||||||
`OtaUpdater.check()` reads `https://api.github.com/repos/fatihdonmezdev/MG4ABRP/releases`
|
`OtaUpdater.check()` reads `https://api.github.com/repos/fatihdonmezdev/FatihsMG4/releases`
|
||||||
and walks **every** entry, keeping the highest version rather than the first match. A
|
and walks **every** entry, keeping the highest version rather than the first match. A
|
||||||
release is a candidate only if all of these hold:
|
release is a candidate only if all of these hold:
|
||||||
|
|
||||||
@@ -99,7 +99,7 @@ The version comes from the **asset filename**, not the git tag. The tag can stay
|
|||||||
"$BT/apksigner" verify --print-certs FatihsMG4-unstable-2.2.7.apk
|
"$BT/apksigner" verify --print-certs FatihsMG4-unstable-2.2.7.apk
|
||||||
```
|
```
|
||||||
|
|
||||||
5. **Publish.** On github.com/fatihdonmezdev/MG4ABRP → Releases → Draft a new release:
|
5. **Publish.** On github.com/fatihdonmezdev/FatihsMG4 → Releases → Draft a new release:
|
||||||
- tag: `unstable` (reuse it; the rolling tag is the design)
|
- tag: `unstable` (reuse it; the rolling tag is the design)
|
||||||
- **tick "Set as a pre-release"** ← the whole thing is inert without this
|
- **tick "Set as a pre-release"** ← the whole thing is inert without this
|
||||||
- attach the signed APK and its `.sha256` sidecar
|
- attach the signed APK and its `.sha256` sidecar
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
# EVABRPUploader
|
# FatihsMG4
|
||||||
|
|
||||||
<p align="center"><img src="docs/logo.svg" width="440" alt="EVABRPUploader"></p>
|
<p align="center"><img src="docs/logo.svg" width="440" alt="FatihsMG4"></p>
|
||||||
|
|
||||||
[](https://github.com/malys/EVABRPUploader/actions/workflows/tests.yml)
|
[](https://github.com/fatihdonmezdev/FatihsMG4/releases)
|
||||||
[](https://github.com/malys/EVABRPUploader/actions/workflows/security.yml)
|
|
||||||
[](https://github.com/malys/EVABRPUploader/actions/workflows/unstable.yml)
|
|
||||||
[](https://github.com/malys/EVABRPUploader/releases)
|
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
|
|
||||||
Sends live telemetry from an **MG4 (SAIC eh32)** to
|
Sends live telemetry from an **MG4 (SAIC eh32)** to
|
||||||
@@ -112,7 +109,7 @@ Two channels. Pick one — they install side by side.
|
|||||||
| **Stable** | No. Contains no updater at all. | You want the car to run what you put on it |
|
| **Stable** | No. Contains no updater at all. | You want the car to run what you put on it |
|
||||||
| **Unstable** | Yes, from GitHub pre-releases | You are testing and want fixes as they land |
|
| **Unstable** | Yes, from GitHub pre-releases | You are testing and want fixes as they land |
|
||||||
|
|
||||||
Grab the APK from [Releases](https://github.com/malys/EVABRPUploader/releases). Stable builds are the tagged ones; unstable builds
|
Grab the APK from [Releases](https://github.com/fatihdonmezdev/FatihsMG4/releases). Stable builds are the tagged ones; unstable builds
|
||||||
are marked pre-release.
|
are marked pre-release.
|
||||||
|
|
||||||
### Getting the APK onto the car
|
### Getting the APK onto the car
|
||||||
|
|||||||
+8
-3
@@ -37,6 +37,11 @@ android {
|
|||||||
versionCode 51
|
versionCode 51
|
||||||
versionName "2.2.34"
|
versionName "2.2.34"
|
||||||
|
|
||||||
|
// MongoDB credentials never ship in the APK. The app only talks to the small
|
||||||
|
// authenticated API in server/, which owns the database connection.
|
||||||
|
buildConfigField "String", "CONSUMPTION_API_URL", "\"${System.getenv('CONSUMPTION_API_URL') ?: ''}\""
|
||||||
|
buildConfigField "String", "CONSUMPTION_API_TOKEN", "\"${System.getenv('CONSUMPTION_API_TOKEN') ?: ''}\""
|
||||||
|
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,7 +109,7 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Version publiée par la CI pour composer le nom de l'asset APK : ce nom DOIT être
|
// Version publiée par la CI pour composer le nom de l'asset APK : ce nom DOIT être
|
||||||
// comparable par l'updater ("EVABRPUploader-unstable-1.0.42.apk"). Le tag de la
|
// comparable par l'updater ("FatihsMG4-unstable-1.0.42.apk"). Le tag de la
|
||||||
// pré-release est la constante "unstable", écrasée à chaque build.
|
// pré-release est la constante "unstable", écrasée à chaque build.
|
||||||
tasks.register('printUnstableVersion') {
|
tasks.register('printUnstableVersion') {
|
||||||
doLast {
|
doLast {
|
||||||
@@ -120,8 +125,8 @@ androidComponents {
|
|||||||
// Le canal unstable garde un nom de fichier stable : l'updater identifie
|
// Le canal unstable garde un nom de fichier stable : l'updater identifie
|
||||||
// la version par le tag de la pré-release, jamais par le nom d'APK.
|
// la version par le tag de la pré-release, jamais par le nom d'APK.
|
||||||
output.outputFileName.set(variant.flavorName == "unstable"
|
output.outputFileName.set(variant.flavorName == "unstable"
|
||||||
? "EVABRPUploader-unstable.apk"
|
? "FatihsMG4-unstable.apk"
|
||||||
: "EVABRPUploader-${variant.flavorName}-${output.versionName.get()}.apk")
|
: "FatihsMG4-${variant.flavorName}-${output.versionName.get()}.apk")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ public class AbrpUploadService extends Service {
|
|||||||
|
|
||||||
private EnergyTelemetryReader energyReader;
|
private EnergyTelemetryReader energyReader;
|
||||||
private ConsumptionTracker consumptionTracker;
|
private ConsumptionTracker consumptionTracker;
|
||||||
|
private ConsumptionCloudClient consumptionCloudClient;
|
||||||
/**
|
/**
|
||||||
* 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
|
||||||
@@ -196,6 +197,7 @@ public class AbrpUploadService extends Service {
|
|||||||
// One shared, firmware-aware EVHardware snapshot owns every vehicle signal.
|
// One shared, firmware-aware EVHardware snapshot owns every vehicle signal.
|
||||||
energyReader = new EnergyTelemetryReader(getApplicationContext());
|
energyReader = new EnergyTelemetryReader(getApplicationContext());
|
||||||
consumptionTracker = ConsumptionTracker.get(this);
|
consumptionTracker = ConsumptionTracker.get(this);
|
||||||
|
consumptionCloudClient = new ConsumptionCloudClient(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
|
||||||
@@ -324,6 +326,7 @@ 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);
|
||||||
|
|
||||||
// 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,82 @@
|
|||||||
|
package com.evsuite.abrp;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.SharedPreferences;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
import org.json.JSONObject;
|
||||||
|
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.net.HttpURLConnection;
|
||||||
|
import java.net.URL;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/** Best-effort MongoDB sync through the API in server/. Never blocks local persistence. */
|
||||||
|
final class ConsumptionCloudClient {
|
||||||
|
private static final String TAG = "FatihsMG4.Mongo";
|
||||||
|
private static final long SYNC_INTERVAL_MS = 5 * 60_000L;
|
||||||
|
private static final int TIMEOUT_MS = 8_000;
|
||||||
|
private static final String PREFS = "consumption_cloud";
|
||||||
|
private static final String INSTALLATION_ID = "installation_id";
|
||||||
|
|
||||||
|
private final String endpoint;
|
||||||
|
private final String token;
|
||||||
|
private final String installationId;
|
||||||
|
private long lastAttemptMs;
|
||||||
|
|
||||||
|
ConsumptionCloudClient(Context context) {
|
||||||
|
endpoint = BuildConfig.CONSUMPTION_API_URL.trim();
|
||||||
|
token = BuildConfig.CONSUMPTION_API_TOKEN.trim();
|
||||||
|
SharedPreferences prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
|
||||||
|
String existing = prefs.getString(INSTALLATION_ID, "");
|
||||||
|
if (existing == null || existing.isEmpty()) {
|
||||||
|
existing = UUID.randomUUID().toString();
|
||||||
|
prefs.edit().putString(INSTALLATION_ID, existing).commit();
|
||||||
|
}
|
||||||
|
installationId = existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
void syncIfDue(ConsumptionTracker.CloudSnapshot snapshot, long nowMs) {
|
||||||
|
if (endpoint.isEmpty() || token.isEmpty() || nowMs - lastAttemptMs < SYNC_INTERVAL_MS) return;
|
||||||
|
lastAttemptMs = nowMs;
|
||||||
|
HttpURLConnection connection = null;
|
||||||
|
try {
|
||||||
|
JSONObject body = new JSONObject()
|
||||||
|
.put("installationId", installationId)
|
||||||
|
.put("recordedAt", nowMs)
|
||||||
|
.put("lifetime", totals(snapshot.lifetime));
|
||||||
|
if (snapshot.sohPercent != null) body.put("sohPercent", snapshot.sohPercent);
|
||||||
|
byte[] bytes = body.toString().getBytes(StandardCharsets.UTF_8);
|
||||||
|
connection = (HttpURLConnection) new URL(trimSlash(endpoint) + "/v1/consumption").openConnection();
|
||||||
|
connection.setRequestMethod("PUT");
|
||||||
|
connection.setConnectTimeout(TIMEOUT_MS);
|
||||||
|
connection.setReadTimeout(TIMEOUT_MS);
|
||||||
|
connection.setDoOutput(true);
|
||||||
|
connection.setFixedLengthStreamingMode(bytes.length);
|
||||||
|
connection.setRequestProperty("Content-Type", "application/json");
|
||||||
|
connection.setRequestProperty("Authorization", "Bearer " + token);
|
||||||
|
try (OutputStream out = connection.getOutputStream()) { out.write(bytes); }
|
||||||
|
int status = connection.getResponseCode();
|
||||||
|
try (InputStream ignored = status < 400
|
||||||
|
? connection.getInputStream() : connection.getErrorStream()) { }
|
||||||
|
if (status < 200 || status >= 300) Log.w(TAG, "Sync rejected with HTTP " + status);
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.w(TAG, "Consumption sync unavailable");
|
||||||
|
} finally {
|
||||||
|
if (connection != null) connection.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JSONObject totals(ConsumptionTracker.Totals totals) throws Exception {
|
||||||
|
return new JSONObject().put("km", totals.km).put("kwh", totals.kwh)
|
||||||
|
.put("hours", totals.hours).put("socDrop", totals.soc);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String trimSlash(String value) {
|
||||||
|
int end = value.length();
|
||||||
|
while (end > 0 && value.charAt(end - 1) == '/') end--;
|
||||||
|
return value.substring(0, end);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,7 @@ final class ConsumptionTracker {
|
|||||||
*/
|
*/
|
||||||
private static final String PREFS = "consumption_counters_v2";
|
private static final String PREFS = "consumption_counters_v2";
|
||||||
private static final String LEGACY_PREFS = "consumption_counters_v1";
|
private static final String LEGACY_PREFS = "consumption_counters_v1";
|
||||||
|
private static final String SOH_PERCENT = "manual_soh_percent";
|
||||||
|
|
||||||
/** How often the in-memory accumulators reach disk. Matches DriveHub_Dort. */
|
/** How often the in-memory accumulators reach disk. Matches DriveHub_Dort. */
|
||||||
private static final long PERSIST_INTERVAL_MS = 30_000L;
|
private static final long PERSIST_INTERVAL_MS = 30_000L;
|
||||||
@@ -138,6 +139,29 @@ final class ConsumptionTracker {
|
|||||||
return loadTotals(dayKey(date));
|
return loadTotals(dayKey(date));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
synchronized CloudSnapshot cloudSnapshot() {
|
||||||
|
return new CloudSnapshot(lifetime.snapshot(), sohPercent());
|
||||||
|
}
|
||||||
|
|
||||||
|
static final class CloudSnapshot {
|
||||||
|
final Totals lifetime;
|
||||||
|
final Float sohPercent;
|
||||||
|
|
||||||
|
CloudSnapshot(Totals lifetime, Float sohPercent) {
|
||||||
|
this.lifetime = lifetime;
|
||||||
|
this.sohPercent = sohPercent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
synchronized Float sohPercent() {
|
||||||
|
return prefs.contains(SOH_PERCENT) ? prefs.getFloat(SOH_PERCENT, 0f) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
synchronized boolean setSohPercent(float value) {
|
||||||
|
if (!Float.isFinite(value) || value <= 0f || value > 100f) return false;
|
||||||
|
return prefs.edit().putFloat(SOH_PERCENT, value).commit();
|
||||||
|
}
|
||||||
|
|
||||||
/** Flushes the day that just ended and opens the new one. */
|
/** Flushes the day that just ended and opens the new one. */
|
||||||
private void rollDayIfNeeded() {
|
private void rollDayIfNeeded() {
|
||||||
LocalDate date = LocalDate.now();
|
LocalDate date = LocalDate.now();
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import android.view.View;
|
|||||||
import android.widget.AdapterView;
|
import android.widget.AdapterView;
|
||||||
import android.widget.ArrayAdapter;
|
import android.widget.ArrayAdapter;
|
||||||
import android.widget.Button;
|
import android.widget.Button;
|
||||||
|
import android.widget.Toast;
|
||||||
import android.widget.ImageView;
|
import android.widget.ImageView;
|
||||||
import android.widget.Spinner;
|
import android.widget.Spinner;
|
||||||
import android.widget.TextView;
|
import android.widget.TextView;
|
||||||
@@ -42,7 +43,7 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
private static final int LOCATION_PERMISSION_REQUEST = 100;
|
private static final int LOCATION_PERMISSION_REQUEST = 100;
|
||||||
/** Shared with {@link AbrpUploadService}, which reads it on every tick. */
|
/** Shared with {@link AbrpUploadService}, which reads it on every tick. */
|
||||||
static final String WIFI_AUTO_KEY = "wifi_auto_connect";
|
static final String WIFI_AUTO_KEY = "wifi_auto_connect";
|
||||||
private static final String REPOSITORY_URL = "https://github.com/fatihdonmezdev/MG4ABRP";
|
private static final String REPOSITORY_URL = "https://github.com/fatihdonmezdev/FatihsMG4";
|
||||||
|
|
||||||
/** Navigation destinations in page order. Parallel to {@link #panes}. */
|
/** Navigation destinations in page order. Parallel to {@link #panes}. */
|
||||||
private static final int[] TAB_IDS =
|
private static final int[] TAB_IDS =
|
||||||
@@ -114,6 +115,8 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
consumptionSpeed, consumptionTime, consumptionSoc, consumptionDailyHistory;
|
consumptionSpeed, consumptionTime, consumptionSoc, consumptionDailyHistory;
|
||||||
private Button consumptionReset;
|
private Button consumptionReset;
|
||||||
private MaterialButton consumptionDateButton;
|
private MaterialButton consumptionDateButton;
|
||||||
|
private TextInputLayout consumptionSohLayout;
|
||||||
|
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, chargeVoltage, chargeCurrent, chargePower,
|
||||||
chargeEnergy, chargeDuration, chargeCurveTable;
|
chargeEnergy, chargeDuration, chargeCurveTable;
|
||||||
@@ -861,6 +864,12 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
consumptionDateButton.setText(selectedConsumptionDate.toString());
|
consumptionDateButton.setText(selectedConsumptionDate.toString());
|
||||||
consumptionDateButton.setOnClickListener(v -> showConsumptionDatePicker());
|
consumptionDateButton.setOnClickListener(v -> showConsumptionDatePicker());
|
||||||
consumptionReset = consumptionPane.findViewById(R.id.consumption_reset);
|
consumptionReset = consumptionPane.findViewById(R.id.consumption_reset);
|
||||||
|
consumptionSohLayout = consumptionPane.findViewById(R.id.consumption_soh_layout);
|
||||||
|
consumptionSohInput = consumptionPane.findViewById(R.id.consumption_soh_input);
|
||||||
|
Float savedSoh = consumptionTracker.sohPercent();
|
||||||
|
if (savedSoh != null) consumptionSohInput.setText(String.format(
|
||||||
|
java.util.Locale.US, "%.1f", savedSoh));
|
||||||
|
consumptionPane.findViewById(R.id.consumption_soh_save).setOnClickListener(v -> saveSoh());
|
||||||
bindPeriodButton(R.id.consumption_lifetime, ConsumptionTracker.Period.LIFETIME);
|
bindPeriodButton(R.id.consumption_lifetime, ConsumptionTracker.Period.LIFETIME);
|
||||||
bindPeriodButton(R.id.consumption_week, ConsumptionTracker.Period.WEEK);
|
bindPeriodButton(R.id.consumption_week, ConsumptionTracker.Period.WEEK);
|
||||||
bindPeriodButton(R.id.consumption_month, ConsumptionTracker.Period.MONTH);
|
bindPeriodButton(R.id.consumption_month, ConsumptionTracker.Period.MONTH);
|
||||||
@@ -880,6 +889,19 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void saveSoh() {
|
||||||
|
String raw = textOf(consumptionSohInput).replace(',', '.');
|
||||||
|
try {
|
||||||
|
float value = Float.parseFloat(raw);
|
||||||
|
if (!consumptionTracker.setSohPercent(value)) throw new NumberFormatException();
|
||||||
|
consumptionSohLayout.setError(null);
|
||||||
|
consumptionSohInput.setText(String.format(java.util.Locale.US, "%.1f", value));
|
||||||
|
Toast.makeText(this, "SoH kaydedildi", Toast.LENGTH_SHORT).show();
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
consumptionSohLayout.setError("1 ile 100 arasında bir değer girin");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void bindPeriodButton(int id, ConsumptionTracker.Period period) {
|
private void bindPeriodButton(int id, ConsumptionTracker.Period period) {
|
||||||
View button = consumptionPane.findViewById(id);
|
View button = consumptionPane.findViewById(id);
|
||||||
button.setOnClickListener(v -> {
|
button.setOnClickListener(v -> {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<?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:layout_width="match_parent" android:layout_height="wrap_content" android:text="TÜKETİM DÖNEMİ" android:textAppearance="@style/Text.EV.Eyebrow"/>
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="TÜKETİM DÖNEMİ" android:textAppearance="@style/Text.EV.Eyebrow"/>
|
||||||
@@ -23,6 +23,14 @@
|
|||||||
<com.google.android.material.button.MaterialButton android:id="@+id/consumption_reset" style="@style/Widget.EV.Button.Outlined" android:layout_width="match_parent" android:layout_height="64dp" android:layout_marginTop="12dp" android:text="Seçili tripi sıfırla" android:visibility="gone"/>
|
<com.google.android.material.button.MaterialButton android:id="@+id/consumption_reset" style="@style/Widget.EV.Button.Outlined" android:layout_width="match_parent" android:layout_height="64dp" android:layout_marginTop="12dp" android:text="Seçili tripi sıfırla" android:visibility="gone"/>
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:text="Son 7/30 gün, tüm zamanlar ve Trip A/B cihazda kalıcı saklanır; uygulama ve OTA güncellemeleriyle silinmez." android:textAppearance="@style/Text.EV.Secondary"/>
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:text="Son 7/30 gün, tüm zamanlar ve Trip A/B cihazda kalıcı saklanır; uygulama ve OTA güncellemeleriyle silinmez." android:textAppearance="@style/Text.EV.Secondary"/>
|
||||||
|
<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="BATARYA SAĞLIĞI (SOH)" android:textAppearance="@style/Text.EV.Eyebrow"/>
|
||||||
|
<com.google.android.material.textfield.TextInputLayout android:id="@+id/consumption_soh_layout" style="@style/Widget.Material3.TextInputLayout.OutlinedBox" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:hint="Manuel SoH (%)" app:suffixText="%">
|
||||||
|
<com.google.android.material.textfield.TextInputEditText android:id="@+id/consumption_soh_input" android:layout_width="match_parent" android:layout_height="wrap_content" android:inputType="numberDecimal" android:maxLength="5" android:minHeight="72dp"/>
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
<com.google.android.material.button.MaterialButton android:id="@+id/consumption_soh_save" style="@style/Widget.EV.Button" android:layout_width="match_parent" android:layout_height="72dp" android:layout_marginTop="8dp" android:text="SoH değerini kaydet"/>
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:text="Bu manuel değer cihazda saklanır ve MongoDB'deki tüm zamanlar kaydına eklenir." android:textAppearance="@style/Text.EV.Secondary"/>
|
||||||
|
</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="GÜNLÜK KAYIT · SON 4 AY" android:textAppearance="@style/Text.EV.Eyebrow"/>
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="GÜNLÜK KAYIT · SON 4 AY" android:textAppearance="@style/Text.EV.Eyebrow"/>
|
||||||
<com.google.android.material.button.MaterialButton android:id="@+id/consumption_date_button" style="@style/Widget.EV.Button.Outlined" android:layout_width="match_parent" android:layout_height="56dp" android:layout_marginTop="12dp" android:text="Tarih seç"/>
|
<com.google.android.material.button.MaterialButton android:id="@+id/consumption_date_button" style="@style/Widget.EV.Button.Outlined" android:layout_width="match_parent" android:layout_height="56dp" android:layout_marginTop="12dp" android:text="Tarih seç"/>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">Fatih\'sMG4</string>
|
<string name="app_name">FatihsMG4</string>
|
||||||
|
|
||||||
<!-- Main screen -->
|
<!-- Main screen -->
|
||||||
<string name="tab_abrp">ABRP</string>
|
<string name="tab_abrp">ABRP</string>
|
||||||
@@ -128,9 +128,9 @@
|
|||||||
<string name="about_title">Fatih\'sMG4 Hakkında</string>
|
<string name="about_title">Fatih\'sMG4 Hakkında</string>
|
||||||
<string name="about_version">Version %1$s</string>
|
<string name="about_version">Version %1$s</string>
|
||||||
<string name="about_attribution">Fork of malys / EVABRPUploader\nand Leon Kernan / ABRP_Uploader</string>
|
<string name="about_attribution">Fork of malys / EVABRPUploader\nand Leon Kernan / ABRP_Uploader</string>
|
||||||
<string name="about_repository">github.com/fatihdonmezdev/MG4ABRP</string>
|
<string name="about_repository">github.com/fatihdonmezdev/FatihsMG4</string>
|
||||||
<string name="about_made_with_love">MG4 EH32 · SWI69</string>
|
<string name="about_made_with_love">MG4 EH32 · SWI69</string>
|
||||||
<string name="about_logo_description">EVABRPUploader logo</string>
|
<string name="about_logo_description">FatihsMG4 logo</string>
|
||||||
<string name="about_qr_description">QR code for the project repository</string>
|
<string name="about_qr_description">QR code for the project repository</string>
|
||||||
<string name="about_version_unknown">unknown</string>
|
<string name="about_version_unknown">unknown</string>
|
||||||
<string name="about_close">Close</string>
|
<string name="about_close">Close</string>
|
||||||
|
|||||||
@@ -102,13 +102,13 @@ public class OtaUpdaterTest {
|
|||||||
@Test
|
@Test
|
||||||
public void versionIsReadFromTheAssetName() {
|
public void versionIsReadFromTheAssetName() {
|
||||||
// The release tag is the constant "unstable", so the asset name carries the build.
|
// The release tag is the constant "unstable", so the asset name carries the build.
|
||||||
assertEquals("1.0.42", OtaUpdater.versionFromAssetName("EVABRPUploader-unstable-1.0.42.apk"));
|
assertEquals("1.0.42", OtaUpdater.versionFromAssetName("FatihsMG4-unstable-1.0.42.apk"));
|
||||||
assertEquals("1.0.100", OtaUpdater.versionFromAssetName("EVABRPUploader-unstable-1.0.100.APK"));
|
assertEquals("1.0.100", OtaUpdater.versionFromAssetName("FatihsMG4-unstable-1.0.100.APK"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void assetNameWithoutAVersionIsIgnored() {
|
public void assetNameWithoutAVersionIsIgnored() {
|
||||||
assertNull(OtaUpdater.versionFromAssetName("EVABRPUploader-unstable.apk"));
|
assertNull(OtaUpdater.versionFromAssetName("FatihsMG4-unstable.apk"));
|
||||||
assertNull(OtaUpdater.versionFromAssetName("unstable"));
|
assertNull(OtaUpdater.versionFromAssetName("unstable"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,7 +218,7 @@ public class OtaUpdaterTest {
|
|||||||
private static JSONObject asset(String version) throws Exception {
|
private static JSONObject asset(String version) throws Exception {
|
||||||
String name = "FatihsMG4-unstable-" + version + ".apk";
|
String name = "FatihsMG4-unstable-" + version + ".apk";
|
||||||
return new JSONObject().put("name", name).put("browser_download_url",
|
return new JSONObject().put("name", name).put("browser_download_url",
|
||||||
"https://github.com/fatihdonmezdev/MG4ABRP/releases/download/unstable/" + name);
|
"https://github.com/fatihdonmezdev/FatihsMG4/releases/download/unstable/" + name);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static JSONObject release(boolean prerelease, JSONObject... assets) throws Exception {
|
private static JSONObject release(boolean prerelease, JSONObject... assets) throws Exception {
|
||||||
@@ -249,7 +249,7 @@ public class OtaUpdaterTest {
|
|||||||
boolean disconnected;
|
boolean disconnected;
|
||||||
|
|
||||||
Response(int status, String body) throws Exception {
|
Response(int status, String body) throws Exception {
|
||||||
super(new URL("https://api.github.com/repos/fatihdonmezdev/MG4ABRP/releases"));
|
super(new URL("https://api.github.com/repos/fatihdonmezdev/FatihsMG4/releases"));
|
||||||
this.status = status;
|
this.status = status;
|
||||||
this.body = body;
|
this.body = body;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ import java.security.MessageDigest;
|
|||||||
final class OtaUpdater {
|
final class OtaUpdater {
|
||||||
|
|
||||||
private static final String TAG = "OtaUpdater";
|
private static final String TAG = "OtaUpdater";
|
||||||
private static final String CACHE_PREFIX = "EVABRPUploader-ota-";
|
private static final String CACHE_PREFIX = "FatihsMG4-ota-";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pre-releases live here; the unstable channel tracks them.
|
* Pre-releases live here; the unstable channel tracks them.
|
||||||
@@ -64,7 +64,7 @@ final class OtaUpdater {
|
|||||||
* nothing until this repository publishes a release, which is the intended quiet.
|
* nothing until this repository publishes a release, which is the intended quiet.
|
||||||
*/
|
*/
|
||||||
private static final String RELEASES_API =
|
private static final String RELEASES_API =
|
||||||
"https://api.github.com/repos/fatihdonmezdev/MG4ABRP/releases";
|
"https://api.github.com/repos/fatihdonmezdev/FatihsMG4/releases";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hosts an update may come from. The githubusercontent entries are the CDNs GitHub
|
* Hosts an update may come from. The githubusercontent entries are the CDNs GitHub
|
||||||
@@ -162,7 +162,7 @@ final class OtaUpdater {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Version carried by an unstable asset name:
|
* Version carried by an unstable asset name:
|
||||||
* "EVABRPUploader-unstable-1.0.42.apk" -> "1.0.42".
|
* "FatihsMG4-unstable-1.0.42.apk" -> "1.0.42".
|
||||||
*
|
*
|
||||||
* The release tag is the fixed string "unstable" (one rolling pre-release), so the asset
|
* The release tag is the fixed string "unstable" (one rolling pre-release), so the asset
|
||||||
* name is what identifies a build. Returns null when the name carries no version.
|
* name is what identifies a build. Returns null when the name carries no version.
|
||||||
@@ -198,7 +198,7 @@ final class OtaUpdater {
|
|||||||
try {
|
try {
|
||||||
conn.setInstanceFollowRedirects(false);
|
conn.setInstanceFollowRedirects(false);
|
||||||
conn.setRequestProperty("Accept", "application/vnd.github.v3+json");
|
conn.setRequestProperty("Accept", "application/vnd.github.v3+json");
|
||||||
conn.setRequestProperty("User-Agent", "EVABRPUploader-Android");
|
conn.setRequestProperty("User-Agent", "FatihsMG4-Android");
|
||||||
conn.setConnectTimeout(TIMEOUT_MS);
|
conn.setConnectTimeout(TIMEOUT_MS);
|
||||||
conn.setReadTimeout(TIMEOUT_MS);
|
conn.setReadTimeout(TIMEOUT_MS);
|
||||||
int status = conn.getResponseCode();
|
int status = conn.getResponseCode();
|
||||||
@@ -297,7 +297,7 @@ final class OtaUpdater {
|
|||||||
connection.setInstanceFollowRedirects(false);
|
connection.setInstanceFollowRedirects(false);
|
||||||
connection.setConnectTimeout(TIMEOUT_MS);
|
connection.setConnectTimeout(TIMEOUT_MS);
|
||||||
connection.setReadTimeout(TIMEOUT_MS);
|
connection.setReadTimeout(TIMEOUT_MS);
|
||||||
connection.setRequestProperty("User-Agent", "EVABRPUploader-Android");
|
connection.setRequestProperty("User-Agent", "FatihsMG4-Android");
|
||||||
try {
|
try {
|
||||||
int status = connection.getResponseCode();
|
int status = connection.getResponseCode();
|
||||||
if (status >= 300 && status <= 399) {
|
if (status >= 300 && status <= 399) {
|
||||||
@@ -358,7 +358,7 @@ final class OtaUpdater {
|
|||||||
long downloadId = -1L;
|
long downloadId = -1L;
|
||||||
try {
|
try {
|
||||||
DownloadManager.Request request = new DownloadManager.Request(Uri.parse(update.apkUrl))
|
DownloadManager.Request request = new DownloadManager.Request(Uri.parse(update.apkUrl))
|
||||||
.setTitle("EVABRPUploader " + update.versionName)
|
.setTitle("FatihsMG4 " + update.versionName)
|
||||||
.setDescription("Güncelleme indiriliyor")
|
.setDescription("Güncelleme indiriliyor")
|
||||||
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
|
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
|
||||||
.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, publicName)
|
.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, publicName)
|
||||||
@@ -564,7 +564,7 @@ final class OtaUpdater {
|
|||||||
Intent install = new Intent(Intent.ACTION_VIEW)
|
Intent install = new Intent(Intent.ACTION_VIEW)
|
||||||
.setDataAndType(contentUri, "application/vnd.android.package-archive")
|
.setDataAndType(contentUri, "application/vnd.android.package-archive")
|
||||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||||
install.setClipData(ClipData.newRawUri("EVABRPUploader update", contentUri));
|
install.setClipData(ClipData.newRawUri("FatihsMG4 update", contentUri));
|
||||||
grantToInstallers(context, contentUri);
|
grantToInstallers(context, contentUri);
|
||||||
if (install.resolveActivity(context.getPackageManager()) == null) {
|
if (install.resolveActivity(context.getPackageManager()) == null) {
|
||||||
throw new IllegalStateException("No package installer activity");
|
throw new IllegalStateException("No package installer activity");
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
MONGODB_URI=mongodb+srv://username:password@cluster.example.mongodb.net/
|
||||||
|
MONGODB_DATABASE=fatihsmg4
|
||||||
|
API_TOKEN=replace-with-a-long-random-secret
|
||||||
|
PORT=8080
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
.env
|
||||||
|
npm-debug.log*
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
FROM node:22-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci --omit=dev
|
||||||
|
COPY src ./src
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
USER node
|
||||||
|
EXPOSE 8080
|
||||||
|
CMD ["node", "src/server.js"]
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# FatihsMG4 consumption API
|
||||||
|
|
||||||
|
The Android app never connects to MongoDB directly and never contains a MongoDB URI. This
|
||||||
|
service accepts authenticated all-time totals and upserts them into the `consumption` collection.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
1. Copy `.env.example` to `.env` and fill in the MongoDB Atlas URI and a random API token.
|
||||||
|
2. Export the variables (Node does not load `.env` automatically), then run `npm install` and
|
||||||
|
`npm start`.
|
||||||
|
3. Build the Android app with the public HTTPS service address and the same token:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
CONSUMPTION_API_URL=https://your-api.example.com \
|
||||||
|
CONSUMPTION_API_TOKEN=your-random-token \
|
||||||
|
./gradlew assembleUnstableRelease
|
||||||
|
```
|
||||||
|
|
||||||
|
If either Android build variable is missing, cloud sync is disabled and local consumption
|
||||||
|
tracking continues unchanged. MongoDB receives one continuously updated document per installation,
|
||||||
|
containing all-time `km`, `kwh`, `hours`, and `socDrop` totals plus the manually entered `sohPercent`.
|
||||||
Generated
+1040
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "fatihsmg4-consumption-api",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node src/server.js",
|
||||||
|
"test": "node --test"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^5.1.0",
|
||||||
|
"mongodb": "^6.20.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import express from "express";
|
||||||
|
|
||||||
|
const MAX_BODY_BYTES = "16kb";
|
||||||
|
|
||||||
|
export function createApp({ collection, apiToken }) {
|
||||||
|
if (!collection) throw new Error("collection is required");
|
||||||
|
if (!apiToken || apiToken.length < 24) throw new Error("API_TOKEN must contain at least 24 characters");
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.disable("x-powered-by");
|
||||||
|
app.use(express.json({ limit: MAX_BODY_BYTES, strict: true }));
|
||||||
|
|
||||||
|
app.get("/health", (_request, response) => response.json({ status: "ok" }));
|
||||||
|
|
||||||
|
app.put("/v1/consumption", authenticate(apiToken), async (request, response, next) => {
|
||||||
|
try {
|
||||||
|
const record = validateRecord(request.body);
|
||||||
|
const updatedAt = new Date();
|
||||||
|
await collection.updateOne(
|
||||||
|
{ installationId: record.installationId },
|
||||||
|
{
|
||||||
|
$set: { ...record, updatedAt },
|
||||||
|
$setOnInsert: { createdAt: updatedAt }
|
||||||
|
},
|
||||||
|
{ upsert: true }
|
||||||
|
);
|
||||||
|
response.status(204).end();
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ValidationError) {
|
||||||
|
response.status(400).json({ error: error.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use((error, _request, response, _next) => {
|
||||||
|
if (error?.type === "entity.parse.failed" || error?.type === "entity.too.large") {
|
||||||
|
response.status(400).json({ error: "invalid JSON body" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error("Request failed", error);
|
||||||
|
response.status(500).json({ error: "internal server error" });
|
||||||
|
});
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
function authenticate(expected) {
|
||||||
|
const expectedBytes = Buffer.from(expected);
|
||||||
|
return (request, response, next) => {
|
||||||
|
const header = request.get("authorization") ?? "";
|
||||||
|
const supplied = header.startsWith("Bearer ") ? header.slice(7) : "";
|
||||||
|
const suppliedBytes = Buffer.from(supplied);
|
||||||
|
if (suppliedBytes.length !== expectedBytes.length ||
|
||||||
|
!crypto.timingSafeEqual(suppliedBytes, expectedBytes)) {
|
||||||
|
response.status(401).json({ error: "unauthorized" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class ValidationError extends Error {}
|
||||||
|
|
||||||
|
function validateRecord(body) {
|
||||||
|
if (!body || typeof body !== "object" || Array.isArray(body)) throw new ValidationError("body is required");
|
||||||
|
if (!/^[0-9a-f]{8}-[0-9a-f-]{27}$/i.test(body.installationId ?? "")) {
|
||||||
|
throw new ValidationError("installationId is invalid");
|
||||||
|
}
|
||||||
|
if (!Number.isSafeInteger(body.recordedAt) || body.recordedAt < 0) {
|
||||||
|
throw new ValidationError("recordedAt is invalid");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
installationId: body.installationId,
|
||||||
|
recordedAt: new Date(body.recordedAt),
|
||||||
|
lifetime: validateTotals(body.lifetime, "lifetime"),
|
||||||
|
...(body.sohPercent === undefined ? {} : { sohPercent: validateSoh(body.sohPercent) })
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateSoh(value) {
|
||||||
|
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0 || value > 100) {
|
||||||
|
throw new ValidationError("sohPercent is invalid");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateTotals(value, name) {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw new ValidationError(`${name} is invalid`);
|
||||||
|
const result = {};
|
||||||
|
for (const field of ["km", "kwh", "hours", "socDrop"]) {
|
||||||
|
const number = value[field];
|
||||||
|
if (typeof number !== "number" || !Number.isFinite(number) || Math.abs(number) > 10_000_000) {
|
||||||
|
throw new ValidationError(`${name}.${field} is invalid`);
|
||||||
|
}
|
||||||
|
result[field] = number;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { MongoClient } from "mongodb";
|
||||||
|
import { createApp } from "./app.js";
|
||||||
|
|
||||||
|
const { MONGODB_URI, API_TOKEN } = process.env;
|
||||||
|
const databaseName = process.env.MONGODB_DATABASE || "fatihsmg4";
|
||||||
|
const port = Number(process.env.PORT || 8080);
|
||||||
|
|
||||||
|
if (!MONGODB_URI) throw new Error("MONGODB_URI is required");
|
||||||
|
if (!API_TOKEN) throw new Error("API_TOKEN is required");
|
||||||
|
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("PORT is invalid");
|
||||||
|
|
||||||
|
const client = new MongoClient(MONGODB_URI, { serverSelectionTimeoutMS: 10_000 });
|
||||||
|
await client.connect();
|
||||||
|
const collection = client.db(databaseName).collection("consumption");
|
||||||
|
await collection.createIndex({ installationId: 1 }, { unique: true });
|
||||||
|
await collection.createIndex({ updatedAt: -1 });
|
||||||
|
|
||||||
|
const app = createApp({ collection, apiToken: API_TOKEN });
|
||||||
|
const server = app.listen(port, () => console.log(`FatihsMG4 API listening on ${port}`));
|
||||||
|
|
||||||
|
async function shutdown() {
|
||||||
|
server.close(async () => {
|
||||||
|
await client.close();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
process.on("SIGINT", shutdown);
|
||||||
|
process.on("SIGTERM", shutdown);
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { after, before, test } from "node:test";
|
||||||
|
import { createApp } from "../src/app.js";
|
||||||
|
|
||||||
|
const token = "a-secure-test-token-with-24-chars";
|
||||||
|
const writes = [];
|
||||||
|
const collection = {
|
||||||
|
async updateOne(filter, update, options) { writes.push({ filter, update, options }); }
|
||||||
|
};
|
||||||
|
let server;
|
||||||
|
let baseUrl;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
const app = createApp({ collection, apiToken: token });
|
||||||
|
await new Promise((resolve) => { server = app.listen(0, "127.0.0.1", resolve); });
|
||||||
|
baseUrl = `http://127.0.0.1:${server.address().port}`;
|
||||||
|
});
|
||||||
|
after(() => new Promise((resolve) => server.close(resolve)));
|
||||||
|
|
||||||
|
const validBody = {
|
||||||
|
installationId: "123e4567-e89b-12d3-a456-426614174000",
|
||||||
|
recordedAt: 1791417600000,
|
||||||
|
lifetime: { km: 1000, kwh: 170, hours: 30, socDrop: 400 },
|
||||||
|
sohPercent: 91.5
|
||||||
|
};
|
||||||
|
|
||||||
|
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" } });
|
||||||
|
assert.equal(response.status, 401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("validates malformed all-time totals", async () => {
|
||||||
|
const response = await fetch(`${baseUrl}/v1/consumption`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
|
||||||
|
body: JSON.stringify({ ...validBody, lifetime: { ...validBody.lifetime, km: "12" } })
|
||||||
|
});
|
||||||
|
assert.equal(response.status, 400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("upserts one all-time record per installation", async () => {
|
||||||
|
const response = await fetch(`${baseUrl}/v1/consumption`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
|
||||||
|
body: JSON.stringify(validBody)
|
||||||
|
});
|
||||||
|
assert.equal(response.status, 204);
|
||||||
|
assert.deepEqual(writes.at(-1).filter, { installationId: validBody.installationId });
|
||||||
|
assert.equal(writes.at(-1).options.upsert, true);
|
||||||
|
assert.equal(writes.at(-1).update.$set.lifetime.kwh, 170);
|
||||||
|
assert.equal(writes.at(-1).update.$set.sohPercent, 91.5);
|
||||||
|
});
|
||||||
+1
-1
@@ -22,7 +22,7 @@ dependencyResolutionManagement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
rootProject.name = "EVABRPUploader"
|
rootProject.name = "FatihsMG4"
|
||||||
include ':app'
|
include ':app'
|
||||||
include ':evhardware'
|
include ':evhardware'
|
||||||
project(':evhardware').projectDir = file('EVHardware/lib')
|
project(':evhardware').projectDir = file('EVHardware/lib')
|
||||||
|
|||||||
Reference in New Issue
Block a user