diff --git a/.gitignore b/.gitignore
index b9f8ba9..aab93a6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -19,6 +19,10 @@ local.properties
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 locally — a checked-in APK is stale the moment anything above it changes.
*.apk
diff --git a/OTA.md b/OTA.md
index fec7c7b..1e1ad16 100644
--- a/OTA.md
+++ b/OTA.md
@@ -25,7 +25,7 @@ vehicle's shared UID; do not use that preview APK for the MG4 upgrade.
## 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
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
```
-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)
- **tick "Set as a pre-release"** ← the whole thing is inert without this
- attach the signed APK and its `.sha256` sidecar
diff --git a/README.md b/README.md
index 5c38800..d723b58 100644
--- a/README.md
+++ b/README.md
@@ -1,11 +1,8 @@
-# EVABRPUploader
+# FatihsMG4
-

+
-[](https://github.com/malys/EVABRPUploader/actions/workflows/tests.yml)
-[](https://github.com/malys/EVABRPUploader/actions/workflows/security.yml)
-[](https://github.com/malys/EVABRPUploader/actions/workflows/unstable.yml)
-[](https://github.com/malys/EVABRPUploader/releases)
+[](https://github.com/fatihdonmezdev/FatihsMG4/releases)
[](LICENSE)
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 |
| **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.
### Getting the APK onto the car
diff --git a/app/build.gradle b/app/build.gradle
index 014d6e2..c076913 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -37,6 +37,11 @@ android {
versionCode 51
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"
}
@@ -104,7 +109,7 @@ android {
}
// 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.
tasks.register('printUnstableVersion') {
doLast {
@@ -120,8 +125,8 @@ androidComponents {
// 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.
output.outputFileName.set(variant.flavorName == "unstable"
- ? "EVABRPUploader-unstable.apk"
- : "EVABRPUploader-${variant.flavorName}-${output.versionName.get()}.apk")
+ ? "FatihsMG4-unstable.apk"
+ : "FatihsMG4-${variant.flavorName}-${output.versionName.get()}.apk")
}
}
}
diff --git a/app/src/main/java/com/evsuite/abrp/AbrpUploadService.java b/app/src/main/java/com/evsuite/abrp/AbrpUploadService.java
index e923985..6ff63c6 100644
--- a/app/src/main/java/com/evsuite/abrp/AbrpUploadService.java
+++ b/app/src/main/java/com/evsuite/abrp/AbrpUploadService.java
@@ -130,6 +130,7 @@ public class AbrpUploadService extends Service {
private EnergyTelemetryReader energyReader;
private ConsumptionTracker consumptionTracker;
+ private ConsumptionCloudClient consumptionCloudClient;
/**
* 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
@@ -196,6 +197,7 @@ public class AbrpUploadService extends Service {
// One shared, firmware-aware EVHardware snapshot owns every vehicle signal.
energyReader = new EnergyTelemetryReader(getApplicationContext());
consumptionTracker = ConsumptionTracker.get(this);
+ consumptionCloudClient = new ConsumptionCloudClient(getApplicationContext());
wifi = new WifiAutoConnect(getApplicationContext());
// The head unit's own weather service, bound for one reason: ENV_OUTSIDE_TEMPERATURE
// is not implemented on this vehicle, so the car cannot say how warm it is outside
@@ -324,6 +326,7 @@ public class AbrpUploadService extends Service {
long sampleMs = System.currentTimeMillis();
EnergySnapshot vehicle = energyReader.read(sampleMs);
consumptionTracker.sample(vehicle);
+ consumptionCloudClient.syncIfDue(consumptionTracker.cloudSnapshot(), sampleMs);
// Keep local consumption history alive while another app owns the ABRP feed.
if (!settings.abrpUploadEnabled) {
diff --git a/app/src/main/java/com/evsuite/abrp/ConsumptionCloudClient.java b/app/src/main/java/com/evsuite/abrp/ConsumptionCloudClient.java
new file mode 100644
index 0000000..2911a5b
--- /dev/null
+++ b/app/src/main/java/com/evsuite/abrp/ConsumptionCloudClient.java
@@ -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);
+ }
+}
diff --git a/app/src/main/java/com/evsuite/abrp/ConsumptionTracker.java b/app/src/main/java/com/evsuite/abrp/ConsumptionTracker.java
index 05e0909..0fd8d7a 100644
--- a/app/src/main/java/com/evsuite/abrp/ConsumptionTracker.java
+++ b/app/src/main/java/com/evsuite/abrp/ConsumptionTracker.java
@@ -34,6 +34,7 @@ final class ConsumptionTracker {
*/
private static final String PREFS = "consumption_counters_v2";
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. */
private static final long PERSIST_INTERVAL_MS = 30_000L;
@@ -138,6 +139,29 @@ final class ConsumptionTracker {
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. */
private void rollDayIfNeeded() {
LocalDate date = LocalDate.now();
diff --git a/app/src/main/java/com/evsuite/abrp/MainActivity.java b/app/src/main/java/com/evsuite/abrp/MainActivity.java
index d9c8fbf..c7be7d7 100644
--- a/app/src/main/java/com/evsuite/abrp/MainActivity.java
+++ b/app/src/main/java/com/evsuite/abrp/MainActivity.java
@@ -12,6 +12,7 @@ import android.view.View;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.Button;
+import android.widget.Toast;
import android.widget.ImageView;
import android.widget.Spinner;
import android.widget.TextView;
@@ -42,7 +43,7 @@ public class MainActivity extends AppCompatActivity {
private static final int LOCATION_PERMISSION_REQUEST = 100;
/** Shared with {@link AbrpUploadService}, which reads it on every tick. */
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}. */
private static final int[] TAB_IDS =
@@ -114,6 +115,8 @@ public class MainActivity extends AppCompatActivity {
consumptionSpeed, consumptionTime, consumptionSoc, consumptionDailyHistory;
private Button consumptionReset;
private MaterialButton consumptionDateButton;
+ private TextInputLayout consumptionSohLayout;
+ private TextInputEditText consumptionSohInput;
private java.time.LocalDate selectedConsumptionDate = java.time.LocalDate.now();
private TextView chargeStatus, chargeSoc, chargeVoltage, chargeCurrent, chargePower,
chargeEnergy, chargeDuration, chargeCurveTable;
@@ -861,6 +864,12 @@ public class MainActivity extends AppCompatActivity {
consumptionDateButton.setText(selectedConsumptionDate.toString());
consumptionDateButton.setOnClickListener(v -> showConsumptionDatePicker());
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_week, ConsumptionTracker.Period.WEEK);
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) {
View button = consumptionPane.findViewById(id);
button.setOnClickListener(v -> {
diff --git a/app/src/main/res/layout/pane_consumption.xml b/app/src/main/res/layout/pane_consumption.xml
index 6555cff..edee196 100644
--- a/app/src/main/res/layout/pane_consumption.xml
+++ b/app/src/main/res/layout/pane_consumption.xml
@@ -1,5 +1,5 @@
-
+
@@ -23,6 +23,14 @@
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index cc4dd0c..f2e4732 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,5 +1,5 @@
- Fatih\'sMG4
+ FatihsMG4
ABRP
@@ -128,9 +128,9 @@
Fatih\'sMG4 Hakkında
Version %1$s
Fork of malys / EVABRPUploader\nand Leon Kernan / ABRP_Uploader
- github.com/fatihdonmezdev/MG4ABRP
+ github.com/fatihdonmezdev/FatihsMG4
MG4 EH32 · SWI69
- EVABRPUploader logo
+ FatihsMG4 logo
QR code for the project repository
unknown
Close
diff --git a/app/src/testUnstable/java/com/evsuite/abrp/OtaUpdaterTest.java b/app/src/testUnstable/java/com/evsuite/abrp/OtaUpdaterTest.java
index ad9d356..4211434 100644
--- a/app/src/testUnstable/java/com/evsuite/abrp/OtaUpdaterTest.java
+++ b/app/src/testUnstable/java/com/evsuite/abrp/OtaUpdaterTest.java
@@ -102,13 +102,13 @@ public class OtaUpdaterTest {
@Test
public void versionIsReadFromTheAssetName() {
// 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.100", OtaUpdater.versionFromAssetName("EVABRPUploader-unstable-1.0.100.APK"));
+ assertEquals("1.0.42", OtaUpdater.versionFromAssetName("FatihsMG4-unstable-1.0.42.apk"));
+ assertEquals("1.0.100", OtaUpdater.versionFromAssetName("FatihsMG4-unstable-1.0.100.APK"));
}
@Test
public void assetNameWithoutAVersionIsIgnored() {
- assertNull(OtaUpdater.versionFromAssetName("EVABRPUploader-unstable.apk"));
+ assertNull(OtaUpdater.versionFromAssetName("FatihsMG4-unstable.apk"));
assertNull(OtaUpdater.versionFromAssetName("unstable"));
}
@@ -218,7 +218,7 @@ public class OtaUpdaterTest {
private static JSONObject asset(String version) throws Exception {
String name = "FatihsMG4-unstable-" + version + ".apk";
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 {
@@ -249,7 +249,7 @@ public class OtaUpdaterTest {
boolean disconnected;
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.body = body;
}
diff --git a/app/src/unstable/java/com/evsuite/abrp/OtaUpdater.java b/app/src/unstable/java/com/evsuite/abrp/OtaUpdater.java
index bf3ad5d..b180804 100644
--- a/app/src/unstable/java/com/evsuite/abrp/OtaUpdater.java
+++ b/app/src/unstable/java/com/evsuite/abrp/OtaUpdater.java
@@ -53,7 +53,7 @@ import java.security.MessageDigest;
final class 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.
@@ -64,7 +64,7 @@ final class OtaUpdater {
* nothing until this repository publishes a release, which is the intended quiet.
*/
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
@@ -162,7 +162,7 @@ final class OtaUpdater {
/**
* 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
* name is what identifies a build. Returns null when the name carries no version.
@@ -198,7 +198,7 @@ final class OtaUpdater {
try {
conn.setInstanceFollowRedirects(false);
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.setReadTimeout(TIMEOUT_MS);
int status = conn.getResponseCode();
@@ -297,7 +297,7 @@ final class OtaUpdater {
connection.setInstanceFollowRedirects(false);
connection.setConnectTimeout(TIMEOUT_MS);
connection.setReadTimeout(TIMEOUT_MS);
- connection.setRequestProperty("User-Agent", "EVABRPUploader-Android");
+ connection.setRequestProperty("User-Agent", "FatihsMG4-Android");
try {
int status = connection.getResponseCode();
if (status >= 300 && status <= 399) {
@@ -358,7 +358,7 @@ final class OtaUpdater {
long downloadId = -1L;
try {
DownloadManager.Request request = new DownloadManager.Request(Uri.parse(update.apkUrl))
- .setTitle("EVABRPUploader " + update.versionName)
+ .setTitle("FatihsMG4 " + update.versionName)
.setDescription("Güncelleme indiriliyor")
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, publicName)
@@ -564,7 +564,7 @@ final class OtaUpdater {
Intent install = new Intent(Intent.ACTION_VIEW)
.setDataAndType(contentUri, "application/vnd.android.package-archive")
.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);
if (install.resolveActivity(context.getPackageManager()) == null) {
throw new IllegalStateException("No package installer activity");
diff --git a/server/.env.example b/server/.env.example
new file mode 100644
index 0000000..6d69c15
--- /dev/null
+++ b/server/.env.example
@@ -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
diff --git a/server/.gitignore b/server/.gitignore
new file mode 100644
index 0000000..03c5ccf
--- /dev/null
+++ b/server/.gitignore
@@ -0,0 +1,3 @@
+node_modules/
+.env
+npm-debug.log*
diff --git a/server/Dockerfile b/server/Dockerfile
new file mode 100644
index 0000000..0f5fb99
--- /dev/null
+++ b/server/Dockerfile
@@ -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"]
diff --git a/server/README.md b/server/README.md
new file mode 100644
index 0000000..15821f0
--- /dev/null
+++ b/server/README.md
@@ -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`.
diff --git a/server/package-lock.json b/server/package-lock.json
new file mode 100644
index 0000000..72f9a88
--- /dev/null
+++ b/server/package-lock.json
@@ -0,0 +1,1040 @@
+{
+ "name": "fatihsmg4-consumption-api",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "fatihsmg4-consumption-api",
+ "version": "1.0.0",
+ "dependencies": {
+ "express": "^5.1.0",
+ "mongodb": "^6.20.0"
+ },
+ "engines": {
+ "node": ">=22"
+ }
+ },
+ "node_modules/@mongodb-js/saslprep": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.6.0.tgz",
+ "integrity": "sha512-19jGXfpctLDJUELLUZGJ5BllcLT7rHdF+gTTaN2jzqu0bGkUhpdcYtgK2Vux+g3zuBAa0TM/W666QMIjYhYujw==",
+ "license": "MIT",
+ "dependencies": {
+ "sparse-bitfield": "^3.0.3"
+ }
+ },
+ "node_modules/@types/webidl-conversions": {
+ "version": "7.0.3",
+ "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz",
+ "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/whatwg-url": {
+ "version": "11.0.5",
+ "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz",
+ "integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/webidl-conversions": "*"
+ }
+ },
+ "node_modules/accepts": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
+ "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "^3.0.0",
+ "negotiator": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/body-parser": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
+ "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "^3.1.2",
+ "content-type": "^2.0.0",
+ "debug": "^4.4.3",
+ "http-errors": "^2.0.1",
+ "iconv-lite": "^0.7.2",
+ "on-finished": "^2.4.1",
+ "qs": "^6.15.2",
+ "raw-body": "^3.0.2",
+ "type-is": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/body-parser/node_modules/content-type": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
+ "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/bson": {
+ "version": "6.10.4",
+ "resolved": "https://registry.npmjs.org/bson/-/bson-6.10.4.tgz",
+ "integrity": "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=16.20.1"
+ }
+ },
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/content-disposition": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
+ "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
+ "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.6.0"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "license": "MIT"
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "license": "MIT"
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/express": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
+ "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "^2.0.0",
+ "body-parser": "^2.2.1",
+ "content-disposition": "^1.0.0",
+ "content-type": "^1.0.5",
+ "cookie": "^0.7.1",
+ "cookie-signature": "^1.2.1",
+ "debug": "^4.4.0",
+ "depd": "^2.0.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "finalhandler": "^2.1.0",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.0",
+ "merge-descriptors": "^2.0.0",
+ "mime-types": "^3.0.0",
+ "on-finished": "^2.4.1",
+ "once": "^1.4.0",
+ "parseurl": "^1.3.3",
+ "proxy-addr": "^2.0.7",
+ "qs": "^6.14.0",
+ "range-parser": "^1.2.1",
+ "router": "^2.2.0",
+ "send": "^1.1.0",
+ "serve-static": "^2.2.0",
+ "statuses": "^2.0.1",
+ "type-is": "^2.0.1",
+ "vary": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/finalhandler": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
+ "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "on-finished": "^2.4.1",
+ "parseurl": "^1.3.3",
+ "statuses": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 18.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
+ "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.7.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
+ "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/is-promise": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
+ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
+ "license": "MIT"
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz",
+ "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/memory-pager": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz",
+ "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==",
+ "license": "MIT"
+ },
+ "node_modules/merge-descriptors": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
+ "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.54.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
+ "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
+ "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "^1.54.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/mongodb": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.21.0.tgz",
+ "integrity": "sha512-URyb/VXMjJ4da46OeSXg+puO39XH9DeQpWCslifrRn9JWugy0D+DvvBvkm2WxmHe61O/H19JM66p1z7RHVkZ6A==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@mongodb-js/saslprep": "^1.3.0",
+ "bson": "^6.10.4",
+ "mongodb-connection-string-url": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=16.20.1"
+ },
+ "peerDependencies": {
+ "@aws-sdk/credential-providers": "^3.188.0",
+ "@mongodb-js/zstd": "^1.1.0 || ^2.0.0",
+ "gcp-metadata": "^5.2.0",
+ "kerberos": "^2.0.1",
+ "mongodb-client-encryption": ">=6.0.0 <7",
+ "snappy": "^7.3.2",
+ "socks": "^2.7.1"
+ },
+ "peerDependenciesMeta": {
+ "@aws-sdk/credential-providers": {
+ "optional": true
+ },
+ "@mongodb-js/zstd": {
+ "optional": true
+ },
+ "gcp-metadata": {
+ "optional": true
+ },
+ "kerberos": {
+ "optional": true
+ },
+ "mongodb-client-encryption": {
+ "optional": true
+ },
+ "snappy": {
+ "optional": true
+ },
+ "socks": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/mongodb-connection-string-url": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.2.tgz",
+ "integrity": "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@types/whatwg-url": "^11.0.2",
+ "whatwg-url": "^14.1.0 || ^13.0.0"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/negotiator": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz",
+ "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==",
+ "license": "MIT",
+ "dependencies": {
+ "content-type": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/negotiator/node_modules/content-type": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
+ "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "license": "ISC",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "8.4.2",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
+ "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.8",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
+ "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.16.0",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
+ "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "es-define-property": "^1.0.1",
+ "side-channel": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
+ "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
+ "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.7.0",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/router": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
+ "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "depd": "^2.0.0",
+ "is-promise": "^4.0.0",
+ "parseurl": "^1.3.3",
+ "path-to-regexp": "^8.0.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
+ "node_modules/send": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
+ "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.3",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.1",
+ "mime-types": "^3.0.2",
+ "ms": "^2.1.3",
+ "on-finished": "^2.4.1",
+ "range-parser": "^1.2.1",
+ "statuses": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/serve-static": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
+ "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "parseurl": "^1.3.3",
+ "send": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "license": "ISC"
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
+ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4",
+ "side-channel-list": "^1.0.1",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/sparse-bitfield": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz",
+ "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==",
+ "license": "MIT",
+ "dependencies": {
+ "memory-pager": "^1.0.2"
+ }
+ },
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
+ "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/type-is": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
+ "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
+ "license": "MIT",
+ "dependencies": {
+ "content-type": "^2.0.0",
+ "media-typer": "^1.1.0",
+ "mime-types": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/type-is/node_modules/content-type": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
+ "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/webidl-conversions": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
+ "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/whatwg-url": {
+ "version": "14.2.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
+ "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
+ "license": "MIT",
+ "dependencies": {
+ "tr46": "^5.1.0",
+ "webidl-conversions": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "license": "ISC"
+ }
+ }
+}
diff --git a/server/package.json b/server/package.json
new file mode 100644
index 0000000..8ec4047
--- /dev/null
+++ b/server/package.json
@@ -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"
+ }
+}
diff --git a/server/src/app.js b/server/src/app.js
new file mode 100644
index 0000000..7cc4ef4
--- /dev/null
+++ b/server/src/app.js
@@ -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;
+}
diff --git a/server/src/server.js b/server/src/server.js
new file mode 100644
index 0000000..bc85f3f
--- /dev/null
+++ b/server/src/server.js
@@ -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);
diff --git a/server/test/app.test.js b/server/test/app.test.js
new file mode 100644
index 0000000..cbc7285
--- /dev/null
+++ b/server/test/app.test.js
@@ -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);
+});
diff --git a/settings.gradle b/settings.gradle
index 5255aaf..98c922f 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -22,7 +22,7 @@ dependencyResolutionManagement {
}
}
-rootProject.name = "EVABRPUploader"
+rootProject.name = "FatihsMG4"
include ':app'
include ':evhardware'
project(':evhardware').projectDir = file('EVHardware/lib')