From 864ac612dad89c1db035ed014aa2281b5e9378a9 Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Sat, 17 Oct 2026 11:38:00 +0200 Subject: [PATCH 01/79] Translate app UI from Turkish to English Translates string resources, hardcoded layout text and user-facing strings in Java sources (toasts, dialogs, status labels, upload log). Co-Authored-By: Claude Opus 5.5 --- .../com/evsuite/abrp/AbrpUploadService.java | 4 +- .../com/evsuite/abrp/ChargingGraphView.java | 4 +- .../evsuite/abrp/ConsumptionCloudClient.java | 32 +++---- .../java/com/evsuite/abrp/MainActivity.java | 62 ++++++------- .../com/evsuite/abrp/WifiAutoConnect.java | 12 +-- app/src/main/res/layout/pane_charging.xml | 28 +++--- app/src/main/res/layout/pane_consumption.xml | 40 ++++---- app/src/main/res/values/strings.xml | 92 +++++++++---------- app/src/main/res/values/strings_design.xml | 44 ++++----- .../java/com/evsuite/abrp/UpdateHook.java | 2 +- .../java/com/evsuite/abrp/OtaUpdater.java | 2 +- .../java/com/evsuite/abrp/UpdateHook.java | 18 ++-- 12 files changed, 170 insertions(+), 170 deletions(-) diff --git a/app/src/main/java/com/evsuite/abrp/AbrpUploadService.java b/app/src/main/java/com/evsuite/abrp/AbrpUploadService.java index f8eb323..b4aa351 100644 --- a/app/src/main/java/com/evsuite/abrp/AbrpUploadService.java +++ b/app/src/main/java/com/evsuite/abrp/AbrpUploadService.java @@ -100,7 +100,7 @@ public class AbrpUploadService extends Service { FIRST_UPLOAD_DELAY_SEC, UPLOAD_INTERVAL_SEC, TimeUnit.SECONDS); uploadLog.record(new UploadLog.Entry(System.currentTimeMillis(), 0, true, - "Tüketim servisi başlatıldı")); + "Consumption service started")); Log.i(TAG, "Service started, consumption tracking armed"); } @@ -159,7 +159,7 @@ public class AbrpUploadService extends Service { wifi.ensureConnected(false); } - updateNotification("Tüketim takibi aktif"); + updateNotification("Consumption tracking active"); } // ---------- Notification ---------- diff --git a/app/src/main/java/com/evsuite/abrp/ChargingGraphView.java b/app/src/main/java/com/evsuite/abrp/ChargingGraphView.java index 9db5b47..acd99da 100644 --- a/app/src/main/java/com/evsuite/abrp/ChargingGraphView.java +++ b/app/src/main/java/com/evsuite/abrp/ChargingGraphView.java @@ -33,13 +33,13 @@ public final class ChargingGraphView extends View { invalidate(); } synchronized String tableText() { - StringBuilder out = new StringBuilder("SOC Şarj gücü\n"); + StringBuilder out = new StringBuilder("SOC Charge power\n"); boolean found = false; for (int soc=0; soc<=100; soc++) if (!Float.isNaN(socPower[soc])) { found = true; out.append(String.format(Locale.getDefault(), "%3d %% %6.1f kW\n", soc, socPower[soc])); } - return found ? out.toString().trim() : "Henüz şarj ölçümü yok"; + return found ? out.toString().trim() : "No charging measurements yet"; } @Override protected synchronized void onDraw(Canvas c) { super.onDraw(c); float left=58, right=getWidth()-12, top=12, bottom=getHeight()-38; diff --git a/app/src/main/java/com/evsuite/abrp/ConsumptionCloudClient.java b/app/src/main/java/com/evsuite/abrp/ConsumptionCloudClient.java index 118d938..4e855e2 100644 --- a/app/src/main/java/com/evsuite/abrp/ConsumptionCloudClient.java +++ b/app/src/main/java/com/evsuite/abrp/ConsumptionCloudClient.java @@ -79,7 +79,7 @@ final class ConsumptionCloudClient { * Every 30 minutes, takes a delta chunk from the tracker and sends it. Chunks that fail * to send (offline, server error) accumulate in a local cache and are retried on the * next cycle — so a drive through a dead-signal area still reaches the cloud once the - * head unit is back on the hotspot. Every attempt is logged to the in-app "Kayıtlar" tab. + * head unit is back on the hotspot. Every attempt is logged to the in-app "Logs" tab. * * @param nowMs wall clock, recorded with the payload — the throttle below runs on a * monotonic clock instead, so a backwards NTP correction cannot stall @@ -96,7 +96,7 @@ final class ConsumptionCloudClient { // Drain any cached chunks first — a backlog from an offline period has priority. int cached = drainChunkCache(); if (cached > 0) { - logEvent(true, 200, "Cache boşaltıldı: " + cached + " chunk gönderildi"); + logEvent(true, 200, "Cache drained: " + cached + " chunk(s) sent"); invalidateHistory(); } @@ -115,14 +115,14 @@ final class ConsumptionCloudClient { try { JSONObject body = consumptionBody(chunk, nowMs); if (send("/v1/consumption/daily", "PUT", body)) { - logEvent(true, 200, "Chunk " + chunk.date + " gönderildi (+" + logEvent(true, 200, "Chunk " + chunk.date + " sent (+" + String.format("%.1f", chunk.day.km) + "km, +" + String.format("%.2f", chunk.day.kwh) + "kWh)"); invalidateHistory(); } else { cacheChunk(body); int pending = pendingChunkCount(); - logEvent(false, 0, "Chunk gönderilemedi — cache'de bekliyor (" + pending + " chunk)"); + logEvent(false, 0, "Chunk not sent — waiting in cache (" + pending + " chunk(s))"); } } catch (Exception e) { // Network failure: cache the chunk for the next cycle. @@ -131,7 +131,7 @@ final class ConsumptionCloudClient { cacheChunk(body); } catch (Exception ignored) { } int pending = pendingChunkCount(); - logEvent(false, 0, "Çevrimdışı — cache'de bekliyor (" + pending + " chunk): " + e.getMessage()); + logEvent(false, 0, "Offline — waiting in cache (" + pending + " chunk(s)): " + e.getMessage()); Log.w(TAG, "Chunk sync failed, cached: " + e.getMessage()); } } @@ -190,7 +190,7 @@ final class ConsumptionCloudClient { return loadChunkCache().length(); } - /** Records a cloud-sync attempt to the in-app log (the "Kayıtlar" tab). */ + /** Records a cloud-sync attempt to the in-app log (the "Logs" tab). */ private void logEvent(boolean success, int httpStatus, String detail) { UploadLog log = AbrpUploadService.log(); if (log == null) return; @@ -220,7 +220,7 @@ final class ConsumptionCloudClient { try { syncPendingCharges(charges, pending); } catch (Exception e) { - logEvent(false, 0, "Şarj session sync hatası: " + e.getMessage()); + logEvent(false, 0, "Charging session sync error: " + e.getMessage()); Log.w(TAG, "Charging-session sync unavailable: " + e.getMessage()); } } @@ -235,10 +235,10 @@ final class ConsumptionCloudClient { Log.i(TAG, "Uploading charge session: " + id); if (send("/v1/charging-sessions/" + id, "PUT", body)) { charges.markUploaded(id); - logEvent(true, 200, "Şarj session gönderildi: " + id); + logEvent(true, 200, "Charging session sent: " + id); Log.i(TAG, "Successfully uploaded charge session: " + id); } else { - logEvent(false, 0, "Şarj session gönderilemedi: " + id); + logEvent(false, 0, "Charging session not sent: " + id); Log.w(TAG, "Failed to upload charge session: " + id); return; } @@ -310,11 +310,11 @@ final class ConsumptionCloudClient { List fresh = fetchHistory(); if (!fresh.isEmpty()) cachedHistory = fresh; Log.i(TAG, "Fetched " + fresh.size() + " history records from cloud"); - logEvent(true, 200, "History çekildi: " + fresh.size() + " gün"); + logEvent(true, 200, "History fetched: " + fresh.size() + " day(s)"); } catch (Exception e) { String msg = e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage(); Log.w(TAG, "History fetch unavailable: " + msg); - logEvent(false, 0, "History fetch hatası: " + msg); + logEvent(false, 0, "History fetch error: " + msg); } } @@ -325,11 +325,11 @@ final class ConsumptionCloudClient { /** * Immediately attempts to send every cached chunk and take a fresh one, ignoring the - * 2-minute throttle. Called from the UI "Şimdi yükle" button. Returns a one-line + * 2-minute throttle. Called from the UI "Upload now" button. Returns a one-line * status for the log. */ String flushNow(ConsumptionTracker tracker, long nowMs) { - if (endpoint.isEmpty() || token.isEmpty()) return "API yapılandırılmamış"; + if (endpoint.isEmpty() || token.isEmpty()) return "API not configured"; int cached = drainChunkCache(); ConsumptionTracker.CloudSnapshot chunk = tracker.takeChunkDelta(); int total = cached; @@ -348,9 +348,9 @@ final class ConsumptionCloudClient { } int pending = pendingChunkCount(); String msg = total > 0 - ? total + " chunk yüklendi" + (pending > 0 ? ", " + pending + " hâlâ bekliyor" : "") - : (pending > 0 ? pending + " chunk yüklenemedi (internet?)" : "Yüklenecek yeni veri yok"); - logEvent(total > 0, total > 0 ? 200 : 0, "Manuel sync: " + msg); + ? total + " chunk(s) uploaded" + (pending > 0 ? ", " + pending + " still pending" : "") + : (pending > 0 ? pending + " chunk(s) could not be uploaded (internet?)" : "No new data to upload"); + logEvent(total > 0, total > 0 ? 200 : 0, "Manual sync: " + msg); return msg; } diff --git a/app/src/main/java/com/evsuite/abrp/MainActivity.java b/app/src/main/java/com/evsuite/abrp/MainActivity.java index 530b3ed..475686c 100644 --- a/app/src/main/java/com/evsuite/abrp/MainActivity.java +++ b/app/src/main/java/com/evsuite/abrp/MainActivity.java @@ -363,9 +363,9 @@ public class MainActivity extends AppCompatActivity { String ssid = wifi.currentSsid(); wifiSsid.setText(ssid == null ? getString(R.string.wifi_not_connected) : ssid); if (ssid != null) { - wifiState.setText("Bağlı ♥"); + wifiState.setText("Connected ♥"); } else if (!wifi.isWifiEnabled()) { - wifiState.setText("Wi-Fi kapalı"); + wifiState.setText("Wi-Fi off"); } // Left alone otherwise: a message the button just set says more about what is // happening than "not connected" would. @@ -458,10 +458,10 @@ public class MainActivity extends AppCompatActivity { consumptionReset.setOnClickListener(v -> { if (consumptionPeriod != ConsumptionTracker.Period.TRIP_A && consumptionPeriod != ConsumptionTracker.Period.TRIP_B) return; String name = consumptionPeriod == ConsumptionTracker.Period.TRIP_A ? "Trip A" : "Trip B"; - new MaterialAlertDialogBuilder(this).setTitle(name + " sıfırlansın mı?") - .setMessage("Bu sayacın kayıtlı tüketim geçmişi kalıcı olarak silinir.") - .setNegativeButton("Vazgeç", null) - .setPositiveButton("Sıfırla", (d, w) -> { + new MaterialAlertDialogBuilder(this).setTitle("Reset " + name + "?") + .setMessage("This counter's recorded consumption history will be permanently deleted.") + .setNegativeButton("Cancel", null) + .setPositiveButton("Reset", (d, w) -> { consumptionTracker.reset(consumptionPeriod); refreshConsumption(); }).show(); @@ -475,9 +475,9 @@ public class MainActivity extends AppCompatActivity { 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(); + Toast.makeText(this, "SoH saved", Toast.LENGTH_SHORT).show(); } catch (NumberFormatException e) { - consumptionSohLayout.setError("1 ile 100 arasında bir değer girin"); + consumptionSohLayout.setError("Enter a value between 1 and 100"); } } @@ -533,9 +533,9 @@ public class MainActivity extends AppCompatActivity { if (!chargeSessionTracker.setPricePerKwh(value)) throw new NumberFormatException(); chargePriceLayout.setError(null); chargePriceInput.setText(String.format(java.util.Locale.US, "%.2f", value)); - Toast.makeText(this, "Şarj fiyatı kaydedildi", Toast.LENGTH_SHORT).show(); + Toast.makeText(this, "Charging price saved", Toast.LENGTH_SHORT).show(); } catch (NumberFormatException e) { - chargePriceLayout.setError("Geçerli bir ₺/kWh değeri girin"); + chargePriceLayout.setError("Enter a valid ₺/kWh value"); } } @@ -550,9 +550,9 @@ public class MainActivity extends AppCompatActivity { // below — which then describe the previous DC session — be read as this charge. boolean dc = ChargingState.isDcCharging( code, s.getChargePortConnected(), s.getBatteryPowerKw(), s.getSpeedKmh()); - chargeStatus.setText(dc ? "DC hızlı şarj" - : charging ? "AC şarj (ölçülmüyor)" - : (session.startedAtMs > 0 ? "Son şarj" : "Şarj yok")); + chargeStatus.setText(dc ? "DC fast charging" + : charging ? "AC charging (not metered)" + : (session.startedAtMs > 0 ? "Last charge" : "Not charging")); chargeSoc.setText(fmt(s.getSocPercent(), "%.1f %%")); chargeStartSoc.setText(fmt(session.startSocPercent, "%.1f %%")); chargeVoltage.setText(fmt(s.getBatteryVoltageV(), "%.1f V")); @@ -586,20 +586,20 @@ public class MainActivity extends AppCompatActivity { consumptionSpeed.setText(t.hours <= 0 ? "—" : String.format(java.util.Locale.getDefault(), "%.1f km/h", t.km / t.hours)); long minutes = Math.round(t.hours * 60d); - consumptionTime.setText(String.format(java.util.Locale.getDefault(), "%d sa %02d dk", + consumptionTime.setText(String.format(java.util.Locale.getDefault(), "%d h %02d min", minutes / 60, minutes % 60)); consumptionSoc.setText(String.format(java.util.Locale.getDefault(), "%.1f %%", t.soc)); // Daily history: cloud first, local fallback for today (not yet uploaded). ConsumptionTracker.Totals day = cloudDay(selectedConsumptionDate); if (day == null) day = consumptionTracker.totalsForDay(selectedConsumptionDate); if (day.km == 0 && day.kwh == 0 && day.hours == 0 && day.soc == 0) { - consumptionDailyHistory.setText("Bu tarih için kayıt yok"); + consumptionDailyHistory.setText("No records for this date"); } else { String average = day.km < 0.1 ? "—" : String.format(java.util.Locale.getDefault(), "%.1f kWh/100 km", day.kwh * 100d / day.km); long dayMinutes = Math.round(day.hours * 60d); consumptionDailyHistory.setText(String.format(java.util.Locale.getDefault(), - "Mesafe %.1f km\nEnerji %.2f kWh\nOrtalama %s\nSüre %d sa %02d dk\nSOC farkı %.1f %%", + "Distance %.1f km\nEnergy %.2f kWh\nAverage %s\nDuration %d h %02d min\nSOC change %.1f %%", day.km, day.kwh, average, dayMinutes / 60, dayMinutes % 60, day.soc)); } } @@ -666,17 +666,17 @@ public class MainActivity extends AppCompatActivity { Integer status = s.getChargingStatus(); if (status != null) { switch (status) { - case 1: return "AC şarj ♥"; - case 10: return "DC hızlı şarj ♥"; - case 5: return "Bağlanıyor…"; - case 7: return "Kabloda, şarj yok"; - case 8: return "Durduruldu"; - case 0: return "Şarj yok"; - default: return "Durum " + status; + case 1: return "AC charging ♥"; + case 10: return "DC fast charging ♥"; + case 5: return "Connecting…"; + case 7: return "Plugged in, not charging"; + case 8: return "Stopped"; + case 0: return "Not charging"; + default: return "Status " + status; } } Boolean plugged = s.getChargePortConnected(); - if (plugged != null) return plugged ? "Bağlı ♥" : "Bağlı değil"; + if (plugged != null) return plugged ? "Connected ♥" : "Not connected"; return "—"; } @@ -689,13 +689,13 @@ public class MainActivity extends AppCompatActivity { // ---------- Call log ---------- /** - * "Şimdi yükle" button — forces a cache drain + fresh chunk on a background thread, - * ignoring the 2-minute throttle. The result lands in the upload log (Kayıtlar tab). + * "Upload now" button — forces a cache drain + fresh chunk on a background thread, + * ignoring the 2-minute throttle. The result lands in the upload log (Logs tab). */ private void manualFlush() { if (consumptionCloudClient == null || consumptionTracker == null) return; AbrpUploadService.log().record(new UploadLog.Entry( - System.currentTimeMillis(), 0, true, "Manuel sync başlatıldı…")); + System.currentTimeMillis(), 0, true, "Manual sync started…")); new Thread(() -> { String result = consumptionCloudClient.flushNow( consumptionTracker, System.currentTimeMillis()); @@ -704,19 +704,19 @@ public class MainActivity extends AppCompatActivity { } /** - * Tüketim sekmesi "Yenile" button — sends cached chunks + fresh delta, invalidates the + * Consumption tab "Refresh" button — sends cached chunks + fresh delta, invalidates the * history cache so the next poll fetches fresh data from the cloud, then refreshes the UI. */ private void refreshFromCloud() { if (consumptionCloudClient == null || consumptionTracker == null) return; AbrpUploadService.log().record(new UploadLog.Entry( - System.currentTimeMillis(), 0, true, "Tüketim yenileniyor…")); + System.currentTimeMillis(), 0, true, "Refreshing consumption…")); new Thread(() -> { consumptionCloudClient.flushNow(consumptionTracker, System.currentTimeMillis()); consumptionCloudClient.invalidateHistory(); runOnUiThread(() -> { refreshConsumption(); - Toast.makeText(this, "Tüketim yenilendi", Toast.LENGTH_SHORT).show(); + Toast.makeText(this, "Consumption refreshed", Toast.LENGTH_SHORT).show(); }); }, "cloud-refresh").start(); } @@ -764,7 +764,7 @@ public class MainActivity extends AppCompatActivity { break; case RUNNING: case STARTING: - statusText.setText("Tüketim takibi aktif"); + statusText.setText("Consumption tracking active"); statusText.setTextColor(COLOR_OK); break; case STOPPED: diff --git a/app/src/main/java/com/evsuite/abrp/WifiAutoConnect.java b/app/src/main/java/com/evsuite/abrp/WifiAutoConnect.java index 5e89f8b..9228a75 100644 --- a/app/src/main/java/com/evsuite/abrp/WifiAutoConnect.java +++ b/app/src/main/java/com/evsuite/abrp/WifiAutoConnect.java @@ -150,14 +150,14 @@ final class WifiAutoConnect { this.message = message; } - static Result alreadyOn(String ssid) { return new Result(true, ssid, "Bağlı: " + ssid); } - static Result attempting(String ssid) { return new Result(false, ssid, "Bağlanılıyor: " + ssid); } + static Result alreadyOn(String ssid) { return new Result(true, ssid, "Connected: " + ssid); } + static Result attempting(String ssid) { return new Result(false, ssid, "Connecting: " + ssid); } - static final Result UNSUPPORTED = new Result(false, null, "Wi-Fi yönetilemiyor"); + static final Result UNSUPPORTED = new Result(false, null, "Wi-Fi cannot be managed"); static final Result WAITING = new Result(false, null, "Bekleniyor…"); - static final Result ENABLING = new Result(false, null, "Wi-Fi açılıyor…"); - static final Result CANNOT_ENABLE = new Result(false, null, "Wi-Fi açılamadı"); - static final Result NO_SAVED_NETWORKS = new Result(false, null, "Kayıtlı ağ yok"); + static final Result ENABLING = new Result(false, null, "Turning Wi-Fi on…"); + static final Result CANNOT_ENABLE = new Result(false, null, "Could not turn Wi-Fi on"); + static final Result NO_SAVED_NETWORKS = new Result(false, null, "No saved networks"); static final Result REFUSED = new Result(false, null, "Sistem izin vermedi"); } } diff --git a/app/src/main/res/layout/pane_charging.xml b/app/src/main/res/layout/pane_charging.xml index 4d561aa..ce2c3f0 100644 --- a/app/src/main/res/layout/pane_charging.xml +++ b/app/src/main/res/layout/pane_charging.xml @@ -2,30 +2,30 @@ - + - - - - - - - - + + + + + + + + - - + + - + - + - + diff --git a/app/src/main/res/layout/pane_consumption.xml b/app/src/main/res/layout/pane_consumption.xml index bb614af..da37318 100644 --- a/app/src/main/res/layout/pane_consumption.xml +++ b/app/src/main/res/layout/pane_consumption.xml @@ -3,41 +3,41 @@ - - + + - - - + + + - - - - - - + + + + + + - + - + - - + + - - + + - - - + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 194577a..be15a7f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3,45 +3,45 @@ ABRP - Aktarım - Genel - Tüketim - Şarj - Tüketim - Başlangıç, motor, tüm zamanlar ve trip sayaçları - Şarj - Canlı batarya değerleri ve SOC/güç eğrisi + Upload + Overview + Consumption + Charging + Consumption + Since start, motor, lifetime and trip counters + Charging + Live battery values and SOC/power curve Wi-Fi - Kayıtlar + Logs - Wi-Fi Bağlantısı - Bağlı ağ - Durum - Şimdi Bağlan - Arka planda otomatik bağlan - Yükleme servisi çalışırken bağlantı koptuğunda kayıtlı ağlara kendi kendine bağlanmayı dener. - Kayıtlı Ağlar - Kayıtlı ağ okunamadı - Bağlı değil + Wi-Fi Connection + Connected network + Status + Connect Now + Auto-connect in the background + While the upload service is running, it tries to reconnect to saved networks on its own when the connection drops. + Saved Networks + Could not read saved networks + Not connected - Canlı Araç Verileri - Lastik Basınçları - Şarj (SOC) - Menzil - Güç - Hız - Kilometre - Batarya Sıcaklığı - Kabin Sıcaklığı - Klima Ayarı - Batarya Kapasitesi - Şarj Durumu - Ön Sol - Ön Sağ - Arka Sol - Arka Sağ + Live Vehicle Data + Tire Pressures + Charge (SOC) + Range + Power + Speed + Odometer + Battery Temperature + Cabin Temperature + Climate Setpoint + Battery Capacity + Charging Status + Front Left + Front Right + Rear Left + Rear Right Configuration ABRP API Key 8cfc314b-03cd-4efe-ab7d-4431cd8f2e2d @@ -51,9 +51,9 @@ Find your token in the ABRP app: Settings → Connections → Generic (Manual Entry) Show or hide API key Show or hide user token - Kaydet - Bağlantıyı test et - Dosyadan al + Save + Test connection + Import from file Config imported — press Test to verify @@ -107,7 +107,7 @@ Recent uploads No upload attempted yet. - Şimdi yükle + Upload now Starting… Running — waiting for first upload Last upload: %1$s (%2$s) @@ -118,15 +118,15 @@ Shows vehicle telemetry upload status ABRP Uploader Stop - Güncelleme kuruldu — uygulamayı yeniden başlatın - Kurulum başarısız: %1$s - Güncelleme kontrol et - Yeni OTA sürümü kontrol ediliyor… - Yazılım güncellemesi - İndiriliyor ve doğrulanıyor… - İndiriliyor… %1$d%% + Update installed — restart the app + Installation failed: %1$s + Check for updates + Checking for a new OTA version… + Software update + Downloading and verifying… + Downloading… %1$d%% About - Fatih\'sMG4 Hakkında + About Fatih\'sMG4 Version %1$s Fork of malys / EVABRPUploader\nand Leon Kernan / ABRP_Uploader github.com/fatihdonmezdev/FatihsMG4 diff --git a/app/src/main/res/values/strings_design.xml b/app/src/main/res/values/strings_design.xml index 8564ab9..161df82 100644 --- a/app/src/main/res/values/strings_design.xml +++ b/app/src/main/res/values/strings_design.xml @@ -1,28 +1,28 @@ - Batarya + Battery VHAL Probe MG⁴ Fatih’s MG4 - ELEKTRİKLE YOLDA - Sürüşe bir bakış. - Aracından gelen veriler, tek bir yerde. - Rotana bağlan. - ABRP hesabınla aracını buluştur. - Sen yola odaklan. - Verilerin arka planda güncel kalsın. - Bağlantıda kal. - Yolculuğunun internet bağlantısı. - Her aktarım, burada. - Son gönderimlerin durumu ve ayrıntıları. - BATARYA - ENERJİ VE SICAKLIK - LASTİK BASINÇLARI - HESAP BAĞLANTISI - Sana özel bağlantı. - Token, aracını ABRP hesabına bağlar. Bilgilerin bu cihazda şifreli saklanır. - Okunamayan veriler — olarak gösterilir. - AKTARIM KONTROLÜ - GÜNCELLEME SIKLIĞI - Lastik basınçlarının araç üzerindeki konumları + ON THE ROAD, ELECTRIC + Your drive at a glance. + Data from your car, all in one place. + Connect to your route. + Link your car with your ABRP account. + You focus on the road. + Your data stays up to date in the background. + Stay connected. + The internet connection for your journey. + Every upload, right here. + Status and details of recent uploads. + BATTERY + ENERGY AND TEMPERATURE + TIRE PRESSURES + ACCOUNT LINK + Your private connection. + The token links your car to your ABRP account. Your credentials are stored encrypted on this device. + Values that cannot be read are shown as —. + UPLOAD CONTROL + UPDATE FREQUENCY + Tire pressure positions on the vehicle diff --git a/app/src/stable/java/com/evsuite/abrp/UpdateHook.java b/app/src/stable/java/com/evsuite/abrp/UpdateHook.java index ed29e05..c6745a2 100644 --- a/app/src/stable/java/com/evsuite/abrp/UpdateHook.java +++ b/app/src/stable/java/com/evsuite/abrp/UpdateHook.java @@ -27,7 +27,7 @@ final class UpdateHook { /** Answers immediately that this channel does not self-update. */ static void checkInBackground(Context context, Listener listener) { - if (listener != null) listener.onResult("Bu sürümde otomatik güncelleme yok"); + if (listener != null) listener.onResult("This build has no automatic updates"); } static boolean isSupported() { return false; } diff --git a/app/src/unstable/java/com/evsuite/abrp/OtaUpdater.java b/app/src/unstable/java/com/evsuite/abrp/OtaUpdater.java index b180804..9d09aa7 100644 --- a/app/src/unstable/java/com/evsuite/abrp/OtaUpdater.java +++ b/app/src/unstable/java/com/evsuite/abrp/OtaUpdater.java @@ -359,7 +359,7 @@ final class OtaUpdater { try { DownloadManager.Request request = new DownloadManager.Request(Uri.parse(update.apkUrl)) .setTitle("FatihsMG4 " + update.versionName) - .setDescription("Güncelleme indiriliyor") + .setDescription("Downloading update") .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) .setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, publicName) .setAllowedOverMetered(true) diff --git a/app/src/unstable/java/com/evsuite/abrp/UpdateHook.java b/app/src/unstable/java/com/evsuite/abrp/UpdateHook.java index 8629c21..a335a1e 100644 --- a/app/src/unstable/java/com/evsuite/abrp/UpdateHook.java +++ b/app/src/unstable/java/com/evsuite/abrp/UpdateHook.java @@ -50,7 +50,7 @@ final class UpdateHook { if (update != null && update.hashUrl != null) new android.os.Handler(android.os.Looper.getMainLooper()) .post(() -> android.widget.Toast.makeText(app, - "Güncelleme hazır: " + update.versionName, + "Update available: " + update.versionName, android.widget.Toast.LENGTH_LONG).show()); } catch (Throwable t) { Log.w(TAG, "Startup update check failed", t); @@ -69,7 +69,7 @@ final class UpdateHook { Context app = context.getApplicationContext(); android.os.Handler main = new android.os.Handler(android.os.Looper.getMainLooper()); if (!checking.compareAndSet(false, true)) { - if (listener != null) main.post(() -> listener.onResult("Güncelleme kontrolü zaten sürüyor")); + if (listener != null) main.post(() -> listener.onResult("Update check already in progress")); return; } new Thread(() -> { @@ -78,7 +78,7 @@ final class UpdateHook { message = runCheck(app, listener, main); } catch (Throwable t) { Log.w(TAG, "Update check threw", t); - message = "Güncelleme kontrolü başarısız"; + message = "Update check failed"; } finally { checking.set(false); } @@ -97,11 +97,11 @@ final class UpdateHook { current = app.getPackageManager() .getPackageInfo(app.getPackageName(), 0).versionName; } catch (Exception e) { - return "Sürüm okunamadı"; + return "Could not read version"; } OtaUpdater.Update update = OtaUpdater.check(current); - if (update == null) return "Güncel (" + current + ")"; + if (update == null) return "Up to date (" + current + ")"; Log.i(TAG, "Update available: " + update.versionName); String expectedHash; @@ -109,16 +109,16 @@ final class UpdateHook { expectedHash = OtaUpdater.fetchExpectedSha256(update); } catch (IOException e) { Log.w(TAG, "OTA hash unavailable", e); - return "Güncelleme reddedildi: SHA-256 doğrulaması yok"; + return "Update refused: no SHA-256 verification"; } File apk = OtaUpdater.download(app, update, expectedHash, percent -> { if (listener != null) main.post(() -> listener.onProgress(percent)); }); - if (apk == null) return "İndirme veya SHA-256 doğrulaması başarısız (" + if (apk == null) return "Download or SHA-256 verification failed (" + update.versionName + ")"; if (!OtaUpdater.openDownloads(app)) - return "APK Downloads'a indirildi; dosya yöneticisi açılamadı"; - return update.versionName + ".apk Downloads'a indirildi — dosyaya dokunup kurun"; + return "APK downloaded to Downloads; could not open the file manager"; + return update.versionName + ".apk downloaded to Downloads — tap the file to install"; } } From bac887bfa689e386c8f91d79be7270c16dc72f0e Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Sat, 17 Oct 2026 11:46:37 +0200 Subject: [PATCH 02/79] Add Nix flake dev shell for building on NixOS Provides JDK 21 and an Android SDK (platforms 36/36.1, build-tools 36.0.0) via `nix develop`. Points AGP at the SDK's aapt2 and Gradle at the Nix JDK, since the binaries they would otherwise download do not run on NixOS. Co-Authored-By: Claude Opus 5.5 --- flake.lock | 27 +++++++++++++++++++++++ flake.nix | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 flake.lock create mode 100644 flake.nix diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..bb38431 --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1791956154, + "narHash": "sha256-jBYGPi4udQ9uo4KQp9m2J0546nJVQitG15gvZSaVVAs=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "55bfb4d7437bd4e09ab7cba58a43bd68eb310044", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..9544b6c --- /dev/null +++ b/flake.nix @@ -0,0 +1,64 @@ +{ + description = "FatihsMG4 — Android Automotive build environment"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + }; + + outputs = { self, nixpkgs }: + let + systems = [ "x86_64-linux" "aarch64-linux" ]; + forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f (import nixpkgs { + inherit system; + config = { + allowUnfree = true; + android_sdk.accept_license = true; + }; + })); + + # Must match app/build.gradle (compileSdk 36.1) and EVHardware/lib (compileSdk 36). + buildToolsVersion = "36.0.0"; + in + { + devShells = forAllSystems (pkgs: + let + android = pkgs.androidenv.composeAndroidPackages { + platformVersions = [ "36" "36.1" ]; + buildToolsVersions = [ buildToolsVersion ]; + includeEmulator = false; + includeNDK = false; + includeSources = false; + includeSystemImages = false; + }; + sdk = "${android.androidsdk}/libexec/android-sdk"; + # gradle/gradle-daemon-jvm.properties pins the daemon to JDK 21. + jdk = pkgs.jdk21; + in + { + default = pkgs.mkShell { + packages = [ jdk android.androidsdk ]; + + ANDROID_HOME = sdk; + ANDROID_SDK_ROOT = sdk; + JAVA_HOME = jdk.home; + + # AGP otherwise downloads an aapt2 binary from Maven, which is dynamically linked + # against a generic FHS and does not run on NixOS. Use the SDK's patched one. + # Gradle is also pointed at the Nix JDK so the daemon toolchain criteria are met + # locally instead of downloading a (non-NixOS) JDK through foojay. + GRADLE_OPTS = builtins.concatStringsSep " " [ + "-Dorg.gradle.project.android.aapt2FromMavenOverride=${sdk}/build-tools/${buildToolsVersion}/aapt2" + "-Dorg.gradle.java.installations.paths=${jdk.home}" + "-Dorg.gradle.java.installations.auto-download=false" + ]; + + shellHook = '' + echo "sdk.dir=$ANDROID_HOME" > local.properties + echo "FatihsMG4 dev shell — JDK $(java -version 2>&1 | head -1 | cut -d'"' -f2), SDK at $ANDROID_HOME" + echo " ./gradlew assembleStableDebug build a debug APK" + echo " ./gradlew testStableDebugUnitTest run unit tests" + ''; + }; + }); + }; +} From 9d647f2ce1894600b7aea9ad3409e79104e3820a Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Sat, 17 Oct 2026 11:49:17 +0200 Subject: [PATCH 03/79] Match dev shell hint to the documented build commands Drops the unit-test hint, since the test sources do not compile upstream. Co-Authored-By: Claude Opus 5.5 --- flake.nix | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flake.nix b/flake.nix index 9544b6c..948455b 100644 --- a/flake.nix +++ b/flake.nix @@ -55,8 +55,8 @@ shellHook = '' echo "sdk.dir=$ANDROID_HOME" > local.properties echo "FatihsMG4 dev shell — JDK $(java -version 2>&1 | head -1 | cut -d'"' -f2), SDK at $ANDROID_HOME" - echo " ./gradlew assembleStableDebug build a debug APK" - echo " ./gradlew testStableDebugUnitTest run unit tests" + echo " ./gradlew assembleStableDebug # debug APK" + echo " ./gradlew assembleUnstableDebug # build that can update itself (OTA)" ''; }; }); From 8e7d8b862715aa3b6d09893763916a2160aaa79a Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Sat, 17 Oct 2026 12:51:58 +0200 Subject: [PATCH 04/79] Add build-car: unstable APK signed with the AOSP platform key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MG4 head unit's platform certificate (c8a2e9bc…192ab8, per tools/README.md) is the public AOSP test key. build-car fetches that key into the gitignored tools/ directory from a pinned android-9.0.0_r1 tag, builds assembleUnstableDebug, zipaligns and signs it, and refuses to finish unless both the key's certificate and the resulting signer match the expected SHA-256. Output lands in build/car/ with a .sha256 sidecar. Co-Authored-By: Claude Opus 5.5 --- flake.nix | 89 ++++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 79 insertions(+), 10 deletions(-) diff --git a/flake.nix b/flake.nix index 948455b..70fead2 100644 --- a/flake.nix +++ b/flake.nix @@ -33,30 +33,99 @@ sdk = "${android.androidsdk}/libexec/android-sdk"; # gradle/gradle-daemon-jvm.properties pins the daemon to JDK 21. jdk = pkgs.jdk21; + buildTools = "${sdk}/build-tools/${buildToolsVersion}"; + + # AGP otherwise downloads an aapt2 binary from Maven, which is dynamically linked + # against a generic FHS and does not run on NixOS. Use the SDK's patched one. + # Gradle is also pointed at the Nix JDK so the daemon toolchain criteria are met + # locally instead of downloading a (non-NixOS) JDK through foojay. + gradleOpts = builtins.concatStringsSep " " [ + "-Dorg.gradle.project.android.aapt2FromMavenOverride=${buildTools}/aapt2" + "-Dorg.gradle.java.installations.paths=${jdk.home}" + "-Dorg.gradle.java.installations.auto-download=false" + ]; + + # The MG4 head unit is signed with the public AOSP test platform key, so that is + # the key a system-UID (unstable) build must carry. See tools/README.md and OTA.md. + aospKeys = "https://raw.githubusercontent.com/aosp-mirror/platform_build/android-9.0.0_r1/target/product/security"; + platformCertSha256 = "c8a2e9bccf597c2fb6dc66bee293fc13f2fc47ec77bc6b2b0d52c11f51192ab8"; + + buildCar = pkgs.writeShellApplication { + name = "build-car"; + runtimeInputs = [ jdk pkgs.curl pkgs.openssl pkgs.coreutils pkgs.gnugrep pkgs.gawk ]; + text = '' + root=$(git rev-parse --show-toplevel 2>/dev/null || pwd) + cd "$root" + if [ ! -f app/build.gradle ]; then + echo "build-car: run this from the FatihsMG4 checkout" >&2 + exit 1 + fi + + export ANDROID_HOME=${sdk} ANDROID_SDK_ROOT=${sdk} JAVA_HOME=${jdk.home} + export GRADLE_OPTS="${gradleOpts}" + echo "sdk.dir=$ANDROID_HOME" > local.properties + + # Keys live in tools/, which .gitignore keeps out of the repository. + for f in platform.pk8 platform.x509.pem; do + if [ ! -f "tools/$f" ]; then + echo "Fetching AOSP test key tools/$f" + curl -sSfL -o "tools/$f" "${aospKeys}/$f" + fi + done + cert=$(openssl x509 -in tools/platform.x509.pem -outform der | sha256sum | cut -d' ' -f1) + if [ "$cert" != "${platformCertSha256}" ]; then + echo "build-car: tools/platform.x509.pem has SHA-256 $cert, expected ${platformCertSha256}" >&2 + exit 1 + fi + + ./gradlew assembleUnstableDebug "$@" + version=$(./gradlew -q printUnstableVersion "$@" | tail -1) + + in=app/build/outputs/apk/unstable/debug/app-unstable-debug.apk + mkdir -p build/car + aligned=build/car/aligned.apk + out="build/car/FatihsMG4-unstable-$version.apk" + + "${buildTools}/zipalign" -f -p 4 "$in" "$aligned" + # Options come before the input APK; apksigner rejects the other order. + "${buildTools}/apksigner" sign \ + --key tools/platform.pk8 --cert tools/platform.x509.pem \ + --out "$out" "$aligned" + rm -f "$aligned" "$out.idsig" + + signer=$("${buildTools}/apksigner" verify --print-certs "$out" \ + | awk -F': ' '/Signer #1 certificate SHA-256 digest/ { print $2 }') + if [ "$signer" != "${platformCertSha256}" ]; then + echo "build-car: $out is signed with $signer, expected ${platformCertSha256}" >&2 + rm -f "$out" + exit 1 + fi + sha256sum "$out" | cut -d' ' -f1 > "$out.sha256" + + echo + echo "Signed with the AOSP platform key: $out" + echo "Install on the head unit:" + echo " adb push $out /data/local/tmp/update.apk" + echo " adb shell \"pm install -r -f -d -t /data/local/tmp/update.apk\"" + ''; + }; in { default = pkgs.mkShell { - packages = [ jdk android.androidsdk ]; + packages = [ jdk android.androidsdk buildCar ]; ANDROID_HOME = sdk; ANDROID_SDK_ROOT = sdk; JAVA_HOME = jdk.home; - # AGP otherwise downloads an aapt2 binary from Maven, which is dynamically linked - # against a generic FHS and does not run on NixOS. Use the SDK's patched one. - # Gradle is also pointed at the Nix JDK so the daemon toolchain criteria are met - # locally instead of downloading a (non-NixOS) JDK through foojay. - GRADLE_OPTS = builtins.concatStringsSep " " [ - "-Dorg.gradle.project.android.aapt2FromMavenOverride=${sdk}/build-tools/${buildToolsVersion}/aapt2" - "-Dorg.gradle.java.installations.paths=${jdk.home}" - "-Dorg.gradle.java.installations.auto-download=false" - ]; + GRADLE_OPTS = gradleOpts; shellHook = '' echo "sdk.dir=$ANDROID_HOME" > local.properties echo "FatihsMG4 dev shell — JDK $(java -version 2>&1 | head -1 | cut -d'"' -f2), SDK at $ANDROID_HOME" echo " ./gradlew assembleStableDebug # debug APK" echo " ./gradlew assembleUnstableDebug # build that can update itself (OTA)" + echo " build-car # unstable build signed for the car" ''; }; }); From 3af337ca89cc87424bcf1edad17911c3dba0ea5b Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Sat, 17 Oct 2026 12:59:52 +0200 Subject: [PATCH 05/79] Disable OTA update checks by pointing at an unresolvable host The updater stays in place, but its releases URL now uses the .invalid TLD (RFC 2606), so checks fail instead of offering upstream's Turkish builds over this one. Swap in a fork's releases API to re-enable it. Co-Authored-By: Claude Opus 5.5 --- app/src/unstable/java/com/evsuite/abrp/OtaUpdater.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/src/unstable/java/com/evsuite/abrp/OtaUpdater.java b/app/src/unstable/java/com/evsuite/abrp/OtaUpdater.java index 9d09aa7..f77c44d 100644 --- a/app/src/unstable/java/com/evsuite/abrp/OtaUpdater.java +++ b/app/src/unstable/java/com/evsuite/abrp/OtaUpdater.java @@ -62,9 +62,14 @@ final class OtaUpdater { * signed with a different key, and Android refuses to install over a platform-signed * build with anything but the same signature. Pointing here means the check finds * nothing until this repository publishes a release, which is the intended quiet. + * + * Disabled for now: the .invalid TLD (RFC 2606) never resolves, so every check fails + * with "Update check failed" instead of offering upstream's builds over this one. To + * re-enable, point this at a fork's releases API, e.g. + * "https://api.github.com/repos///releases". */ private static final String RELEASES_API = - "https://api.github.com/repos/fatihdonmezdev/FatihsMG4/releases"; + "https://updates.invalid/repos/fatihdonmezdev/FatihsMG4/releases"; /** * Hosts an update may come from. The githubusercontent entries are the CDNs GitHub From 3da3fa9f49e960afe25bf9783fbdbe56810acbf0 Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Sat, 17 Oct 2026 13:02:18 +0200 Subject: [PATCH 06/79] Rename app to "Nils' MG4" Changes the launcher label, header brand caption, About title, logo description and OTA download labels. Package ID, APK file names, log tags and the repository link are unchanged. Co-Authored-By: Claude Opus 5.5 --- app/src/main/res/values/strings.xml | 6 +++--- app/src/main/res/values/strings_design.xml | 2 +- app/src/unstable/java/com/evsuite/abrp/OtaUpdater.java | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index be15a7f..4f9a0ce 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,5 +1,5 @@ - FatihsMG4 + Nils\' MG4 ABRP @@ -126,12 +126,12 @@ Downloading and verifying… Downloading… %1$d%% About - About Fatih\'sMG4 + About Nils\' MG4 Version %1$s Fork of malys / EVABRPUploader\nand Leon Kernan / ABRP_Uploader github.com/fatihdonmezdev/FatihsMG4 MG4 EH32 · SWI69 - FatihsMG4 logo + Nils\' MG4 logo QR code for the project repository unknown Close diff --git a/app/src/main/res/values/strings_design.xml b/app/src/main/res/values/strings_design.xml index 161df82..8cce1d3 100644 --- a/app/src/main/res/values/strings_design.xml +++ b/app/src/main/res/values/strings_design.xml @@ -3,7 +3,7 @@ Battery VHAL Probe MG⁴ - Fatih’s MG4 + Nils’ MG4 ON THE ROAD, ELECTRIC Your drive at a glance. Data from your car, all in one place. diff --git a/app/src/unstable/java/com/evsuite/abrp/OtaUpdater.java b/app/src/unstable/java/com/evsuite/abrp/OtaUpdater.java index f77c44d..67838a4 100644 --- a/app/src/unstable/java/com/evsuite/abrp/OtaUpdater.java +++ b/app/src/unstable/java/com/evsuite/abrp/OtaUpdater.java @@ -363,7 +363,7 @@ final class OtaUpdater { long downloadId = -1L; try { DownloadManager.Request request = new DownloadManager.Request(Uri.parse(update.apkUrl)) - .setTitle("FatihsMG4 " + update.versionName) + .setTitle("Nils' MG4 " + update.versionName) .setDescription("Downloading update") .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) .setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, publicName) @@ -569,7 +569,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("FatihsMG4 update", contentUri)); + install.setClipData(ClipData.newRawUri("Nils' MG4 update", contentUri)); grantToInstallers(context, contentUri); if (install.resolveActivity(context.getPackageManager()) == null) { throw new IllegalStateException("No package installer activity"); From a707b590f7d9935a08714bc355bb2840c530b75f Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Sat, 17 Oct 2026 13:08:09 +0200 Subject: [PATCH 07/79] Rename package and APKs to NilsMG4, credit Fatih, add SPECS.md - applicationId com.evsuite.abrp -> com.stinnesbeck.nilsmg4 (the car build is .unstable); the Java namespace is unchanged. Installs next to upstream's app rather than over it. - APK names NilsMG4-*.apk, debug builds included; build-car follows. - OTA install-result action, cache/download names, User-Agent and log tags follow the new name. - About credits fatihdonmezdev / FatihsMG4. - SPECS.md journals what changed and what is still to do, starting with pointing the About link and QR code at git.stinnesbeck.com/nils/mg4. Co-Authored-By: Claude Opus 5.5 --- SPECS.md | 55 +++++++++++++++++++ app/build.gradle | 11 ++-- .../evsuite/abrp/ConsumptionCloudClient.java | 2 +- .../com/evsuite/abrp/ConsumptionTracker.java | 2 +- app/src/main/res/values/strings.xml | 2 +- .../java/com/evsuite/abrp/OtaUpdaterTest.java | 24 ++++---- app/src/unstable/AndroidManifest.xml | 2 +- .../abrp/OtaInstallResultReceiver.java | 2 +- .../java/com/evsuite/abrp/OtaUpdater.java | 10 ++-- flake.nix | 10 ++-- settings.gradle | 2 +- 11 files changed, 90 insertions(+), 32 deletions(-) create mode 100644 SPECS.md diff --git a/SPECS.md b/SPECS.md new file mode 100644 index 0000000..aa56f6f --- /dev/null +++ b/SPECS.md @@ -0,0 +1,55 @@ +# SPECS — Nils' MG4 + +Journal for this fork of [fatihdonmezdev/FatihsMG4](https://github.com/fatihdonmezdev/FatihsMG4): +what has been changed, and what is still to do. + +- Our repository: https://git.stinnesbeck.com/nils/mg4 +- Upstream: https://github.com/fatihdonmezdev/FatihsMG4 (itself a fork of malys / EVABRPUploader + and Leon Kernan / ABRP_Uploader). Credited in the app's About dialog. + +## To do + +- [ ] **About dialog link and QR code → https://git.stinnesbeck.com/nils/mg4.** + Both come from `REPOSITORY_URL` in `app/src/main/java/com/evsuite/abrp/MainActivity.java` + and `about_repository` in `app/src/main/res/values/strings.xml`. They still point to Fatih's + GitHub repository. +- [ ] **Re-enable OTA updates against our own releases.** `RELEASES_API` in + `app/src/unstable/java/com/evsuite/abrp/OtaUpdater.java` points at `updates.invalid` on purpose, + so checks fail. The updater speaks the GitHub releases API and only allows GitHub hosts + (`ALLOWED_HOSTS`). For git.stinnesbeck.com it needs the host on the allowlist, and the + response parsing checked against that server's releases API. Release assets must be named + `NilsMG4-unstable-.apk` with a `.sha256` sidecar (`build-car` writes both). +- [ ] **Fix the unit tests.** They don't compile upstream: `AbrpApiTest`, `TelemetryPayloadTest` + and others still use `AbrpApi`/`TelemetryPayload`, which upstream commit `ea8032a` deleted. +- [ ] **Charging currency.** Prices and costs are shown in Turkish lira (`₺/kWh`, `₺`) in + `pane_charging.xml` and `MainActivity.java`. +- [ ] **Cloud consumption sync.** `CONSUMPTION_INSTALLATION_ID` in `app/build.gradle` is a fixed + UUID inherited from upstream. Set our own before configuring `CONSUMPTION_API_URL` / + `CONSUMPTION_API_TOKEN`, so our data cannot land under upstream's id. +- [ ] **Translate the rest.** The `web/` dashboard (Next.js) and docs (`CHANGELOG.md`, `OTA.md`'s + message table, comments in `mise.toml`/`app/build.gradle`) are still Turkish or French. +- [ ] **Docs still use the old package ID.** `README.md`, `OTA.md`, `CHANGELOG.md`, `mise.toml` and + `docs/MG4_PLATFORM_INSTALL.md` refer to `com.evsuite.abrp(.unstable)` and `FatihsMG4-*.apk`. +- [ ] **Move hardcoded UI strings into `strings.xml`.** Many are inline in Java and layouts. + +## Done + +- 2026-10-17 — Translated the app UI from Turkish to English. +- 2026-10-17 — Nix flake: `nix develop` provides JDK 21 and the Android SDK. `build-car` builds the + unstable APK and signs it with the AOSP test platform key, which the MG4 head unit uses + (cert SHA-256 `c8a2e9bc…192ab8`). +- 2026-10-17 — OTA update checks disabled by pointing at an unresolvable host (see To do). +- 2026-10-17 — Renamed to "Nils' MG4": package ID `com.stinnesbeck.nilsmg4` (`.unstable` for the car + build), APK names `NilsMG4-*.apk`. It installs next to the old FatihsMG4 app instead of replacing + it, and starts with empty settings and history. + +## Installing on the car + +``` +nix develop +build-car +adb push build/car/NilsMG4-unstable-.apk /data/local/tmp/update.apk +adb shell "pm install -r -f -d -t /data/local/tmp/update.apk" +``` + +To remove the old upstream app: `adb uninstall com.evsuite.abrp.unstable`. diff --git a/app/build.gradle b/app/build.gradle index 8a597c2..a82f37f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -31,7 +31,7 @@ android { } defaultConfig { - applicationId "com.evsuite.abrp" + applicationId "com.stinnesbeck.nilsmg4" minSdk 27 targetSdk 36 versionCode 56 @@ -113,7 +113,7 @@ android { } // Version publiée par la CI pour composer le nom de l'asset APK : ce nom DOIT être -// comparable par l'updater ("FatihsMG4-unstable-1.0.42.apk"). Le tag de la +// comparable par l'updater ("NilsMG4-unstable-1.0.42.apk"). Le tag de la // pré-release est la constante "unstable", écrasée à chaque build. tasks.register('printUnstableVersion') { doLast { @@ -129,8 +129,11 @@ 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" - ? "FatihsMG4-unstable.apk" - : "FatihsMG4-${variant.flavorName}-${output.versionName.get()}.apk") + ? "NilsMG4-unstable.apk" + : "NilsMG4-${variant.flavorName}-${output.versionName.get()}.apk") + } else { + // Debug builds: NilsMG4-stable-debug.apk, NilsMG4-unstable-debug.apk. + output.outputFileName.set("NilsMG4-${variant.flavorName}-${variant.buildType}.apk") } } } diff --git a/app/src/main/java/com/evsuite/abrp/ConsumptionCloudClient.java b/app/src/main/java/com/evsuite/abrp/ConsumptionCloudClient.java index 4e855e2..aec491d 100644 --- a/app/src/main/java/com/evsuite/abrp/ConsumptionCloudClient.java +++ b/app/src/main/java/com/evsuite/abrp/ConsumptionCloudClient.java @@ -28,7 +28,7 @@ import java.util.List; * picks up right where the old one left off instead of showing zeroes. */ final class ConsumptionCloudClient { - private static final String TAG = "FatihsMG4.Mongo"; + private static final String TAG = "NilsMG4.Mongo"; private static final long CONSUMPTION_SYNC_INTERVAL_MS = 2 * 60_000L; private static final long CHARGE_RETRY_INTERVAL_MS = 5 * 60_000L; private static final long HISTORY_FETCH_INTERVAL_MS = 5 * 60_000L; diff --git a/app/src/main/java/com/evsuite/abrp/ConsumptionTracker.java b/app/src/main/java/com/evsuite/abrp/ConsumptionTracker.java index 2f9395a..c00ddcc 100644 --- a/app/src/main/java/com/evsuite/abrp/ConsumptionTracker.java +++ b/app/src/main/java/com/evsuite/abrp/ConsumptionTracker.java @@ -216,7 +216,7 @@ final class ConsumptionTracker { if (cloudLifetime.km > local.km) { lifetime.set(cloudLifetime.km, cloudLifetime.kwh, cloudLifetime.hours, cloudLifetime.soc); persist(); - Log.i("FatihsMG4.Consumption", "Lifetime baseline synced from cloud: " + Log.i("NilsMG4.Consumption", "Lifetime baseline synced from cloud: " + cloudLifetime.km + " km (was " + local.km + " km)"); } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4f9a0ce..341259d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -128,7 +128,7 @@ About About Nils\' MG4 Version %1$s - Fork of malys / EVABRPUploader\nand Leon Kernan / ABRP_Uploader + Fork of fatihdonmezdev / FatihsMG4,\nmalys / EVABRPUploader\nand Leon Kernan / ABRP_Uploader github.com/fatihdonmezdev/FatihsMG4 MG4 EH32 · SWI69 Nils\' MG4 logo diff --git a/app/src/testUnstable/java/com/evsuite/abrp/OtaUpdaterTest.java b/app/src/testUnstable/java/com/evsuite/abrp/OtaUpdaterTest.java index 4211434..e318cfd 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("FatihsMG4-unstable-1.0.42.apk")); - assertEquals("1.0.100", OtaUpdater.versionFromAssetName("FatihsMG4-unstable-1.0.100.APK")); + assertEquals("1.0.42", OtaUpdater.versionFromAssetName("NilsMG4-unstable-1.0.42.apk")); + assertEquals("1.0.100", OtaUpdater.versionFromAssetName("NilsMG4-unstable-1.0.100.APK")); } @Test public void assetNameWithoutAVersionIsIgnored() { - assertNull(OtaUpdater.versionFromAssetName("FatihsMG4-unstable.apk")); + assertNull(OtaUpdater.versionFromAssetName("NilsMG4-unstable.apk")); assertNull(OtaUpdater.versionFromAssetName("unstable")); } @@ -125,14 +125,14 @@ public class OtaUpdaterTest { public void sha256SidecarFormatsAreParsed() { String hash = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; assertEquals(hash, OtaUpdater.parseExpectedSha256(hash, - "FatihsMG4-unstable-2.2.7.apk")); + "NilsMG4-unstable-2.2.7.apk")); assertEquals(hash, OtaUpdater.parseExpectedSha256( - hash + " *FatihsMG4-unstable-2.2.7.apk\n", - "FatihsMG4-unstable-2.2.7.apk")); + hash + " *NilsMG4-unstable-2.2.7.apk\n", + "NilsMG4-unstable-2.2.7.apk")); assertNull(OtaUpdater.parseExpectedSha256(hash + " another.apk", - "FatihsMG4-unstable-2.2.7.apk")); + "NilsMG4-unstable-2.2.7.apk")); assertNull(OtaUpdater.parseExpectedSha256("not-a-hash", - "FatihsMG4-unstable-2.2.7.apk")); + "NilsMG4-unstable-2.2.7.apk")); } @Test @@ -150,14 +150,14 @@ public class OtaUpdaterTest { .put(release(true, asset("2.2.5"))); OtaUpdater.Update update = OtaUpdater.selectUpdate(releases, "2.2.2.0-unstable"); assertEquals("2.2.6", update.versionName); - assertTrue(update.apkUrl.endsWith("FatihsMG4-unstable-2.2.6.apk")); + assertTrue(update.apkUrl.endsWith("NilsMG4-unstable-2.2.6.apk")); } @Test public void stableDraftForeignAndUnversionedAssetsCannotWin() throws Exception { JSONObject foreign = asset("9.0.0").put("browser_download_url", "https://example.com/app.apk"); - JSONObject unversioned = asset("9.0.0").put("name", "FatihsMG4-unstable.apk"); - JSONObject stableName = asset("9.0.0").put("name", "FatihsMG4-stable-9.0.0.apk"); + JSONObject unversioned = asset("9.0.0").put("name", "NilsMG4-unstable.apk"); + JSONObject stableName = asset("9.0.0").put("name", "NilsMG4-stable-9.0.0.apk"); JSONArray releases = new JSONArray() .put(release(false, asset("9.0.0"))) .put(release(true, asset("9.0.0")).put("draft", true)) @@ -216,7 +216,7 @@ public class OtaUpdaterTest { } private static JSONObject asset(String version) throws Exception { - String name = "FatihsMG4-unstable-" + version + ".apk"; + String name = "NilsMG4-unstable-" + version + ".apk"; return new JSONObject().put("name", name).put("browser_download_url", "https://github.com/fatihdonmezdev/FatihsMG4/releases/download/unstable/" + name); } diff --git a/app/src/unstable/AndroidManifest.xml b/app/src/unstable/AndroidManifest.xml index 1e4f316..54d3c67 100644 --- a/app/src/unstable/AndroidManifest.xml +++ b/app/src/unstable/AndroidManifest.xml @@ -27,7 +27,7 @@ android:name=".OtaInstallResultReceiver" android:exported="false"> - + diff --git a/app/src/unstable/java/com/evsuite/abrp/OtaInstallResultReceiver.java b/app/src/unstable/java/com/evsuite/abrp/OtaInstallResultReceiver.java index 3a4611a..2aabac1 100644 --- a/app/src/unstable/java/com/evsuite/abrp/OtaInstallResultReceiver.java +++ b/app/src/unstable/java/com/evsuite/abrp/OtaInstallResultReceiver.java @@ -19,7 +19,7 @@ import android.widget.Toast; */ public final class OtaInstallResultReceiver extends BroadcastReceiver { - static final String ACTION_INSTALL_RESULT = "com.evsuite.abrp.OTA_INSTALL_RESULT"; + static final String ACTION_INSTALL_RESULT = "com.stinnesbeck.nilsmg4.OTA_INSTALL_RESULT"; private static final String TAG = "EVABRP.Update"; diff --git a/app/src/unstable/java/com/evsuite/abrp/OtaUpdater.java b/app/src/unstable/java/com/evsuite/abrp/OtaUpdater.java index 67838a4..276f60f 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 = "FatihsMG4-ota-"; + private static final String CACHE_PREFIX = "NilsMG4-ota-"; /** * Pre-releases live here; the unstable channel tracks them. @@ -167,7 +167,7 @@ final class OtaUpdater { /** * Version carried by an unstable asset name: - * "FatihsMG4-unstable-1.0.42.apk" -> "1.0.42". + * "NilsMG4-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. @@ -203,7 +203,7 @@ final class OtaUpdater { try { conn.setInstanceFollowRedirects(false); conn.setRequestProperty("Accept", "application/vnd.github.v3+json"); - conn.setRequestProperty("User-Agent", "FatihsMG4-Android"); + conn.setRequestProperty("User-Agent", "NilsMG4-Android"); conn.setConnectTimeout(TIMEOUT_MS); conn.setReadTimeout(TIMEOUT_MS); int status = conn.getResponseCode(); @@ -302,7 +302,7 @@ final class OtaUpdater { connection.setInstanceFollowRedirects(false); connection.setConnectTimeout(TIMEOUT_MS); connection.setReadTimeout(TIMEOUT_MS); - connection.setRequestProperty("User-Agent", "FatihsMG4-Android"); + connection.setRequestProperty("User-Agent", "NilsMG4-Android"); try { int status = connection.getResponseCode(); if (status >= 300 && status <= 399) { @@ -584,7 +584,7 @@ final class OtaUpdater { if (downloads == null || (!downloads.isDirectory() && !downloads.mkdirs())) { throw new IllegalStateException("Downloads directory unavailable"); } - File destination = new File(downloads, "FatihsMG4-OTA-update.apk"); + File destination = new File(downloads, "NilsMG4-OTA-update.apk"); try (FileInputStream input = new FileInputStream(source); FileOutputStream output = new FileOutputStream(destination, false)) { byte[] buffer = new byte[64 * 1024]; diff --git a/flake.nix b/flake.nix index 70fead2..b0b4a1f 100644 --- a/flake.nix +++ b/flake.nix @@ -1,5 +1,5 @@ { - description = "FatihsMG4 — Android Automotive build environment"; + description = "NilsMG4 — Android Automotive build environment"; inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; @@ -57,7 +57,7 @@ root=$(git rev-parse --show-toplevel 2>/dev/null || pwd) cd "$root" if [ ! -f app/build.gradle ]; then - echo "build-car: run this from the FatihsMG4 checkout" >&2 + echo "build-car: run this from the NilsMG4 checkout" >&2 exit 1 fi @@ -81,10 +81,10 @@ ./gradlew assembleUnstableDebug "$@" version=$(./gradlew -q printUnstableVersion "$@" | tail -1) - in=app/build/outputs/apk/unstable/debug/app-unstable-debug.apk + in=app/build/outputs/apk/unstable/debug/NilsMG4-unstable-debug.apk mkdir -p build/car aligned=build/car/aligned.apk - out="build/car/FatihsMG4-unstable-$version.apk" + out="build/car/NilsMG4-unstable-$version.apk" "${buildTools}/zipalign" -f -p 4 "$in" "$aligned" # Options come before the input APK; apksigner rejects the other order. @@ -122,7 +122,7 @@ shellHook = '' echo "sdk.dir=$ANDROID_HOME" > local.properties - echo "FatihsMG4 dev shell — JDK $(java -version 2>&1 | head -1 | cut -d'"' -f2), SDK at $ANDROID_HOME" + echo "NilsMG4 dev shell — JDK $(java -version 2>&1 | head -1 | cut -d'"' -f2), SDK at $ANDROID_HOME" echo " ./gradlew assembleStableDebug # debug APK" echo " ./gradlew assembleUnstableDebug # build that can update itself (OTA)" echo " build-car # unstable build signed for the car" diff --git a/settings.gradle b/settings.gradle index 98c922f..c8d83b5 100644 --- a/settings.gradle +++ b/settings.gradle @@ -22,7 +22,7 @@ dependencyResolutionManagement { } } -rootProject.name = "FatihsMG4" +rootProject.name = "NilsMG4" include ':app' include ':evhardware' project(':evhardware').projectDir = file('EVHardware/lib') From b2a5b32b08a14fcf7c6de6e58c1d139fd06c52cf Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Sat, 17 Oct 2026 13:26:32 +0200 Subject: [PATCH 08/79] Add on-screen diagnostics to the Logs tab Without adb there was no way to see why vehicle values stay blank: refreshVehicle() swallowed every exception. The Logs tab now shows the package, version, UID (and whether it is the system UID), signer SHA-256, firmware, car permission grants, the last snapshot and read error, and the newest 200 entries of the vehicle library's AppLogger. Co-Authored-By: Claude Opus 5.5 --- SPECS.md | 2 + .../java/com/evsuite/abrp/MainActivity.java | 102 +++++++++++++++++- app/src/main/res/layout/pane_log.xml | 3 + app/src/main/res/values/strings.xml | 2 + 4 files changed, 107 insertions(+), 2 deletions(-) diff --git a/SPECS.md b/SPECS.md index aa56f6f..b067264 100644 --- a/SPECS.md +++ b/SPECS.md @@ -42,6 +42,8 @@ what has been changed, and what is still to do. - 2026-10-17 — Renamed to "Nils' MG4": package ID `com.stinnesbeck.nilsmg4` (`.unstable` for the car build), APK names `NilsMG4-*.apk`. It installs next to the old FatihsMG4 app instead of replacing it, and starts with empty settings and history. +- 2026-10-17 — Diagnostics section on the Logs tab: UID, signer, car permissions, last vehicle + read and its error, and the vehicle library's in-app log. For debugging without adb. ## Installing on the car diff --git a/app/src/main/java/com/evsuite/abrp/MainActivity.java b/app/src/main/java/com/evsuite/abrp/MainActivity.java index 475686c..8d96209 100644 --- a/app/src/main/java/com/evsuite/abrp/MainActivity.java +++ b/app/src/main/java/com/evsuite/abrp/MainActivity.java @@ -102,6 +102,16 @@ public class MainActivity extends AppCompatActivity { private ChargeSessionTracker chargeSessionTracker; private ChargingGraphView chargeGraph; + /** + * What the vehicle reads last did, for the diagnostics section of the Logs page. A failed + * read leaves the cells showing a dash, which looks the same as a signal the car does not + * publish; this is the only on-screen trace of why. + */ + private TextView diagnosticsText; + private volatile EnergySnapshot lastVehicleSnapshot; + private volatile long lastVehicleReadOkMs; + private volatile String lastVehicleReadError; + /** * Inflates a page with no parent, then gives it the MATCH_PARENT/MATCH_PARENT layout * params ViewPager2 requires of its items — inflating detached leaves them null, and @@ -168,6 +178,7 @@ public class MainActivity extends AppCompatActivity { } ((TextView) findViewById(R.id.header_title)).setText(PAGE_TITLES[position]); ((TextView) findViewById(R.id.header_subtitle)).setText(PAGE_SUBTITLES[position]); + if (panes[position] == logPane) refreshDiagnostics(); } /** Refreshes state + call log while the screen is visible. */ @@ -216,6 +227,9 @@ public class MainActivity extends AppCompatActivity { statusText = servicePane.findViewById(R.id.status_text); callLogText = logPane.findViewById(R.id.call_log_text); logPane.findViewById(R.id.flush_now_button).setOnClickListener(v -> manualFlush()); + diagnosticsText = logPane.findViewById(R.id.diagnostics_text); + logPane.findViewById(R.id.diagnostics_refresh_button) + .setOnClickListener(v -> refreshDiagnostics()); setUpPager(); findViewById(R.id.about_button).setOnClickListener(v -> showAbout()); @@ -407,6 +421,8 @@ public class MainActivity extends AppCompatActivity { } // The timestamp is passed explicitly: its Kotlin default is not visible from Java. EnergySnapshot s = vehicleReader.read(System.currentTimeMillis()); + lastVehicleSnapshot = s; + lastVehicleReadOkMs = System.currentTimeMillis(); ConsumptionTracker.get(this).sample(s); refreshCharging(s); @@ -423,8 +439,12 @@ public class MainActivity extends AppCompatActivity { vehTireFr.setText(fmt(s.getTirePressures().getFrontRightKpa(), "%.0f kPa")); vehTireRl.setText(fmt(s.getTirePressures().getRearLeftKpa(), "%.0f kPa")); vehTireRr.setText(fmt(s.getTirePressures().getRearRightKpa(), "%.0f kPa")); - } catch (Throwable ignored) { - // Leave the last good reading on screen. + } catch (Throwable t) { + // Leave the last good reading on screen, but keep the reason for the diagnostics. + String trace = android.util.Log.getStackTraceString(t); + lastVehicleReadError = new java.text.SimpleDateFormat("HH:mm:ss", java.util.Locale.US) + .format(new java.util.Date()) + " " + + (trace.length() > 2000 ? trace.substring(0, 2000) + "…" : trace); } } @@ -749,6 +769,84 @@ public class MainActivity extends AppCompatActivity { callLogText.setText(sb.toString()); } + // ---------- Diagnostics ---------- + + /** + * Fills the diagnostics section: who the app runs as, how it is signed, which car + * permissions it holds, what the last vehicle read returned, and the in-app log of the + * vehicle library. On the head unit there is no adb, so this is what can be photographed. + */ + private void refreshDiagnostics() { + if (diagnosticsText == null) return; + java.text.SimpleDateFormat time = new java.text.SimpleDateFormat("HH:mm:ss", java.util.Locale.US); + StringBuilder sb = new StringBuilder(); + int uid = android.os.Process.myUid(); + sb.append("Package ").append(getPackageName()).append('\n'); + sb.append("Version ").append(BuildConfig.VERSION_NAME).append('\n'); + sb.append("UID ").append(uid) + .append(uid == android.os.Process.SYSTEM_UID ? " (system — OK)" : " (NOT system)").append('\n'); + sb.append("Firmware ").append(safe(() -> com.evsuite.hardware.FirmwareInfo.INSTANCE.getDetectedString())) + .append('\n'); + PackageManager pm = getPackageManager(); + try { + @SuppressWarnings("deprecation") + android.content.pm.PackageInfo info = pm.getPackageInfo(getPackageName(), + PackageManager.GET_SIGNATURES | PackageManager.GET_PERMISSIONS); + if (info.signatures != null && info.signatures.length > 0) { + byte[] digest = java.security.MessageDigest.getInstance("SHA-256") + .digest(info.signatures[0].toByteArray()); + StringBuilder hex = new StringBuilder(); + for (byte b : digest) hex.append(String.format(java.util.Locale.US, "%02x", b)); + sb.append("Signer ").append(hex).append('\n'); + } + if (info.requestedPermissions != null) { + sb.append("Permissions\n"); + for (String p : info.requestedPermissions) { + if (!p.startsWith("android.car.")) continue; + boolean granted = checkSelfPermission(p) == PackageManager.PERMISSION_GRANTED; + sb.append(" ").append(granted ? "granted " : "DENIED ") + .append(p.substring(p.lastIndexOf('.') + 1)).append('\n'); + } + } + } catch (Exception e) { + sb.append("Package info unavailable: ").append(e).append('\n'); + } + + sb.append('\n').append("Last vehicle read ") + .append(lastVehicleReadOkMs > 0 ? time.format(new java.util.Date(lastVehicleReadOkMs)) : "never") + .append('\n'); + EnergySnapshot s = lastVehicleSnapshot; + if (s != null) { + sb.append(" SOC ").append(s.getSocPercent()) + .append(" range ").append(s.getRangeKm()) + .append(" speed ").append(s.getSpeedKmh()) + .append(" power ").append(s.getBatteryPowerKw()).append('\n') + .append(" odometer ").append(s.getOdometerKm()) + .append(" charge status ").append(s.getChargingStatus()) + .append(" port ").append(s.getChargePortConnected()).append('\n'); + } + String error = lastVehicleReadError; + if (error != null) sb.append("Last read error ").append(error).append('\n'); + + java.util.List entries = + com.evsuite.hardware.AppLogger.INSTANCE.getEntries(); + int shown = Math.min(entries.size(), 200); + sb.append('\n').append("Vehicle library log (newest first, ").append(shown) + .append(" of ").append(entries.size()).append(")\n"); + for (int i = entries.size() - 1; i >= entries.size() - shown; i--) { + com.evsuite.hardware.AppLogger.Entry e = entries.get(i); + sb.append(e.getTime()).append(' ').append(e.getLevel().name().charAt(0)).append('/') + .append(e.getTag()).append(": ").append(e.getMsg()).append('\n'); + } + diagnosticsText.setText(sb.toString()); + } + + private interface StringSupplier { String get() throws Exception; } + + private static String safe(StringSupplier supplier) { + try { return supplier.get(); } catch (Throwable t) { return "unavailable (" + t + ")"; } + } + // ---------- Service status ---------- private void refreshStatus() { diff --git a/app/src/main/res/layout/pane_log.xml b/app/src/main/res/layout/pane_log.xml index 31ea5d8..7049563 100644 --- a/app/src/main/res/layout/pane_log.xml +++ b/app/src/main/res/layout/pane_log.xml @@ -4,5 +4,8 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 341259d..11039fa 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -107,6 +107,8 @@ Recent uploads No upload attempted yet. + Diagnostics + Refresh diagnostics Upload now Starting… Running — waiting for first upload From 10029623e318215dbaf6fbfed7d538ada670812d Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Sat, 17 Oct 2026 13:38:36 +0200 Subject: [PATCH 09/79] Remove on-screen diagnostics from the Logs tab They served their purpose: vehicle values show on the car. This reverts commit b2a5b32. Co-Authored-By: Claude Opus 5.5 --- SPECS.md | 2 - .../java/com/evsuite/abrp/MainActivity.java | 102 +----------------- app/src/main/res/layout/pane_log.xml | 3 - app/src/main/res/values/strings.xml | 2 - 4 files changed, 2 insertions(+), 107 deletions(-) diff --git a/SPECS.md b/SPECS.md index b067264..aa56f6f 100644 --- a/SPECS.md +++ b/SPECS.md @@ -42,8 +42,6 @@ what has been changed, and what is still to do. - 2026-10-17 — Renamed to "Nils' MG4": package ID `com.stinnesbeck.nilsmg4` (`.unstable` for the car build), APK names `NilsMG4-*.apk`. It installs next to the old FatihsMG4 app instead of replacing it, and starts with empty settings and history. -- 2026-10-17 — Diagnostics section on the Logs tab: UID, signer, car permissions, last vehicle - read and its error, and the vehicle library's in-app log. For debugging without adb. ## Installing on the car diff --git a/app/src/main/java/com/evsuite/abrp/MainActivity.java b/app/src/main/java/com/evsuite/abrp/MainActivity.java index 8d96209..475686c 100644 --- a/app/src/main/java/com/evsuite/abrp/MainActivity.java +++ b/app/src/main/java/com/evsuite/abrp/MainActivity.java @@ -102,16 +102,6 @@ public class MainActivity extends AppCompatActivity { private ChargeSessionTracker chargeSessionTracker; private ChargingGraphView chargeGraph; - /** - * What the vehicle reads last did, for the diagnostics section of the Logs page. A failed - * read leaves the cells showing a dash, which looks the same as a signal the car does not - * publish; this is the only on-screen trace of why. - */ - private TextView diagnosticsText; - private volatile EnergySnapshot lastVehicleSnapshot; - private volatile long lastVehicleReadOkMs; - private volatile String lastVehicleReadError; - /** * Inflates a page with no parent, then gives it the MATCH_PARENT/MATCH_PARENT layout * params ViewPager2 requires of its items — inflating detached leaves them null, and @@ -178,7 +168,6 @@ public class MainActivity extends AppCompatActivity { } ((TextView) findViewById(R.id.header_title)).setText(PAGE_TITLES[position]); ((TextView) findViewById(R.id.header_subtitle)).setText(PAGE_SUBTITLES[position]); - if (panes[position] == logPane) refreshDiagnostics(); } /** Refreshes state + call log while the screen is visible. */ @@ -227,9 +216,6 @@ public class MainActivity extends AppCompatActivity { statusText = servicePane.findViewById(R.id.status_text); callLogText = logPane.findViewById(R.id.call_log_text); logPane.findViewById(R.id.flush_now_button).setOnClickListener(v -> manualFlush()); - diagnosticsText = logPane.findViewById(R.id.diagnostics_text); - logPane.findViewById(R.id.diagnostics_refresh_button) - .setOnClickListener(v -> refreshDiagnostics()); setUpPager(); findViewById(R.id.about_button).setOnClickListener(v -> showAbout()); @@ -421,8 +407,6 @@ public class MainActivity extends AppCompatActivity { } // The timestamp is passed explicitly: its Kotlin default is not visible from Java. EnergySnapshot s = vehicleReader.read(System.currentTimeMillis()); - lastVehicleSnapshot = s; - lastVehicleReadOkMs = System.currentTimeMillis(); ConsumptionTracker.get(this).sample(s); refreshCharging(s); @@ -439,12 +423,8 @@ public class MainActivity extends AppCompatActivity { vehTireFr.setText(fmt(s.getTirePressures().getFrontRightKpa(), "%.0f kPa")); vehTireRl.setText(fmt(s.getTirePressures().getRearLeftKpa(), "%.0f kPa")); vehTireRr.setText(fmt(s.getTirePressures().getRearRightKpa(), "%.0f kPa")); - } catch (Throwable t) { - // Leave the last good reading on screen, but keep the reason for the diagnostics. - String trace = android.util.Log.getStackTraceString(t); - lastVehicleReadError = new java.text.SimpleDateFormat("HH:mm:ss", java.util.Locale.US) - .format(new java.util.Date()) + " " - + (trace.length() > 2000 ? trace.substring(0, 2000) + "…" : trace); + } catch (Throwable ignored) { + // Leave the last good reading on screen. } } @@ -769,84 +749,6 @@ public class MainActivity extends AppCompatActivity { callLogText.setText(sb.toString()); } - // ---------- Diagnostics ---------- - - /** - * Fills the diagnostics section: who the app runs as, how it is signed, which car - * permissions it holds, what the last vehicle read returned, and the in-app log of the - * vehicle library. On the head unit there is no adb, so this is what can be photographed. - */ - private void refreshDiagnostics() { - if (diagnosticsText == null) return; - java.text.SimpleDateFormat time = new java.text.SimpleDateFormat("HH:mm:ss", java.util.Locale.US); - StringBuilder sb = new StringBuilder(); - int uid = android.os.Process.myUid(); - sb.append("Package ").append(getPackageName()).append('\n'); - sb.append("Version ").append(BuildConfig.VERSION_NAME).append('\n'); - sb.append("UID ").append(uid) - .append(uid == android.os.Process.SYSTEM_UID ? " (system — OK)" : " (NOT system)").append('\n'); - sb.append("Firmware ").append(safe(() -> com.evsuite.hardware.FirmwareInfo.INSTANCE.getDetectedString())) - .append('\n'); - PackageManager pm = getPackageManager(); - try { - @SuppressWarnings("deprecation") - android.content.pm.PackageInfo info = pm.getPackageInfo(getPackageName(), - PackageManager.GET_SIGNATURES | PackageManager.GET_PERMISSIONS); - if (info.signatures != null && info.signatures.length > 0) { - byte[] digest = java.security.MessageDigest.getInstance("SHA-256") - .digest(info.signatures[0].toByteArray()); - StringBuilder hex = new StringBuilder(); - for (byte b : digest) hex.append(String.format(java.util.Locale.US, "%02x", b)); - sb.append("Signer ").append(hex).append('\n'); - } - if (info.requestedPermissions != null) { - sb.append("Permissions\n"); - for (String p : info.requestedPermissions) { - if (!p.startsWith("android.car.")) continue; - boolean granted = checkSelfPermission(p) == PackageManager.PERMISSION_GRANTED; - sb.append(" ").append(granted ? "granted " : "DENIED ") - .append(p.substring(p.lastIndexOf('.') + 1)).append('\n'); - } - } - } catch (Exception e) { - sb.append("Package info unavailable: ").append(e).append('\n'); - } - - sb.append('\n').append("Last vehicle read ") - .append(lastVehicleReadOkMs > 0 ? time.format(new java.util.Date(lastVehicleReadOkMs)) : "never") - .append('\n'); - EnergySnapshot s = lastVehicleSnapshot; - if (s != null) { - sb.append(" SOC ").append(s.getSocPercent()) - .append(" range ").append(s.getRangeKm()) - .append(" speed ").append(s.getSpeedKmh()) - .append(" power ").append(s.getBatteryPowerKw()).append('\n') - .append(" odometer ").append(s.getOdometerKm()) - .append(" charge status ").append(s.getChargingStatus()) - .append(" port ").append(s.getChargePortConnected()).append('\n'); - } - String error = lastVehicleReadError; - if (error != null) sb.append("Last read error ").append(error).append('\n'); - - java.util.List entries = - com.evsuite.hardware.AppLogger.INSTANCE.getEntries(); - int shown = Math.min(entries.size(), 200); - sb.append('\n').append("Vehicle library log (newest first, ").append(shown) - .append(" of ").append(entries.size()).append(")\n"); - for (int i = entries.size() - 1; i >= entries.size() - shown; i--) { - com.evsuite.hardware.AppLogger.Entry e = entries.get(i); - sb.append(e.getTime()).append(' ').append(e.getLevel().name().charAt(0)).append('/') - .append(e.getTag()).append(": ").append(e.getMsg()).append('\n'); - } - diagnosticsText.setText(sb.toString()); - } - - private interface StringSupplier { String get() throws Exception; } - - private static String safe(StringSupplier supplier) { - try { return supplier.get(); } catch (Throwable t) { return "unavailable (" + t + ")"; } - } - // ---------- Service status ---------- private void refreshStatus() { diff --git a/app/src/main/res/layout/pane_log.xml b/app/src/main/res/layout/pane_log.xml index 7049563..31ea5d8 100644 --- a/app/src/main/res/layout/pane_log.xml +++ b/app/src/main/res/layout/pane_log.xml @@ -4,8 +4,5 @@ - - - diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 11039fa..341259d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -107,8 +107,6 @@ Recent uploads No upload attempted yet. - Diagnostics - Refresh diagnostics Upload now Starting… Running — waiting for first upload From de2428786aa844ed6ff7b29b2f4cbac81716046c Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Sat, 17 Oct 2026 13:38:47 +0200 Subject: [PATCH 10/79] Journal diagnostics and upstream ABRP removal in SPECS.md Co-Authored-By: Claude Opus 5.5 --- SPECS.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/SPECS.md b/SPECS.md index aa56f6f..c06b748 100644 --- a/SPECS.md +++ b/SPECS.md @@ -19,6 +19,11 @@ what has been changed, and what is still to do. (`ALLOWED_HOSTS`). For git.stinnesbeck.com it needs the host on the allowlist, and the response parsing checked against that server's releases API. Release assets must be named `NilsMG4-unstable-.apk` with a `.sha256` sidecar (`build-car` writes both). +- [ ] **ABRP upload is gone upstream.** Upstream commit `ea8032a` ("chore", 2026-10-09) removed the + ABRP tab, the config-file import (`ConfigImport`), the upload (`AbrpApi`, `TelemetryPayload`, + `UploadCadence`), `SecurePrefs` and `HeadUnitWeather`. The app now only tracks consumption + and syncs it to the MongoDB API. Leftover `strings.xml` entries (`btn_import`, `import_*`, + `hint_api_key`, …) belong to it. Decide whether to restore it. - [ ] **Fix the unit tests.** They don't compile upstream: `AbrpApiTest`, `TelemetryPayloadTest` and others still use `AbrpApi`/`TelemetryPayload`, which upstream commit `ea8032a` deleted. - [ ] **Charging currency.** Prices and costs are shown in Turkish lira (`₺/kWh`, `₺`) in @@ -42,6 +47,8 @@ what has been changed, and what is still to do. - 2026-10-17 — Renamed to "Nils' MG4": package ID `com.stinnesbeck.nilsmg4` (`.unstable` for the car build), APK names `NilsMG4-*.apk`. It installs next to the old FatihsMG4 app instead of replacing it, and starts with empty settings and history. +- 2026-10-17 — Temporary on-screen diagnostics (b2a5b32) to debug blank values without adb; + removed again (1002962) once values showed on the car. Revert 1002962 to bring them back. ## Installing on the car From d4fb4ba3af01855dd1aa658caf6845e1d72a6701 Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Sat, 17 Oct 2026 13:51:01 +0200 Subject: [PATCH 11/79] Plan the car server (InfluxDB 2, ABRP forwarder, k8s manifests) in SPECS.md Co-Authored-By: Claude Opus 5.5 --- SPECS.md | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/SPECS.md b/SPECS.md index c06b748..a315240 100644 --- a/SPECS.md +++ b/SPECS.md @@ -7,6 +7,68 @@ what has been changed, and what is still to do. - Upstream: https://github.com/fatihdonmezdev/FatihsMG4 (itself a fork of malys / EVABRPUploader and Leon Kernan / ABRP_Uploader). Credited in the app's About dialog. +## Plan: car server at car.stinnesbeck.com + +The car sends everything to our own server. The server stores it and forwards live telemetry to +ABRP, so the ABRP token lives only on the server, never in the APK. + +``` +car ──live telemetry───▶ car.stinnesbeck.com ──▶ ABRP api.iternio.com/1/tlm/send + ──daily totals─────▶ │ + ──charging sessions▶ ▼ + InfluxDB 2 +``` + +### Server — extend `web/` (Next.js) + +- **Storage: MongoDB → InfluxDB 2.** Rewrite `web/src/lib/mongo.ts` / `data.ts` against one bucket + (tag `installationId` on every point): + + | Measurement | Timestamp | Tags | Fields | + |---|---|---|---| + | `telemetry` | sample time | — | soc, power, speed, lat, lon, heading, elevation, is_charging, is_dcfc, is_parked, capacity, soe, odometer, est_battery_range, batt_temp, cabin_temp, ext_temp, hvac_setpoint, voltage, current, tire_pressure_fl/fr/rl/rr | + | `consumption_daily` | the day, 00:00 UTC | — | km, kwh, hours, socDrop, sohPercent | + | `charging_session` | startedAt | sessionId | endedAt, durationSeconds, energyKwh, gridEnergyKwh, pricePerKwh, totalCost, start/endSocPercent | + | `charging_curve` | point time | sessionId | powerKw, socPercent | + + Same measurement + tags + timestamp overwrites, so the car's retries stay idempotent + (MongoDB used unique indexes for that). +- **New endpoint `POST /v1/telemetry`:** a batch of live samples, ingest-token auth, validated + and bounded like the existing endpoints. +- **ABRP forwarder:** after storing, forward the newest sample to `tlm/send` with `ABRP_API_KEY` and + `ABRP_USER_TOKEN` from the environment. Rate-limited; a failed forward is logged but not + retried, since stale live data is useless to ABRP. Off when the token is unset. +- **Existing endpoints stay:** `PUT /v1/consumption/daily`, `PUT /v1/charging-sessions/:id`, + `GET /v1/consumption/history/:id`, the dashboard and `/v1/health`. +- **Self-hosting:** drop the Vercel assumptions, `output: "standalone"`, a `Dockerfile`, and + `ensureIndexes()` replaced by bucket checks at startup. + +### Car app + +- Restore the live telemetry payload and upload cadence from before `ea8032a` (`TelemetryPayload`, + `UploadCadence`, location access), sending to the server instead of ABRP. +- Server URL and ingest token: open question, see below. +- Generate our own `CONSUMPTION_INSTALLATION_ID`. + +### Image and deployment + +- Image: `git.stinnesbeck.com/nils/mg4-server:`, pushed to the forge's registry + (needs a registry login on the build machine). +- `k8s/` with plain manifests: Namespace, InfluxDB StatefulSet + PVC + Service (cluster-internal), + server Deployment + Service + Ingress for `car.stinnesbeck.com` (TLS), and Secret *templates* + with placeholder values only. +- **Nothing is ever applied to the cluster by us.** Nils applies the manifests. + +### Open questions + +- [ ] Run InfluxDB inside the cluster from our manifests (assumed), or use an existing instance? +- [ ] Ingress class and TLS setup (e.g. cert-manager `ClusterIssuer` name), or an existing wildcard + certificate Secret? +- [ ] Car config: bake the server URL and ingest token into the APK at build time (as upstream + does), or enter them in the app, which restores the settings tab with the config-file import? + In the app keeps the token out of the APK. +- [ ] ABRP API key: the open-source default key from upstream, or our own? + ## To do - [ ] **About dialog link and QR code → https://git.stinnesbeck.com/nils/mg4.** From 4ea66cf42407596483a3a93f175cd6be8215610e Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Sat, 17 Oct 2026 14:09:29 +0200 Subject: [PATCH 12/79] Server: InfluxDB 2 storage, live telemetry endpoint, ABRP forwarder - MongoDB replaced by InfluxDB 2 (one bucket, installationId tag). Consumption is stored per chunk at its recordedAt and summed on read, so a chunk the car re-sends from its cache overwrites itself instead of being counted twice ($inc did count it twice). Lifetime is a running total derived on read. - POST /v1/telemetry takes batches of samples in ABRP's tlm fields; every field is bounded, unknown fields are dropped, unread signals stay absent. - After a successful write the newest fresh sample is forwarded to ABRP with ABRP_API_KEY / ABRP_USER_TOKEN from the server environment; rate limited, never queued. The car no longer needs ABRP credentials. - Self-hosting: standalone output, Dockerfile (tests run in the build, runs as UID 10001), .env.example and README for the new setup. - Dev shell gains Node 22 and InfluxDB 2. Verified locally against InfluxDB 2.7.12: all endpoints, retry idempotency, and the dashboard pages. Unit tests: 17 pass. Co-Authored-By: Claude Opus 5.5 --- flake.nix | 2 +- web/.dockerignore | 7 + web/.env.example | 17 +- web/Dockerfile | 27 +++ web/README.md | 84 ++++--- web/next.config.mjs | 2 + web/package-lock.json | 155 +----------- web/package.json | 4 +- .../v1/charging-sessions/[sessionId]/route.ts | 12 +- web/src/app/v1/consumption/daily/route.ts | 37 +-- .../history/[installationId]/route.ts | 23 +- .../[installationId]/charging/route.ts | 9 +- .../[installationId]/consumption/route.ts | 9 +- web/src/app/v1/telemetry/route.ts | 24 ++ web/src/lib/abrp.ts | 88 +++++++ web/src/lib/api.ts | 2 +- web/src/lib/data.ts | 45 ++-- web/src/lib/days.ts | 43 ++++ web/src/lib/influx.ts | 40 ++++ web/src/lib/mongo.ts | 100 -------- web/src/lib/store.ts | 225 ++++++++++++++++++ web/src/lib/types.ts | 96 ++++++++ web/src/lib/validate.ts | 71 +++++- web/test/days.test.ts | 37 +++ web/test/telemetry.test.ts | 56 +++++ web/tsconfig.json | 1 + 26 files changed, 821 insertions(+), 395 deletions(-) create mode 100644 web/.dockerignore create mode 100644 web/Dockerfile create mode 100644 web/src/app/v1/telemetry/route.ts create mode 100644 web/src/lib/abrp.ts create mode 100644 web/src/lib/days.ts create mode 100644 web/src/lib/influx.ts delete mode 100644 web/src/lib/mongo.ts create mode 100644 web/src/lib/store.ts create mode 100644 web/src/lib/types.ts create mode 100644 web/test/days.test.ts create mode 100644 web/test/telemetry.test.ts diff --git a/flake.nix b/flake.nix index b0b4a1f..88d38f0 100644 --- a/flake.nix +++ b/flake.nix @@ -112,7 +112,7 @@ in { default = pkgs.mkShell { - packages = [ jdk android.androidsdk buildCar ]; + packages = [ jdk android.androidsdk buildCar pkgs.nodejs_22 pkgs.influxdb2-server pkgs.influxdb2-cli ]; ANDROID_HOME = sdk; ANDROID_SDK_ROOT = sdk; diff --git a/web/.dockerignore b/web/.dockerignore new file mode 100644 index 0000000..6614541 --- /dev/null +++ b/web/.dockerignore @@ -0,0 +1,7 @@ +node_modules +.next +.env* +!.env.example +*.tsbuildinfo +Dockerfile +.dockerignore diff --git a/web/.env.example b/web/.env.example index 5cd72df..1a81b62 100644 --- a/web/.env.example +++ b/web/.env.example @@ -1,5 +1,18 @@ -MONGODB_URI=mongodb+srv://username:password@cluster.example.mongodb.net/ -MONGODB_DATABASE=fatihsmg4 +# InfluxDB 2 — a token with read/write on the bucket. +INFLUX_URL=http://influxdb:8086 +INFLUX_TOKEN=replace-with-an-influxdb-token +INFLUX_ORG=mg4 +INFLUX_BUCKET=mg4 + +# The car's write token (at least 24 characters): openssl rand -hex 32 INGEST_TOKEN=replace-with-a-long-random-write-token +# Signs dashboard sessions; rotating it signs every browser out: openssl rand -hex 32 SESSION_SECRET=replace-with-a-long-random-session-secret +# Dashboard login, at least 8 characters. DASHBOARD_PASSWORD=replace-with-a-strong-dashboard-password + +# ABRP forwarding; off when either is empty. +ABRP_API_KEY= +ABRP_USER_TOKEN= +# Minimum seconds between two samples forwarded to ABRP. +ABRP_MIN_INTERVAL_SECONDS=5 diff --git a/web/Dockerfile b/web/Dockerfile new file mode 100644 index 0000000..22663d7 --- /dev/null +++ b/web/Dockerfile @@ -0,0 +1,27 @@ +# Self-hosted image of the car server: Next.js standalone output on Node 22. +# Configuration comes entirely from the environment at runtime (see .env.example); +# nothing secret is baked in. + +FROM node:22-alpine AS deps +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci --no-audit --no-fund + +FROM node:22-alpine AS build +WORKDIR /app +ENV NEXT_TELEMETRY_DISABLED=1 +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN npm test && npm run build + +FROM node:22-alpine +WORKDIR /app +ENV NODE_ENV=production \ + NEXT_TELEMETRY_DISABLED=1 \ + PORT=3000 \ + HOSTNAME=0.0.0.0 +COPY --from=build --chown=10001:10001 /app/.next/standalone ./ +COPY --from=build --chown=10001:10001 /app/.next/static ./.next/static +USER 10001:10001 +EXPOSE 3000 +CMD ["node", "server.js"] diff --git a/web/README.md b/web/README.md index 9715a88..0b2d722 100644 --- a/web/README.md +++ b/web/README.md @@ -1,68 +1,88 @@ -# FatihsMG4 web +# Nils' MG4 — car server -Next.js app on Vercel: the ingest API the car writes to, and a phone-shaped dashboard for -reading the history back. Replaces the standalone Express service that used to live in -`server/`. +Next.js app, self-hosted at car.stinnesbeck.com: the ingest API the car writes to, the ABRP +forwarder, and a phone-shaped dashboard for reading the history back. Data lives in +InfluxDB 2. Based on upstream's Vercel/MongoDB app in FatihsMG4. -**The car never connects to MongoDB and never holds a MongoDB URI.** The URI is a Vercel -environment variable and is only ever read by a server-side route. +``` +car ──live telemetry───▶ server ──▶ ABRP api.iternio.com/1/tlm/send + ──daily chunks─────▶ │ + ──charging sessions▶ ▼ + InfluxDB 2 +``` -## Two credentials, on purpose +## Credentials | | Who holds it | What it opens | |---|---|---| -| `INGEST_TOKEN` | the car, in the APK | the `PUT` endpoints — write only | -| `DASHBOARD_PASSWORD` | you, in a browser | the dashboard and the `GET` history | +| `INGEST_TOKEN` | the car, entered in the app | the write endpoints, and the car's own history read | +| `DASHBOARD_PASSWORD` | you, in a browser | the dashboard and the `GET /v1/history` reads | +| `ABRP_API_KEY`, `ABRP_USER_TOKEN` | only the server | forwarding live telemetry to ABRP | -The APK is published, so its token must be assumed extractable: anyone can `unzip` an APK -and read its strings. That is survivable here because the write token cannot read anything -back. The password is never compiled into anything. +The ABRP credentials never reach the car. A missing or empty one switches forwarding off. ## Environment variables -Set all five in Vercel → Settings → Environment Variables, for Production and Preview. +See `.env.example`. In Kubernetes they come from the Secret in `../k8s/`. | Name | Notes | |---|---| -| `MONGODB_URI` | Atlas connection string | -| `MONGODB_DATABASE` | defaults to `fatihsmg4` | -| `INGEST_TOKEN` | `openssl rand -hex 32` | +| `INFLUX_URL`, `INFLUX_TOKEN`, `INFLUX_ORG`, `INFLUX_BUCKET` | bucket defaults to `mg4`; the token needs read/write on it | +| `INGEST_TOKEN` | at least 24 characters, or every write is refused: `openssl rand -hex 32` | | `DASHBOARD_PASSWORD` | at least 8 characters, or login is refused outright | | `SESSION_SECRET` | `openssl rand -hex 32`; rotating it signs every browser out | - -Atlas network access must allow Vercel's egress, which is not a fixed address on the Hobby -plan — either allow `0.0.0.0/0` and rely on the database user's credentials, or use Atlas -private networking on a paid plan. +| `ABRP_API_KEY`, `ABRP_USER_TOKEN` | optional; both needed to forward | +| `ABRP_MIN_INTERVAL_SECONDS` | default 5 | ## Endpoints Write, `Authorization: Bearer `: -- `PUT /v1/consumption/daily` +- `POST /v1/telemetry` — `{ installationId, samples: [...] }`, up to 500 samples in ABRP's `tlm` + field names and units (`utc` in epoch seconds; flags as 0/1 or booleans). Stored, then the + newest sample is forwarded to ABRP if it is under 5 minutes old. +- `PUT /v1/consumption/daily` — one delta chunk of a day's consumption. - `PUT /v1/charging-sessions/:sessionId` -Read, session cookie or `Authorization: Bearer `: +Read: -- `GET /v1/history/:installationId/consumption` -- `GET /v1/history/:installationId/charging` +- `GET /v1/consumption/history/:installationId` — ingest token; the car reads its own totals back. +- `GET /v1/history/:installationId/consumption`, `.../charging` — session cookie or + `Authorization: Bearer `. `GET /v1/health` is unauthenticated and returns `{"status":"ok"}`. -The car builds its URLs as `CONSUMPTION_API_URL + /v1/...`, so the Android build variable -is the bare origin — `https://your-app.vercel.app`, no trailing path. +## Storage -## Indexes +One bucket, `installationId` tag on every point: -There is no boot step on Vercel, so `ensureIndexes()` runs on the first write of each warm -lambda and is skipped afterwards. The unique indexes on `(installationId, date)` and -`(installationId, sessionId)` are what make the upserts idempotent when the car retries a -record it could not confirm. +| Measurement | Timestamp | Tags | Fields | +|---|---|---|---| +| `telemetry` | sample `utc` | — | the ABRP fields that were sent | +| `consumption_chunk` | `recordedAt` | `date` | km, kwh, hours, socDrop, sohPercent | +| `charging_session` | `startedAt` | `sessionId` | endedAtMs, duration, energy, cost, start/end SOC | +| `charging_curve` | point time | `sessionId` | powerKw, socPercent | + +A point with the same measurement, tags and timestamp replaces the earlier one, so the car's +retries are idempotent: a cached chunk is re-sent with its original `recordedAt`. Days and the +lifetime total are summed from the chunks when read. All numbers are written as floats, because +InfluxDB fixes a field's type on first write. ## Local development ```sh +nix develop # from the repository root: Node 22 and InfluxDB 2 cp .env.example .env.local # Next loads .env.local automatically npm install npm run dev -npm test # pure validation and session-signing tests, no database +npm test # validation, aggregation and forwarding rules; no database ``` + +## Container image + +```sh +podman build -t git.stinnesbeck.com/nils/mg4-server: . +podman push git.stinnesbeck.com/nils/mg4-server: +``` + +The build runs the unit tests. The image runs as UID 10001 and listens on port 3000. diff --git a/web/next.config.mjs b/web/next.config.mjs index 1f0ce49..7bd72cb 100644 --- a/web/next.config.mjs +++ b/web/next.config.mjs @@ -1,5 +1,7 @@ /** @type {import('next').NextConfig} */ const nextConfig = { + // A self-contained server in .next/standalone, for the container image. + output: "standalone", // Every page reads live telemetry; a cached shell would show yesterday's drive. experimental: { serverActions: { bodySizeLimit: "1mb" } }, }; diff --git a/web/package-lock.json b/web/package-lock.json index d4aa3dd..8ad8c25 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -8,7 +8,7 @@ "name": "fatihsmg4-web", "version": "1.0.0", "dependencies": { - "mongodb": "^6.20.0", + "@influxdata/influxdb-client": "^1.35.0", "next": "^15.5.4", "react": "^19.1.0", "react-dom": "^19.1.0" @@ -533,14 +533,11 @@ "url": "https://opencollective.com/libvips" } }, - "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/@influxdata/influxdb-client": { + "version": "1.35.0", + "resolved": "https://registry.npmjs.org/@influxdata/influxdb-client/-/influxdb-client-1.35.0.tgz", + "integrity": "sha512-woWMi8PDpPQpvTsRaUw4Ig+nOGS/CWwAwS66Fa1Vr/EkW+NEwxI8YfPBsdBMn33jK2Y86/qMiiuX/ROHIkJLTw==", + "license": "MIT" }, "node_modules/@next/env": { "version": "15.5.27", @@ -705,30 +702,6 @@ "csstype": "^3.2.2" } }, - "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/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/caniuse-lite": { "version": "1.0.30001815", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001815.tgz", @@ -772,68 +745,6 @@ "node": ">=8" } }, - "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/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/nanoid": { "version": "3.3.20", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", @@ -938,21 +849,11 @@ "node": "^10 || ^12 || >=14" } }, - "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/react": { "version": "19.3.0", "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -962,7 +863,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.28.0" }, @@ -1048,15 +948,6 @@ "node": ">=0.10.0" } }, - "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/styled-jsx": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", @@ -1080,18 +971,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/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -1118,28 +997,6 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" - }, - "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" - } } } } diff --git a/web/package.json b/web/package.json index 0cf8888..8854c8f 100644 --- a/web/package.json +++ b/web/package.json @@ -13,7 +13,7 @@ "node": ">=22" }, "dependencies": { - "mongodb": "^6.20.0", + "@influxdata/influxdb-client": "^1.35.0", "next": "^15.5.4", "react": "^19.1.0", "react-dom": "^19.1.0" @@ -24,4 +24,4 @@ "typescript": "^5.8.3" }, "type": "module" -} \ No newline at end of file +} diff --git a/web/src/app/v1/charging-sessions/[sessionId]/route.ts b/web/src/app/v1/charging-sessions/[sessionId]/route.ts index d36ff88..55ee73a 100644 --- a/web/src/app/v1/charging-sessions/[sessionId]/route.ts +++ b/web/src/app/v1/charging-sessions/[sessionId]/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { handle, parseJson, requireIngest } from "@/lib/api"; -import { chargingCollection, ensureIndexes } from "@/lib/mongo"; +import { writeChargingSession } from "@/lib/store"; import { validateChargingSession } from "@/lib/validate"; export const runtime = "nodejs"; @@ -11,15 +11,7 @@ export async function PUT(request: Request, context: { params: Promise<{ session if (denied) return denied; return handle(async () => { const { sessionId } = await context.params; - const record = validateChargingSession(await parseJson(request), sessionId); - await ensureIndexes(); - const collection = await chargingCollection(); - const updatedAt = new Date(); - await collection.updateOne( - { installationId: record.installationId, sessionId: record.sessionId }, - { $set: { ...record, updatedAt }, $setOnInsert: { createdAt: updatedAt } }, - { upsert: true }, - ); + await writeChargingSession(validateChargingSession(await parseJson(request), sessionId)); return new NextResponse(null, { status: 204 }); }); } diff --git a/web/src/app/v1/consumption/daily/route.ts b/web/src/app/v1/consumption/daily/route.ts index 7397ba6..691fab5 100644 --- a/web/src/app/v1/consumption/daily/route.ts +++ b/web/src/app/v1/consumption/daily/route.ts @@ -1,48 +1,21 @@ import { NextResponse } from "next/server"; import { handle, parseJson, requireIngest } from "@/lib/api"; -import { consumptionCollection, ensureIndexes } from "@/lib/mongo"; +import { writeConsumptionChunk } from "@/lib/store"; import { validateConsumption } from "@/lib/validate"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; /** - * Upsert on (installationId, date), incrementally. The car sends a 2-minute delta chunk - * (not a cumulative total), so each PUT adds to the day's running totals rather than - * overwriting them. The app does NOT send lifetime — the backend derives it as the sum of - * all days' totals when history is read, so an APK wipe that zeroes the local counter can - * never corrupt the cloud's lifetime. - * - * $setOnInsert must not touch `day` at all: $inc on `day.km` and $setOnInsert on `day` - * conflict in MongoDB. On an insert, $inc treats a missing field as 0 and adds to it. + * One delta chunk (not a cumulative total) of a day's consumption. Stored as its own point + * at its recordedAt; reads add a day's chunks up. A chunk the car re-sends from its offline + * cache carries the same recordedAt and replaces itself, so a retry is never counted twice. */ export async function PUT(request: Request) { const denied = requireIngest(request); if (denied) return denied; return handle(async () => { - const record = validateConsumption(await parseJson(request)); - await ensureIndexes(); - const collection = await consumptionCollection(); - const updatedAt = new Date(); - const $set: Record = { updatedAt }; - if (record.sohPercent !== undefined) $set.sohPercent = record.sohPercent; - await collection.updateOne( - { installationId: record.installationId, date: record.date }, - { - $inc: { - "day.km": record.day.km, - "day.kwh": record.day.kwh, - "day.hours": record.day.hours, - "day.socDrop": record.day.socDrop, - }, - $set, - $setOnInsert: { - createdAt: updatedAt, - }, - }, - { upsert: true }, - ); + await writeConsumptionChunk(validateConsumption(await parseJson(request))); return new NextResponse(null, { status: 204 }); }); } - diff --git a/web/src/app/v1/consumption/history/[installationId]/route.ts b/web/src/app/v1/consumption/history/[installationId]/route.ts index e44811f..ffd2bc3 100644 --- a/web/src/app/v1/consumption/history/[installationId]/route.ts +++ b/web/src/app/v1/consumption/history/[installationId]/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { handle, requireIngest } from "@/lib/api"; -import { consumptionCollection } from "@/lib/mongo"; +import { readDays } from "@/lib/store"; import { validateUuid } from "@/lib/validate"; export const runtime = "nodejs"; @@ -21,23 +21,10 @@ export async function GET(request: Request, context: { params: Promise<{ install if (denied) return denied; return handle(async () => { const { installationId } = await context.params; - const collection = await consumptionCollection(); - const records = await collection - .find({ installationId: validateUuid(installationId, "installationId") }, { projection: { _id: 0 } }) - .sort({ date: -1 }) - .limit(400) - .toArray(); - // Lifetime = sum of every day's totals. Derived, never stored, so a corrupted or - // stale lifetime field in any single document cannot poison the total. - const lifetime = records.reduce( - (sum, r) => ({ - km: sum.km + (r.day?.km ?? 0), - kwh: sum.kwh + (r.day?.kwh ?? 0), - hours: sum.hours + (r.day?.hours ?? 0), - socDrop: sum.socDrop + (r.day?.socDrop ?? 0), - }), - { km: 0, kwh: 0, hours: 0, socDrop: 0 }, - ); + const days = await readDays(validateUuid(installationId, "installationId")); + // Lifetime = sum of every day's totals, which is the newest day's running total. + const lifetime = days[0]?.lifetime ?? { km: 0, kwh: 0, hours: 0, socDrop: 0 }; + const records = days.slice(0, 400).map(({ lifetime: _lifetime, ...day }) => day); return NextResponse.json({ records, lifetime }); }); } diff --git a/web/src/app/v1/history/[installationId]/charging/route.ts b/web/src/app/v1/history/[installationId]/charging/route.ts index 73dd795..434bf4d 100644 --- a/web/src/app/v1/history/[installationId]/charging/route.ts +++ b/web/src/app/v1/history/[installationId]/charging/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { handle, requireReader } from "@/lib/api"; -import { chargingCollection } from "@/lib/mongo"; +import { readChargingSessions } from "@/lib/store"; import { validateUuid } from "@/lib/validate"; export const runtime = "nodejs"; @@ -11,12 +11,7 @@ export async function GET(request: Request, context: { params: Promise<{ install if (denied) return denied; return handle(async () => { const { installationId } = await context.params; - const collection = await chargingCollection(); - const records = await collection - .find({ installationId: validateUuid(installationId, "installationId") }, { projection: { _id: 0 } }) - .sort({ startedAt: -1 }) - .limit(200) - .toArray(); + const records = await readChargingSessions(validateUuid(installationId, "installationId"), 200); return NextResponse.json({ records }); }); } diff --git a/web/src/app/v1/history/[installationId]/consumption/route.ts b/web/src/app/v1/history/[installationId]/consumption/route.ts index 19d108c..158b0a8 100644 --- a/web/src/app/v1/history/[installationId]/consumption/route.ts +++ b/web/src/app/v1/history/[installationId]/consumption/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { handle, requireReader } from "@/lib/api"; -import { consumptionCollection } from "@/lib/mongo"; +import { readDays } from "@/lib/store"; import { validateUuid } from "@/lib/validate"; export const runtime = "nodejs"; @@ -11,12 +11,7 @@ export async function GET(request: Request, context: { params: Promise<{ install if (denied) return denied; return handle(async () => { const { installationId } = await context.params; - const collection = await consumptionCollection(); - const records = await collection - .find({ installationId: validateUuid(installationId, "installationId") }, { projection: { _id: 0 } }) - .sort({ date: -1 }) - .limit(400) - .toArray(); + const records = (await readDays(validateUuid(installationId, "installationId"))).slice(0, 400); return NextResponse.json({ records }); }); } diff --git a/web/src/app/v1/telemetry/route.ts b/web/src/app/v1/telemetry/route.ts new file mode 100644 index 0000000..ffbf8fb --- /dev/null +++ b/web/src/app/v1/telemetry/route.ts @@ -0,0 +1,24 @@ +import { NextResponse } from "next/server"; +import { forwardToAbrp } from "@/lib/abrp"; +import { handle, parseJson, requireIngest } from "@/lib/api"; +import { writeTelemetry } from "@/lib/store"; +import { validateTelemetryBatch } from "@/lib/validate"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +/** + * A batch of live samples from the car. Stored first; only once the write succeeded is the + * newest sample handed to ABRP, so the car's retry of a failed request cannot leave ABRP + * with data the database never got. + */ +export async function POST(request: Request) { + const denied = requireIngest(request); + if (denied) return denied; + return handle(async () => { + const batch = validateTelemetryBatch(await parseJson(request)); + await writeTelemetry(batch.installationId, batch.samples); + forwardToAbrp(batch.samples); + return new NextResponse(null, { status: 204 }); + }); +} diff --git a/web/src/lib/abrp.ts b/web/src/lib/abrp.ts new file mode 100644 index 0000000..87a7756 --- /dev/null +++ b/web/src/lib/abrp.ts @@ -0,0 +1,88 @@ +import { TELEMETRY_FLAGS, type TelemetrySample } from "./types.ts"; + +/** + * Forwards live telemetry to A Better Route Planner. + * + * The ABRP credentials exist only here, as ABRP_API_KEY and ABRP_USER_TOKEN in the server's + * environment; the car never holds them. Forwarding is off when either is unset. + * + * Only the newest sample of a batch is sent, and at most one every ABRP_MIN_INTERVAL_SECONDS + * (default 5). A failed forward is logged and dropped, never queued: ABRP plans from the + * car's state now, and a backlog replayed later would describe a car that has moved on. + * The stored copy in InfluxDB is unaffected either way. + */ + +const API_URL = "https://api.iternio.com/1/tlm/send"; +const TIMEOUT_MS = 8_000; +/** Older than this, a sample is history rather than live state, and ABRP gets nothing. */ +const MAX_AGE_SECONDS = 300; + +const state = globalThis as unknown as { abrpLastUtc?: number; abrpLastSentMs?: number }; + +export function abrpConfigured(): boolean { + return Boolean(process.env.ABRP_API_KEY && process.env.ABRP_USER_TOKEN); +} + +function minIntervalMs(): number { + const seconds = Number(process.env.ABRP_MIN_INTERVAL_SECONDS ?? "5"); + return Number.isFinite(seconds) && seconds >= 0 ? seconds * 1000 : 5000; +} + +/** The tlm object ABRP expects: the same field names, flags as 0/1. */ +export function toTlm(sample: TelemetrySample): Record { + const tlm: Record = {}; + for (const [field, value] of Object.entries(sample)) { + if (typeof value === "number") tlm[field] = value; + } + for (const field of TELEMETRY_FLAGS) { + const value = sample[field]; + if (value !== undefined) tlm[field] = value ? 1 : 0; + } + return tlm; +} + +/** Picks the sample to forward, or null when there is nothing new or it is too soon. */ +export function pickForForward(samples: TelemetrySample[], nowMs: number): TelemetrySample | null { + if (samples.length === 0) return null; + const newest = samples.reduce((a, b) => (b.utc > a.utc ? b : a)); + if (nowMs / 1000 - newest.utc > MAX_AGE_SECONDS) return null; + if (state.abrpLastUtc !== undefined && newest.utc <= state.abrpLastUtc) return null; + if (state.abrpLastSentMs !== undefined && nowMs - state.abrpLastSentMs < minIntervalMs()) return null; + return newest; +} + +/** Fire-and-forget: the car's request is answered without waiting for ABRP. */ +export function forwardToAbrp(samples: TelemetrySample[]): void { + if (!abrpConfigured()) return; + const nowMs = Date.now(); + const sample = pickForForward(samples, nowMs); + if (!sample) return; + state.abrpLastUtc = sample.utc; + state.abrpLastSentMs = nowMs; + void send(sample).catch((error) => console.warn("ABRP forward failed:", error instanceof Error ? error.message : error)); +} + +async function send(sample: TelemetrySample): Promise { + // Credentials go in the form body, never the URL, which ends up in access logs. + const body = new URLSearchParams({ + api_key: process.env.ABRP_API_KEY ?? "", + token: process.env.ABRP_USER_TOKEN ?? "", + tlm: JSON.stringify(toTlm(sample)), + }); + const response = await fetch(API_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body, + signal: AbortSignal.timeout(TIMEOUT_MS), + }); + if (!response.ok) { + // The response body can echo the request; only the status is logged. + console.warn(`ABRP forward rejected: HTTP ${response.status}`); + } +} + +/** For tests: forget what was forwarded. */ +export function resetAbrpState(): void { + state.abrpLastUtc = undefined; + state.abrpLastSentMs = undefined; +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 327e63b..a266559 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -2,7 +2,7 @@ import { NextResponse } from "next/server"; import { hasIngestToken, hasSession, checkDashboardPassword } from "./auth"; import { ValidationError } from "./validate"; -/** Every endpoint touches MongoDB, which rules out the edge runtime. */ +/** Every endpoint touches InfluxDB, which rules out the edge runtime. */ export const runtime = "nodejs"; export const dynamic = "force-dynamic"; diff --git a/web/src/lib/data.ts b/web/src/lib/data.ts index b23bf5b..50c1d07 100644 --- a/web/src/lib/data.ts +++ b/web/src/lib/data.ts @@ -1,11 +1,12 @@ -import { chargingCollection, consumptionCollection, type ChargingSession, type DailyConsumption } from "./mongo"; +import { installationIds, readChargingSessions, readDays, readLatestTelemetry } from "./store"; +import type { ChargingSession, DailyConsumption, TelemetrySample } from "./types"; /** * Reads for the dashboard. * - * Every query is scoped to one installation id. There is one car today, but the id is in - * the schema and in the unique indexes, so scoping now costs nothing and means a second - * head unit does not silently merge its history into the first one's charts. + * Every query is scoped to one installation id. There is one car today, but the id is on + * every point, so scoping now costs nothing and means a second head unit does not + * silently merge its history into the first one's charts. */ export interface Overview { @@ -13,36 +14,32 @@ export interface Overview { installationCount: number; days: DailyConsumption[]; sessions: ChargingSession[]; + latest: (TelemetrySample & { at: Date }) | null; } /** The installation that reported most recently — the car in use, if there are ever two. */ export async function latestInstallationId(): Promise<{ id: string | null; count: number }> { - const collection = await consumptionCollection(); - const ids = await collection.distinct("installationId"); - if (ids.length === 0) { - const charging = await chargingCollection(); - const chargingIds = await charging.distinct("installationId"); - return { id: chargingIds[0] ?? null, count: chargingIds.length }; - } - if (ids.length === 1) return { id: ids[0], count: 1 }; - const newest = await collection.find({}, { projection: { installationId: 1 } }) - .sort({ date: -1 }).limit(1).next(); - return { id: newest?.installationId ?? ids[0], count: ids.length }; + const ids = await installationIds(); + return { id: ids[0] ?? null, count: ids.length }; } export async function loadOverview(dayLimit = 60, sessionLimit = 60): Promise { const { id, count } = await latestInstallationId(); - if (!id) return { installationId: null, installationCount: 0, days: [], sessions: [] }; - const [consumption, charging] = await Promise.all([consumptionCollection(), chargingCollection()]); - const [days, sessions] = await Promise.all([ - consumption.find({ installationId: id }, { projection: { _id: 0 } }) - .sort({ date: -1 }).limit(dayLimit).toArray(), - charging.find({ installationId: id }, { projection: { _id: 0 } }) - .sort({ startedAt: -1 }).limit(sessionLimit).toArray(), + if (!id) return { installationId: null, installationCount: 0, days: [], sessions: [], latest: null }; + const [days, sessions, latest] = await Promise.all([ + readDays(id), + readChargingSessions(id, sessionLimit), + readLatestTelemetry(id), ]); - // Charts read left-to-right in time; the queries sort newest-first so the limit keeps + // Charts read left-to-right in time; the reads come newest-first so the limit keeps // the recent end rather than the beginning of history. - return { installationId: id, installationCount: count, days: days.reverse(), sessions }; + return { + installationId: id, + installationCount: count, + days: days.slice(0, dayLimit).reverse(), + sessions, + latest, + }; } export interface ChargeTotals { diff --git a/web/src/lib/days.ts b/web/src/lib/days.ts new file mode 100644 index 0000000..e5a36fd --- /dev/null +++ b/web/src/lib/days.ts @@ -0,0 +1,43 @@ +import type { ConsumptionChunk, DailyConsumption, Totals } from "./types.ts"; + +/** + * Folds chunks into days, newest day first, each with its running lifetime total. + * Pure, so the arithmetic is tested without a database. + */ +export function aggregateDays(chunks: ConsumptionChunk[]): DailyConsumption[] { + const byDate = new Map(); + for (const chunk of chunks) { + let day = byDate.get(chunk.date); + if (!day) { + day = { + installationId: chunk.installationId, + date: chunk.date, + recordedAt: chunk.recordedAt, + day: { km: 0, kwh: 0, hours: 0, socDrop: 0 }, + sohAt: -Infinity, + }; + byDate.set(chunk.date, day); + } + day.day.km += chunk.day.km; + day.day.kwh += chunk.day.kwh; + day.day.hours += chunk.day.hours; + day.day.socDrop += chunk.day.socDrop; + const at = chunk.recordedAt.getTime(); + if (at > day.recordedAt.getTime()) day.recordedAt = chunk.recordedAt; + // The newest manual SoH of the day wins. + if (chunk.sohPercent !== undefined && at > day.sohAt) { + day.sohPercent = chunk.sohPercent; + day.sohAt = at; + } + } + const ascending = [...byDate.values()].sort((a, b) => a.date.localeCompare(b.date)); + const running: Totals = { km: 0, kwh: 0, hours: 0, socDrop: 0 }; + const days = ascending.map(({ sohAt: _sohAt, ...day }) => { + running.km += day.day.km; + running.kwh += day.day.kwh; + running.hours += day.day.hours; + running.socDrop += day.day.socDrop; + return { ...day, lifetime: { ...running } }; + }); + return days.reverse(); +} diff --git a/web/src/lib/influx.ts b/web/src/lib/influx.ts new file mode 100644 index 0000000..4bb8c7d --- /dev/null +++ b/web/src/lib/influx.ts @@ -0,0 +1,40 @@ +import { InfluxDB, type QueryApi, type WriteApi } from "@influxdata/influxdb-client"; + +/** + * One InfluxDB client per server process. + * + * Configured entirely from the environment so the image carries no credentials: + * INFLUX_URL, INFLUX_TOKEN, INFLUX_ORG and INFLUX_BUCKET (default "mg4"). The global + * survives module reloads in dev, where Next re-evaluates modules on every edit. + */ +const globalForInflux = globalThis as unknown as { influx?: InfluxDB }; + +function env(name: string): string { + const value = process.env[name]; + if (!value) throw new Error(`${name} is not configured`); + return value; +} + +function client(): InfluxDB { + if (!globalForInflux.influx) { + globalForInflux.influx = new InfluxDB({ url: env("INFLUX_URL"), token: env("INFLUX_TOKEN"), timeout: 10_000 }); + } + return globalForInflux.influx; +} + +export function bucket(): string { + return process.env.INFLUX_BUCKET || "mg4"; +} + +/** A write API with millisecond precision; the caller must close() it to flush. */ +export function writeApi(): WriteApi { + return client().getWriteApi(env("INFLUX_ORG"), bucket(), "ms", { + // The caller waits for close(); a failed write must reach it rather than be retried + // in the background after the car has already been told it succeeded. + maxRetries: 0, + }); +} + +export function queryApi(): QueryApi { + return client().getQueryApi(env("INFLUX_ORG")); +} diff --git a/web/src/lib/mongo.ts b/web/src/lib/mongo.ts deleted file mode 100644 index 508dcc6..0000000 --- a/web/src/lib/mongo.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { MongoClient, type Collection, type Db } from "mongodb"; - -/** - * One MongoClient per warm lambda, cached on globalThis. - * - * A serverless function is re-entered far more often than it is cold-started, and a fresh - * MongoClient per request opens a fresh pool per request — Atlas starts refusing - * connections long before the traffic justifies it. The global survives module reloads in - * dev, where Next re-evaluates modules on every edit. - */ -const globalForMongo = globalThis as unknown as { mongoClient?: Promise }; - -export interface Totals { - km: number; - kwh: number; - hours: number; - socDrop: number; -} - -export interface DailyConsumption { - installationId: string; - date: string; - recordedAt: Date; - day: Totals; - /** Optional — the backend derives lifetime as the sum of all days' totals. */ - lifetime?: Totals; - sohPercent?: number; - createdAt: Date; - updatedAt: Date; -} - -export interface CurvePoint { - timestamp: Date; - powerKw: number; - socPercent?: number; -} - -export interface ChargingSession { - installationId: string; - sessionId: string; - startedAt: Date; - endedAt: Date; - durationSeconds: number; - energyKwh: number; - chargingLossPercent: number; - gridEnergyKwh: number; - pricePerKwh: number; - totalCost: number; - isDemo?: boolean; - startSocPercent?: number; - endSocPercent?: number; - curve: CurvePoint[]; - createdAt: Date; - updatedAt: Date; -} - -function clientPromise(): Promise { - const uri = process.env.MONGODB_URI; - if (!uri) throw new Error("MONGODB_URI is not configured"); - if (!globalForMongo.mongoClient) { - globalForMongo.mongoClient = new MongoClient(uri, { - serverSelectionTimeoutMS: 10_000, - maxPoolSize: 10, - }).connect(); - } - return globalForMongo.mongoClient; -} - -async function db(): Promise { - const client = await clientPromise(); - return client.db(process.env.MONGODB_DATABASE || "fatihsmg4"); -} - -export async function consumptionCollection(): Promise> { - return (await db()).collection("daily_consumption"); -} - -export async function chargingCollection(): Promise> { - return (await db()).collection("charging_sessions"); -} - -/** - * Creates the indexes the Express service used to create at boot. - * - * There is no boot here — a lambda starts on a request — so this runs on the first write - * of each warm instance and is skipped afterwards. `createIndex` is idempotent, but it is - * still a round trip, and the uniqueness constraints are what make the upserts safe. - */ -let indexesEnsured = false; -export async function ensureIndexes(): Promise { - if (indexesEnsured) return; - const [consumption, charging] = await Promise.all([consumptionCollection(), chargingCollection()]); - await Promise.all([ - consumption.createIndex({ installationId: 1, date: 1 }, { unique: true }), - consumption.createIndex({ installationId: 1, date: -1 }), - charging.createIndex({ installationId: 1, sessionId: 1 }, { unique: true }), - charging.createIndex({ installationId: 1, startedAt: -1 }), - ]); - indexesEnsured = true; -} diff --git a/web/src/lib/store.ts b/web/src/lib/store.ts new file mode 100644 index 0000000..7836506 --- /dev/null +++ b/web/src/lib/store.ts @@ -0,0 +1,225 @@ +import { Point, flux } from "@influxdata/influxdb-client"; +import { aggregateDays } from "./days.ts"; +import { bucket, queryApi, writeApi } from "./influx.ts"; +import { + TELEMETRY_FLAGS, + TELEMETRY_NUMBERS, + type ChargingSession, + type ConsumptionChunk, + type CurvePoint, + type DailyConsumption, + type TelemetrySample, +} from "./types.ts"; + +/** + * Storage on InfluxDB 2. One bucket; every point carries an `installationId` tag. + * + * consumption_chunk time = recordedAt tags: date km, kwh, hours, socDrop, sohPercent + * charging_session time = startedAt tags: sessionId summary fields + * charging_curve time = point time tags: sessionId powerKw, socPercent + * telemetry time = sample utc ABRP tlm fields + * + * Idempotency comes from InfluxDB itself: a point with the same measurement, tags and + * timestamp replaces the earlier one. The car re-sends a cached chunk with its original + * recordedAt and a session under its own id, so a retry overwrites instead of adding. + * + * Every numeric field is written as a float. InfluxDB fixes a field's type on first + * write, so a 0 sent as an integer would make every later 0.5 a rejected write. + */ + +const TAG = "installationId"; + +async function write(points: Point[]): Promise { + const api = writeApi(); + api.writePoints(points); + await api.close(); +} + +export async function writeConsumptionChunk(chunk: ConsumptionChunk): Promise { + const point = new Point("consumption_chunk") + .tag(TAG, chunk.installationId) + .tag("date", chunk.date) + .floatField("km", chunk.day.km) + .floatField("kwh", chunk.day.kwh) + .floatField("hours", chunk.day.hours) + .floatField("socDrop", chunk.day.socDrop) + .timestamp(chunk.recordedAt); + if (chunk.sohPercent !== undefined) point.floatField("sohPercent", chunk.sohPercent); + await write([point]); +} + +export async function writeChargingSession(session: ChargingSession): Promise { + const summary = new Point("charging_session") + .tag(TAG, session.installationId) + .tag("sessionId", session.sessionId) + .intField("endedAtMs", session.endedAt.getTime()) + .floatField("durationSeconds", session.durationSeconds) + .floatField("energyKwh", session.energyKwh) + .floatField("chargingLossPercent", session.chargingLossPercent) + .floatField("gridEnergyKwh", session.gridEnergyKwh) + .floatField("pricePerKwh", session.pricePerKwh) + .floatField("totalCost", session.totalCost) + .booleanField("isDemo", session.isDemo === true) + .timestamp(session.startedAt); + if (session.startSocPercent !== undefined) summary.floatField("startSocPercent", session.startSocPercent); + if (session.endSocPercent !== undefined) summary.floatField("endSocPercent", session.endSocPercent); + const curve = session.curve.map((p) => { + const point = new Point("charging_curve") + .tag(TAG, session.installationId) + .tag("sessionId", session.sessionId) + .floatField("powerKw", p.powerKw) + .timestamp(p.timestamp); + if (p.socPercent !== undefined) point.floatField("socPercent", p.socPercent); + return point; + }); + await write([summary, ...curve]); +} + +export async function writeTelemetry(installationId: string, samples: TelemetrySample[]): Promise { + const points = samples.map((sample) => { + const point = new Point("telemetry").tag(TAG, installationId).timestamp(new Date(sample.utc * 1000)); + for (const field of Object.keys(TELEMETRY_NUMBERS) as (keyof typeof TELEMETRY_NUMBERS)[]) { + const value = sample[field]; + if (value !== undefined) point.floatField(field, value); + } + for (const field of TELEMETRY_FLAGS) { + const value = sample[field]; + if (value !== undefined) point.booleanField(field, value); + } + return point; + }); + await write(points); +} + +// ---------- Reads ---------- + +type Row = Record & { _time: string }; + +async function rows(query: ReturnType): Promise { + return queryApi().collectRows(query); +} + +function num(row: Row, field: string): number | undefined { + const value = row[field]; + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +export async function readConsumptionChunks(installationId: string): Promise { + const result = await rows(flux` + from(bucket: ${bucket()}) + |> range(start: 0) + |> filter(fn: (r) => r._measurement == "consumption_chunk" and r.installationId == ${installationId}) + |> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")`); + return result.map((row) => { + const sohPercent = num(row, "sohPercent"); + return { + installationId, + date: String(row.date), + recordedAt: new Date(row._time), + day: { + km: num(row, "km") ?? 0, + kwh: num(row, "kwh") ?? 0, + hours: num(row, "hours") ?? 0, + socDrop: num(row, "socDrop") ?? 0, + }, + ...(sohPercent === undefined ? {} : { sohPercent }), + }; + }); +} + +export async function readDays(installationId: string): Promise { + return aggregateDays(await readConsumptionChunks(installationId)); +} + +export async function readChargingSessions(installationId: string, limit: number): Promise { + const summaries = await rows(flux` + from(bucket: ${bucket()}) + |> range(start: 0) + |> filter(fn: (r) => r._measurement == "charging_session" and r.installationId == ${installationId}) + |> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value") + |> group() + |> sort(columns: ["_time"], desc: true) + |> limit(n: ${limit})`); + if (summaries.length === 0) return []; + + const sessions: ChargingSession[] = summaries.map((row) => { + const startSocPercent = num(row, "startSocPercent"); + const endSocPercent = num(row, "endSocPercent"); + return { + installationId, + sessionId: String(row.sessionId), + startedAt: new Date(row._time), + endedAt: new Date(num(row, "endedAtMs") ?? Date.parse(row._time)), + durationSeconds: num(row, "durationSeconds") ?? 0, + energyKwh: num(row, "energyKwh") ?? 0, + chargingLossPercent: num(row, "chargingLossPercent") ?? 10, + gridEnergyKwh: num(row, "gridEnergyKwh") ?? 0, + pricePerKwh: num(row, "pricePerKwh") ?? 0, + totalCost: num(row, "totalCost") ?? 0, + ...(row.isDemo === true ? { isDemo: true } : {}), + ...(startSocPercent === undefined ? {} : { startSocPercent }), + ...(endSocPercent === undefined ? {} : { endSocPercent }), + curve: [], + }; + }); + + // Curves for exactly these sessions: bounded by the oldest start and the newest end. + const start = new Date(Math.min(...sessions.map((s) => s.startedAt.getTime()))); + const stop = new Date(Math.max(...sessions.map((s) => s.endedAt.getTime())) + 1000); + const curveRows = await rows(flux` + from(bucket: ${bucket()}) + |> range(start: ${start}, stop: ${stop}) + |> filter(fn: (r) => r._measurement == "charging_curve" and r.installationId == ${installationId}) + |> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")`); + const bySession = new Map(sessions.map((s) => [s.sessionId, s])); + for (const row of curveRows) { + const session = bySession.get(String(row.sessionId)); + const powerKw = num(row, "powerKw"); + if (!session || powerKw === undefined) continue; + const socPercent = num(row, "socPercent"); + const point: CurvePoint = { timestamp: new Date(row._time), powerKw }; + if (socPercent !== undefined) point.socPercent = socPercent; + session.curve.push(point); + } + for (const session of sessions) session.curve.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); + return sessions; +} + +/** Installations that reported in the last 400 days, the most recent first. */ +export async function installationIds(): Promise { + const result = await rows(flux` + from(bucket: ${bucket()}) + |> range(start: -400d) + |> filter(fn: (r) => r._measurement == "consumption_chunk" or r._measurement == "charging_session" or r._measurement == "telemetry") + |> keep(columns: ["_time", "installationId"]) + |> group(columns: ["installationId"]) + |> max(column: "_time") + |> group() + |> sort(columns: ["_time"], desc: true)`); + return result.map((row) => String(row.installationId)); +} + +/** + * The latest known value of every live field over the last 30 days, for the dashboard. + * Per field rather than per sample: the newest sample may lack a GPS fix that the one + * before it had. `at` is when the newest of them arrived. Null when nothing was sent. + */ +export async function readLatestTelemetry(installationId: string): Promise<(TelemetrySample & { at: Date }) | null> { + const result = await rows(flux` + from(bucket: ${bucket()}) + |> range(start: -30d) + |> filter(fn: (r) => r._measurement == "telemetry" and r.installationId == ${installationId}) + |> last()`); + if (result.length === 0) return null; + const newest = Math.max(...result.map((row) => Date.parse(row._time))); + const sample: TelemetrySample & { at: Date } = { utc: Math.floor(newest / 1000), at: new Date(newest) }; + const numbers = TELEMETRY_NUMBERS as Record; + const flags = TELEMETRY_FLAGS as readonly string[]; + for (const row of result) { + const field = String(row._field); + const value = row._value; + if (field in numbers && typeof value === "number") (sample as Record)[field] = value; + if (flags.includes(field) && typeof value === "boolean") (sample as Record)[field] = value; + } + return sample; +} diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts new file mode 100644 index 0000000..c523a46 --- /dev/null +++ b/web/src/lib/types.ts @@ -0,0 +1,96 @@ +/** Record shapes shared by validation, storage and the dashboard. */ + +export interface Totals { + km: number; + kwh: number; + hours: number; + socDrop: number; +} + +/** One day's consumption: the sum of every chunk the car sent for that date. */ +export interface DailyConsumption { + installationId: string; + /** The car's local date, YYYY-MM-DD. */ + date: string; + /** When the newest chunk of the day was recorded. */ + recordedAt: Date; + day: Totals; + /** Running total up to and including this day, derived when history is read. */ + lifetime?: Totals; + sohPercent?: number; +} + +/** One 2-minute delta chunk as the car sends it; days are built from these. */ +export interface ConsumptionChunk { + installationId: string; + date: string; + recordedAt: Date; + day: Totals; + sohPercent?: number; +} + +export interface CurvePoint { + timestamp: Date; + powerKw: number; + socPercent?: number; +} + +export interface ChargingSession { + installationId: string; + sessionId: string; + startedAt: Date; + endedAt: Date; + durationSeconds: number; + energyKwh: number; + chargingLossPercent: number; + gridEnergyKwh: number; + pricePerKwh: number; + totalCost: number; + isDemo?: boolean; + startSocPercent?: number; + endSocPercent?: number; + curve: CurvePoint[]; +} + +/** Numeric live-telemetry fields, named and unit-ed as ABRP's tlm/send expects them. */ +export const TELEMETRY_NUMBERS = { + soc: [0, 100], // % + speed: [0, 400], // km/h + power: [-1000, 1000], // kW, positive leaving the battery + est_battery_range: [0, 2000], // km + capacity: [0, 500], // kWh + soe: [0, 500], // kWh + soh: [0, 100], // % + kwh_charged: [0, 1000], // kWh, this charge session + odometer: [0, 10_000_000], // km + lat: [-90, 90], + lon: [-180, 180], + elevation: [-1000, 10_000], // m + heading: [0, 360], // ° + batt_temp: [-60, 100], // °C + cabin_temp: [-60, 100], // °C + ext_temp: [-60, 70], // °C + hvac_setpoint: [0, 40], // °C + hvac_power: [0, 50], // kW + voltage: [0, 1500], // V + current: [-2000, 2000], // A + tire_pressure_fl: [0, 1000], // kPa + tire_pressure_fr: [0, 1000], + tire_pressure_rl: [0, 1000], + tire_pressure_rr: [0, 1000], +} as const satisfies Record; + +/** Boolean live-telemetry fields; ABRP takes them as 0/1. */ +export const TELEMETRY_FLAGS = ["is_charging", "is_dcfc", "is_parked"] as const; + +export type TelemetryNumber = keyof typeof TELEMETRY_NUMBERS; +export type TelemetryFlag = (typeof TELEMETRY_FLAGS)[number]; + +/** One live sample. Only `utc` is always present; an unread signal is absent, never 0. */ +export type TelemetrySample = { utc: number } & Partial> & + Partial>; + +export interface TelemetryBatch { + installationId: string; + samples: TelemetrySample[]; +} diff --git a/web/src/lib/validate.ts b/web/src/lib/validate.ts index 1184fc0..945657e 100644 --- a/web/src/lib/validate.ts +++ b/web/src/lib/validate.ts @@ -6,35 +6,40 @@ * read forever. Every number is bounded; an out-of-range value is rejected rather than * stored and quietly plotted. */ -import type { ChargingSession, CurvePoint, DailyConsumption, Totals } from "./mongo"; +import { + TELEMETRY_FLAGS, + TELEMETRY_NUMBERS, + type ChargingSession, + type ConsumptionChunk, + type CurvePoint, + type TelemetryBatch, + type TelemetrySample, + type Totals, +} from "./types.ts"; export class ValidationError extends Error {} const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/; -export type ConsumptionInput = Omit; -export type ChargingInput = Omit; - -export function validateConsumption(body: unknown): ConsumptionInput { +export function validateConsumption(body: unknown): ConsumptionChunk { const record = asObject(body); const date = record.date; if (typeof date !== "string" || !ISO_DATE.test(date)) throw new ValidationError("date is invalid"); const sohPercent = record.sohPercent; - const lifetime = record.lifetime; + // A lifetime from an older app is still validated, so a malformed one is still a 400, + // but it is not stored: the backend derives lifetime from the day totals. + if (record.lifetime !== undefined) validateTotals(record.lifetime, "lifetime"); return { installationId: validateUuid(record.installationId, "installationId"), date, recordedAt: validateDate(record.recordedAt, "recordedAt"), day: validateTotals(record.day, "day"), - // Lifetime is optional — the backend derives it from day totals, and the app no longer - // sends it. Legacy documents may still carry one; new chunks do not. - ...(lifetime === undefined ? {} : { lifetime: validateTotals(lifetime, "lifetime") }), ...(sohPercent === undefined ? {} : { sohPercent: bounded(sohPercent, 0, 100, "sohPercent") }), }; } -export function validateChargingSession(body: unknown, pathSessionId: string): ChargingInput { +export function validateChargingSession(body: unknown, pathSessionId: string): ChargingSession { const record = asObject(body); const sessionId = validateUuid(record.sessionId, "sessionId"); if (sessionId !== pathSessionId) throw new ValidationError("sessionId does not match path"); @@ -75,6 +80,52 @@ export function validateChargingSession(body: unknown, pathSessionId: string): C }; } +const MAX_SAMPLES = 500; +/** Epoch seconds: 2001 to 2286. Milliseconds would be far above the upper bound. */ +const UTC_MIN = 1_000_000_000; +const UTC_MAX = 9_999_999_999; + +/** + * A batch of live samples in ABRP's tlm field names and units. Fields the server does not + * know are dropped rather than rejected, so a newer app can add one without a server + * release; every known field is bounded, and a flag may be sent as 0/1 or a boolean. + */ +export function validateTelemetryBatch(body: unknown): TelemetryBatch { + const record = asObject(body); + const installationId = validateUuid(record.installationId, "installationId"); + const samples = record.samples; + if (!Array.isArray(samples) || samples.length === 0 || samples.length > MAX_SAMPLES) { + throw new ValidationError("samples is invalid"); + } + return { installationId, samples: samples.map((entry, i) => validateSample(entry, `samples[${i}]`)) }; +} + +function validateSample(entry: unknown, name: string): TelemetrySample { + const raw = asObject(entry); + const utc = raw.utc; + if (typeof utc !== "number" || !Number.isSafeInteger(utc) || utc < UTC_MIN || utc > UTC_MAX) { + throw new ValidationError(`${name}.utc is invalid`); + } + const sample: TelemetrySample = { utc }; + for (const [field, [min, max]] of Object.entries(TELEMETRY_NUMBERS)) { + const value = raw[field]; + if (value === undefined || value === null) continue; + (sample as Record)[field] = bounded(value, min, max, `${name}.${field}`); + } + for (const field of TELEMETRY_FLAGS) { + const value = raw[field]; + if (value === undefined || value === null) continue; + if (value === true || value === 1) sample[field] = true; + else if (value === false || value === 0) sample[field] = false; + else throw new ValidationError(`${name}.${field} is invalid`); + } + // A position is only a position as a pair. + if ((sample.lat === undefined) !== (sample.lon === undefined)) { + throw new ValidationError(`${name}.lat and lon must be sent together`); + } + return sample; +} + export function validateUuid(value: unknown, name: string): string { if (typeof value !== "string" || !UUID.test(value)) throw new ValidationError(`${name} is invalid`); return value; diff --git a/web/test/days.test.ts b/web/test/days.test.ts new file mode 100644 index 0000000..6fb6486 --- /dev/null +++ b/web/test/days.test.ts @@ -0,0 +1,37 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { aggregateDays } from "../src/lib/days.ts"; + +const ID = "6f1a2b3c-4d5e-4f60-8a9b-0c1d2e3f4a5b"; +const chunk = (date: string, at: number, km: number, kwh: number, sohPercent?: number) => ({ + installationId: ID, + date, + recordedAt: new Date(at), + day: { km, kwh, hours: km / 50, socDrop: kwh / 0.6 }, + ...(sohPercent === undefined ? {} : { sohPercent }), +}); + +test("adds chunks up per day, newest day first, with a running lifetime", () => { + const days = aggregateDays([ + chunk("2026-10-16", 1_000, 10, 2), + chunk("2026-10-17", 3_000, 5, 1), + chunk("2026-10-16", 2_000, 20, 3), + ]); + assert.deepEqual(days.map((d) => d.date), ["2026-10-17", "2026-10-16"]); + assert.equal(days[1].day.km, 30); + assert.equal(days[1].day.kwh, 5); + assert.equal(days[1].recordedAt.getTime(), 2_000); + assert.equal(days[1].lifetime?.km, 30); + assert.equal(days[0].lifetime?.km, 35); + assert.equal(days[0].lifetime?.kwh, 6); +}); + +test("the newest manual SoH of a day wins, and a day without one has none", () => { + const days = aggregateDays([ + chunk("2026-10-16", 2_000, 1, 1, 96), + chunk("2026-10-16", 1_000, 1, 1, 98), + chunk("2026-10-17", 3_000, 1, 1), + ]); + assert.equal(days[1].sohPercent, 96); + assert.equal("sohPercent" in days[0], false); +}); diff --git a/web/test/telemetry.test.ts b/web/test/telemetry.test.ts new file mode 100644 index 0000000..7adea8a --- /dev/null +++ b/web/test/telemetry.test.ts @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { pickForForward, resetAbrpState, toTlm } from "../src/lib/abrp.ts"; +import { ValidationError, validateTelemetryBatch } from "../src/lib/validate.ts"; + +const INSTALLATION = "6f1a2b3c-4d5e-4f60-8a9b-0c1d2e3f4a5b"; +const NOW_S = 1_790_000_000; + +test("accepts a sample in ABRP's field names and keeps only what was sent", () => { + const batch = validateTelemetryBatch({ + installationId: INSTALLATION, + samples: [{ utc: NOW_S, soc: 81, power: -12.5, speed: 0, lat: 52.1, lon: 7.6, is_charging: 1, is_dcfc: false }], + }); + assert.deepEqual(batch.samples[0], { + utc: NOW_S, soc: 81, power: -12.5, speed: 0, lat: 52.1, lon: 7.6, is_charging: true, is_dcfc: false, + }); + // An unread signal stays absent: a missing SOC must never become 0. + assert.equal("est_battery_range" in batch.samples[0], false); +}); + +test("drops unknown fields instead of rejecting the batch", () => { + const batch = validateTelemetryBatch({ installationId: INSTALLATION, samples: [{ utc: NOW_S, future_field: 3 }] }); + assert.deepEqual(batch.samples[0], { utc: NOW_S }); +}); + +test("rejects bad timestamps, out-of-range values, bad flags and half positions", () => { + const send = (sample: object) => validateTelemetryBatch({ installationId: INSTALLATION, samples: [sample] }); + assert.throws(() => send({ utc: NOW_S * 1000 }), ValidationError); // milliseconds, not seconds + assert.throws(() => send({ utc: 1.5 }), ValidationError); + assert.throws(() => send({ utc: NOW_S, soc: 101 }), ValidationError); + assert.throws(() => send({ utc: NOW_S, speed: "fast" }), ValidationError); + assert.throws(() => send({ utc: NOW_S, is_parked: 2 }), ValidationError); + assert.throws(() => send({ utc: NOW_S, lat: 52.1 }), ValidationError); +}); + +test("rejects empty and oversized batches and bad installation ids", () => { + assert.throws(() => validateTelemetryBatch({ installationId: INSTALLATION, samples: [] }), ValidationError); + const many = Array.from({ length: 501 }, (_, i) => ({ utc: NOW_S + i })); + assert.throws(() => validateTelemetryBatch({ installationId: INSTALLATION, samples: many }), ValidationError); + assert.throws(() => validateTelemetryBatch({ installationId: "nope", samples: [{ utc: NOW_S }] }), ValidationError); +}); + +test("ABRP gets flags as 0/1", () => { + assert.deepEqual(toTlm({ utc: NOW_S, soc: 50, is_charging: true, is_parked: false }), { + utc: NOW_S, soc: 50, is_charging: 1, is_parked: 0, + }); +}); + +test("forwards only the newest fresh sample, at most once per interval", () => { + resetAbrpState(); + const nowMs = NOW_S * 1000; + const picked = pickForForward([{ utc: NOW_S - 10 }, { utc: NOW_S - 2 }, { utc: NOW_S - 5 }], nowMs); + assert.equal(picked?.utc, NOW_S - 2); + // A sample from ten minutes ago describes a car that has moved on. + assert.equal(pickForForward([{ utc: NOW_S - 600 }], nowMs), null); +}); diff --git a/web/tsconfig.json b/web/tsconfig.json index 90069c0..deb57ab 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -6,6 +6,7 @@ "skipLibCheck": true, "strict": true, "noEmit": true, + "allowImportingTsExtensions": true, "esModuleInterop": true, "module": "esnext", "moduleResolution": "bundler", From c253876ed2707cb7abd2e8a435cfa2fd26755a3e Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Sat, 17 Oct 2026 14:11:58 +0200 Subject: [PATCH 13/79] Add Kubernetes manifests for car.stinnesbeck.com Namespace, InfluxDB 2.7.12 StatefulSet with PVC and a NetworkPolicy that admits only the server, the server Deployment (non-root, read-only root filesystem), a cert-manager Certificate from letsencrypt-prod and a Traefik IngressRoute. Secrets are a template only. Nothing is applied; k8s/README.md has the steps. Server version bumped to 1.1.0. Validated offline with kustomize and kubeconform (including the cert-manager and Traefik CRD schemas). The InfluxDB container was tested locally as UID 1000 with no capabilities. Co-Authored-By: Claude Opus 5.5 --- k8s/README.md | 44 +++++++++++++ k8s/influxdb.yaml | 130 +++++++++++++++++++++++++++++++++++++++ k8s/ingress.yaml | 37 +++++++++++ k8s/kustomization.yaml | 12 ++++ k8s/namespace.yaml | 4 ++ k8s/secrets.example.yaml | 34 ++++++++++ k8s/server.yaml | 103 +++++++++++++++++++++++++++++++ web/package-lock.json | 4 +- web/package.json | 2 +- 9 files changed, 367 insertions(+), 3 deletions(-) create mode 100644 k8s/README.md create mode 100644 k8s/influxdb.yaml create mode 100644 k8s/ingress.yaml create mode 100644 k8s/kustomization.yaml create mode 100644 k8s/namespace.yaml create mode 100644 k8s/secrets.example.yaml create mode 100644 k8s/server.yaml diff --git a/k8s/README.md b/k8s/README.md new file mode 100644 index 0000000..ecbb682 --- /dev/null +++ b/k8s/README.md @@ -0,0 +1,44 @@ +# Kubernetes manifests — car.stinnesbeck.com + +Plain manifests for the car server and its InfluxDB 2. They are only files: nothing here is +applied automatically. Apply them yourself. + +| File | What | +|---|---| +| `namespace.yaml` | Namespace `mg4` | +| `influxdb.yaml` | InfluxDB 2.7.12 StatefulSet (10Gi PVC, default storage class), cluster-internal Service, NetworkPolicy allowing only the server | +| `server.yaml` | Server Deployment (1 replica, non-root, read-only root filesystem) and Service | +| `ingress.yaml` | cert-manager `Certificate` from `letsencrypt-prod`, Traefik `IngressRoute` on `websecure` | +| `secrets.example.yaml` | **Template** for the two Secrets; not in `kustomization.yaml` | +| `kustomization.yaml` | Everything except the Secrets; pins the server image tag | + +## Before the first apply + +1. **Check the ingress class** in `ingress.yaml`. It is set to `traefik-external` as a + placeholder; use the class of your public-facing Traefik. +2. **Create the Secrets.** Copy `secrets.example.yaml` outside the repository, replace every + `CHANGE-ME` (`openssl rand -hex 32` for tokens), then apply the copy: + `influxdb-auth` (InfluxDB admin password and token) and `mg4-server` (ingest token, + session secret, dashboard password, your ABRP API key and user token). +3. **Registry access.** If the `mg4-server` package on git.stinnesbeck.com is private, create + the `git-stinnesbeck-registry` pull secret (command in `secrets.example.yaml`); if it is + public, delete `imagePullSecrets` from `server.yaml`. + +## Apply + +```sh +kubectl apply -f k8s/namespace.yaml +kubectl apply -f +kubectl apply -k k8s/ +``` + +The Let's Encrypt HTTP-01 challenge needs car.stinnesbeck.com to resolve to the public +Traefik before the certificate can be issued; check with `kubectl -n mg4 get certificate`. + +## Notes + +- InfluxDB's setup mode runs only on an empty volume. Changing `admin-token` in the Secret + later does not change the token inside InfluxDB. +- The server uses the InfluxDB admin token. A token scoped to the `mg4` bucket can replace it + later (`influx auth create --read-bucket … --write-bucket …`). +- To upgrade the server, push a new image tag and change `newTag` in `kustomization.yaml`. diff --git a/k8s/influxdb.yaml b/k8s/influxdb.yaml new file mode 100644 index 0000000..72c2555 --- /dev/null +++ b/k8s/influxdb.yaml @@ -0,0 +1,130 @@ +# InfluxDB 2, cluster-internal only. On first start (empty volume) the image's setup mode +# creates the org, the bucket and the admin token from the influxdb-auth Secret; on later +# starts those variables are ignored and the data on the volume wins. +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: influxdb + namespace: mg4 + labels: + app.kubernetes.io/name: influxdb + app.kubernetes.io/part-of: mg4 +spec: + serviceName: influxdb + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: influxdb + template: + metadata: + labels: + app.kubernetes.io/name: influxdb + app.kubernetes.io/part-of: mg4 + spec: + securityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + seccompProfile: + type: RuntimeDefault + containers: + - name: influxdb + image: docker.io/library/influxdb:2.7.12-alpine + ports: + - name: http + containerPort: 8086 + env: + - name: INFLUXD_REPORTING_DISABLED + value: "true" + - name: DOCKER_INFLUXDB_INIT_MODE + value: setup + - name: DOCKER_INFLUXDB_INIT_ORG + value: mg4 + - name: DOCKER_INFLUXDB_INIT_BUCKET + value: mg4 + - name: DOCKER_INFLUXDB_INIT_USERNAME + value: admin + - name: DOCKER_INFLUXDB_INIT_PASSWORD + valueFrom: + secretKeyRef: + name: influxdb-auth + key: admin-password + - name: DOCKER_INFLUXDB_INIT_ADMIN_TOKEN + valueFrom: + secretKeyRef: + name: influxdb-auth + key: admin-token + volumeMounts: + - name: data + mountPath: /var/lib/influxdb2 + - name: config + mountPath: /etc/influxdb2 + readinessProbe: + httpGet: + path: /health + port: http + periodSeconds: 10 + livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 30 + periodSeconds: 30 + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + memory: 1Gi + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + volumes: + - name: config + emptyDir: {} + volumeClaimTemplates: + - metadata: + name: data + spec: + accessModes: ["ReadWriteOnce"] + # No storageClassName: the cluster's default class is used. + resources: + requests: + storage: 10Gi +--- +apiVersion: v1 +kind: Service +metadata: + name: influxdb + namespace: mg4 + labels: + app.kubernetes.io/name: influxdb + app.kubernetes.io/part-of: mg4 +spec: + selector: + app.kubernetes.io/name: influxdb + ports: + - name: http + port: 8086 + targetPort: http +--- +# Only the server may reach InfluxDB. Has no effect on a CNI without NetworkPolicy support. +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: influxdb-from-server-only + namespace: mg4 +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: influxdb + policyTypes: ["Ingress"] + ingress: + - from: + - podSelector: + matchLabels: + app.kubernetes.io/name: mg4-server + ports: + - port: 8086 diff --git a/k8s/ingress.yaml b/k8s/ingress.yaml new file mode 100644 index 0000000..ff60931 --- /dev/null +++ b/k8s/ingress.yaml @@ -0,0 +1,37 @@ +# TLS certificate from cert-manager. cert-manager does not watch Traefik IngressRoutes, so the +# certificate is requested explicitly and the IngressRoute references its Secret. +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: car-stinnesbeck-com + namespace: mg4 +spec: + secretName: car-stinnesbeck-com-tls + dnsNames: + - car.stinnesbeck.com + issuerRef: + kind: ClusterIssuer + name: letsencrypt-prod +--- +apiVersion: traefik.io/v1alpha1 +kind: IngressRoute +metadata: + name: mg4-server + namespace: mg4 + annotations: + # CHECK: the class of your public-facing Traefik. Your internal example uses + # "traefik-internal"; this host must be served by the external one. + kubernetes.io/ingress.class: traefik-external +spec: + entryPoints: + - websecure + routes: + - kind: Rule + match: Host(`car.stinnesbeck.com`) + services: + - kind: Service + name: mg4-server + namespace: mg4 + port: 80 + tls: + secretName: car-stinnesbeck-com-tls diff --git a/k8s/kustomization.yaml b/k8s/kustomization.yaml new file mode 100644 index 0000000..7a45bf6 --- /dev/null +++ b/k8s/kustomization.yaml @@ -0,0 +1,12 @@ +# Render with `kustomize build k8s/` (or `kubectl kustomize k8s/`) and apply it yourself. +# The Secrets are not included: create them from secrets.example.yaml first. +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - namespace.yaml + - influxdb.yaml + - server.yaml + - ingress.yaml +images: + - name: git.stinnesbeck.com/nils/mg4-server + newTag: "1.1.0" diff --git a/k8s/namespace.yaml b/k8s/namespace.yaml new file mode 100644 index 0000000..dc634fe --- /dev/null +++ b/k8s/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: mg4 diff --git a/k8s/secrets.example.yaml b/k8s/secrets.example.yaml new file mode 100644 index 0000000..9b7dd47 --- /dev/null +++ b/k8s/secrets.example.yaml @@ -0,0 +1,34 @@ +# TEMPLATE ONLY — not part of kustomization.yaml. Copy it outside the repository, fill in +# real values, and apply that copy yourself. Never commit the filled-in file. +# +# openssl rand -hex 32 for every token and secret below +apiVersion: v1 +kind: Secret +metadata: + name: influxdb-auth + namespace: mg4 +type: Opaque +stringData: + admin-password: CHANGE-ME-at-least-8-characters + admin-token: CHANGE-ME-openssl-rand-hex-32 +--- +apiVersion: v1 +kind: Secret +metadata: + name: mg4-server + namespace: mg4 +type: Opaque +stringData: + # The car's write token, entered in the app. At least 24 characters. + INGEST_TOKEN: CHANGE-ME-openssl-rand-hex-32 + SESSION_SECRET: CHANGE-ME-openssl-rand-hex-32 + DASHBOARD_PASSWORD: CHANGE-ME-at-least-8-characters + # Your ABRP credentials. Leave either empty to switch forwarding off. + ABRP_API_KEY: CHANGE-ME + ABRP_USER_TOKEN: CHANGE-ME + ABRP_MIN_INTERVAL_SECONDS: "5" +--- +# Pull secret for a private image on git.stinnesbeck.com. Easier to create with: +# kubectl -n mg4 create secret docker-registry git-stinnesbeck-registry \ +# --docker-server=git.stinnesbeck.com --docker-username= --docker-password= +# If the package is public, remove imagePullSecrets from server.yaml instead. diff --git a/k8s/server.yaml b/k8s/server.yaml new file mode 100644 index 0000000..01c7039 --- /dev/null +++ b/k8s/server.yaml @@ -0,0 +1,103 @@ +# The car server. One replica on purpose: the ABRP rate limit is kept in memory, and two +# replicas would each forward to ABRP at the full rate. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: mg4-server + namespace: mg4 + labels: + app.kubernetes.io/name: mg4-server + app.kubernetes.io/part-of: mg4 +spec: + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + app.kubernetes.io/name: mg4-server + template: + metadata: + labels: + app.kubernetes.io/name: mg4-server + app.kubernetes.io/part-of: mg4 + spec: + # Needed only if the image is private on git.stinnesbeck.com; see secrets.example.yaml. + imagePullSecrets: + - name: git-stinnesbeck-registry + securityContext: + runAsNonRoot: true + runAsUser: 10001 + runAsGroup: 10001 + seccompProfile: + type: RuntimeDefault + containers: + - name: server + image: git.stinnesbeck.com/nils/mg4-server:latest # pinned by kustomization.yaml + ports: + - name: http + containerPort: 3000 + env: + - name: INFLUX_URL + value: http://influxdb.mg4.svc:8086 + - name: INFLUX_ORG + value: mg4 + - name: INFLUX_BUCKET + value: mg4 + - name: INFLUX_TOKEN + valueFrom: + secretKeyRef: + name: influxdb-auth + key: admin-token + # INGEST_TOKEN, SESSION_SECRET, DASHBOARD_PASSWORD, ABRP_API_KEY, ABRP_USER_TOKEN. + envFrom: + - secretRef: + name: mg4-server + readinessProbe: + httpGet: + path: /v1/health + port: http + periodSeconds: 10 + livenessProbe: + httpGet: + path: /v1/health + port: http + initialDelaySeconds: 10 + periodSeconds: 30 + resources: + requests: + cpu: 50m + memory: 128Mi + limits: + memory: 512Mi + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + volumeMounts: + # Next.js writes its fetch/image cache here; the rest of the filesystem is read-only. + - name: next-cache + mountPath: /app/.next/cache + - name: tmp + mountPath: /tmp + volumes: + - name: next-cache + emptyDir: {} + - name: tmp + emptyDir: {} +--- +apiVersion: v1 +kind: Service +metadata: + name: mg4-server + namespace: mg4 + labels: + app.kubernetes.io/name: mg4-server + app.kubernetes.io/part-of: mg4 +spec: + selector: + app.kubernetes.io/name: mg4-server + ports: + - name: http + port: 80 + targetPort: http diff --git a/web/package-lock.json b/web/package-lock.json index 8ad8c25..1dcb4ec 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "fatihsmg4-web", - "version": "1.0.0", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "fatihsmg4-web", - "version": "1.0.0", + "version": "1.1.0", "dependencies": { "@influxdata/influxdb-client": "^1.35.0", "next": "^15.5.4", diff --git a/web/package.json b/web/package.json index 8854c8f..a061eb4 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "fatihsmg4-web", - "version": "1.0.0", + "version": "1.1.0", "private": true, "scripts": { "dev": "next dev", From 6145736c9766f8ef4d269fa113421bca1333c43e Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Sat, 17 Oct 2026 14:23:04 +0200 Subject: [PATCH 14/79] App 2.3.0: Server tab, live telemetry to the car server - New Server tab: server URL, ingest token and installation id, with Save, Test connection (health, then a read-only token check) and the config-file import that upstream removed (app folder / USB stick first, document picker as fallback). Settings are kept encrypted and read on every use, so the service picks up a change without a restart. Each installation generates its own id; the inherited upstream UUID and the baked-in CONSUMPTION_API_* build fields are gone. - Live telemetry restored from before ea8032a (TelemetryPayload, UploadCadence, UploadSettings, GPS) and sent in batches to POST /v1/telemetry instead of ABRP; the server forwards to ABRP. Adds ext_temp, voltage and current. Unsent samples queue in memory (about 8 h) while offline. - Upload tab: the live-telemetry switch and cadence controls work again; the dead service and autostart switches are removed. - Manifest: location permissions and foreground service types. - Unit tests compile again: 114 (stable) / 136 (unstable) pass. Tests for the deleted AbrpApi and HeadUnitWeather removed; new tests for ServerConfig and TelemetryQueue. Lint passes. Not yet tried on the car. Co-Authored-By: Claude Opus 5.5 --- SPECS.md | 56 +-- app/build.gradle | 14 +- app/src/main/AndroidManifest.xml | 8 +- .../com/evsuite/abrp/AbrpUploadService.java | 31 +- .../java/com/evsuite/abrp/ConfigImport.java | 127 ++++++ .../evsuite/abrp/ConsumptionCloudClient.java | 45 +-- .../java/com/evsuite/abrp/MainActivity.java | 379 +++++++++++++++++- .../java/com/evsuite/abrp/SecurePrefs.java | 48 +++ .../java/com/evsuite/abrp/ServerConfig.java | 100 +++++ .../com/evsuite/abrp/TelemetryPayload.java | 213 ++++++++++ .../java/com/evsuite/abrp/TelemetryQueue.java | 59 +++ .../com/evsuite/abrp/TelemetryUploader.java | 264 ++++++++++++ .../java/com/evsuite/abrp/UploadCadence.java | 66 +++ .../java/com/evsuite/abrp/UploadSettings.java | 77 +++- app/src/main/res/drawable/ic_tab_server.xml | 23 ++ .../main/res/layout-w1000dp/activity_main.xml | 1 + .../main/res/layout-w1000dp/pane_service.xml | 5 +- app/src/main/res/layout/activity_main.xml | 1 + app/src/main/res/layout/pane_server.xml | 30 ++ app/src/main/res/layout/pane_service.xml | 5 +- app/src/main/res/values/strings.xml | 48 +-- app/src/main/res/values/strings_design.xml | 8 +- .../java/com/evsuite/abrp/AbrpApiTest.java | 50 --- .../com/evsuite/abrp/ConfigImportTest.java | 23 +- .../com/evsuite/abrp/HeadUnitWeatherTest.java | 109 ----- .../com/evsuite/abrp/ServerConfigTest.java | 47 +++ .../com/evsuite/abrp/TelemetryQueueTest.java | 40 ++ 27 files changed, 1590 insertions(+), 287 deletions(-) create mode 100644 app/src/main/java/com/evsuite/abrp/ConfigImport.java create mode 100644 app/src/main/java/com/evsuite/abrp/SecurePrefs.java create mode 100644 app/src/main/java/com/evsuite/abrp/ServerConfig.java create mode 100644 app/src/main/java/com/evsuite/abrp/TelemetryPayload.java create mode 100644 app/src/main/java/com/evsuite/abrp/TelemetryQueue.java create mode 100644 app/src/main/java/com/evsuite/abrp/TelemetryUploader.java create mode 100644 app/src/main/java/com/evsuite/abrp/UploadCadence.java create mode 100644 app/src/main/res/drawable/ic_tab_server.xml create mode 100644 app/src/main/res/layout/pane_server.xml delete mode 100644 app/src/test/java/com/evsuite/abrp/AbrpApiTest.java delete mode 100644 app/src/test/java/com/evsuite/abrp/HeadUnitWeatherTest.java create mode 100644 app/src/test/java/com/evsuite/abrp/ServerConfigTest.java create mode 100644 app/src/test/java/com/evsuite/abrp/TelemetryQueueTest.java diff --git a/SPECS.md b/SPECS.md index a315240..3991e21 100644 --- a/SPECS.md +++ b/SPECS.md @@ -27,7 +27,7 @@ car ──live telemetry───▶ car.stinnesbeck.com ──▶ ABRP api.ite | Measurement | Timestamp | Tags | Fields | |---|---|---|---| | `telemetry` | sample time | — | soc, power, speed, lat, lon, heading, elevation, is_charging, is_dcfc, is_parked, capacity, soe, odometer, est_battery_range, batt_temp, cabin_temp, ext_temp, hvac_setpoint, voltage, current, tire_pressure_fl/fr/rl/rr | - | `consumption_daily` | the day, 00:00 UTC | — | km, kwh, hours, socDrop, sohPercent | + | `consumption_chunk` | recordedAt | date | km, kwh, hours, socDrop, sohPercent (days summed on read) | | `charging_session` | startedAt | sessionId | endedAt, durationSeconds, energyKwh, gridEnergyKwh, pricePerKwh, totalCost, start/endSocPercent | | `charging_curve` | point time | sessionId | powerKw, socPercent | @@ -40,15 +40,16 @@ car ──live telemetry───▶ car.stinnesbeck.com ──▶ ABRP api.ite retried, since stale live data is useless to ABRP. Off when the token is unset. - **Existing endpoints stay:** `PUT /v1/consumption/daily`, `PUT /v1/charging-sessions/:id`, `GET /v1/consumption/history/:id`, the dashboard and `/v1/health`. -- **Self-hosting:** drop the Vercel assumptions, `output: "standalone"`, a `Dockerfile`, and - `ensureIndexes()` replaced by bucket checks at startup. +- **Self-hosting:** `output: "standalone"`, a `Dockerfile`, and InfluxDB's own overwrite semantics + instead of `ensureIndexes()`. ### Car app - Restore the live telemetry payload and upload cadence from before `ea8032a` (`TelemetryPayload`, `UploadCadence`, location access), sending to the server instead of ABRP. -- Server URL and ingest token: open question, see below. -- Generate our own `CONSUMPTION_INSTALLATION_ID`. +- Server URL, ingest token and installation id are entered in a new **Server** tab or imported from + a config file; nothing about the server is compiled into the APK. Each installation generates + its own id. ### Image and deployment @@ -59,15 +60,23 @@ car ──live telemetry───▶ car.stinnesbeck.com ──▶ ABRP api.ite with placeholder values only. - **Nothing is ever applied to the cluster by us.** Nils applies the manifests. -### Open questions +### Decisions (2026-10-17) -- [ ] Run InfluxDB inside the cluster from our manifests (assumed), or use an existing instance? -- [ ] Ingress class and TLS setup (e.g. cert-manager `ClusterIssuer` name), or an existing wildcard - certificate Secret? -- [ ] Car config: bake the server URL and ingest token into the APK at build time (as upstream - does), or enter them in the app, which restores the settings tab with the config-file import? - In the app keeps the token out of the APK. -- [ ] ABRP API key: the open-source default key from upstream, or our own? +- InfluxDB 2 runs in the cluster from our manifests. +- TLS: cert-manager `ClusterIssuer` `letsencrypt-prod`; Traefik `IngressRoute` on `websecure`. +- Car config is entered in the app (Server tab / config file), not baked into the APK. +- The ABRP API key and user token are set by Nils in the `mg4-server` Secret. + +### Status + +- [x] Server on InfluxDB 2 with `/v1/telemetry` and the ABRP forwarder (`web/`), tested locally + against InfluxDB 2.7.12. Image builds. +- [x] Manifests in `k8s/`, validated offline. **Not applied** — that is Nils' step (`k8s/README.md`). +- [x] Car app 2.3.0: Server tab with config import, live telemetry upload, cadence controls. + Unit tests and lint pass; **not yet tried on the car**. +- [ ] Push the image to git.stinnesbeck.com (needs a registry login on the build machine). +- [ ] Check the Traefik ingress class in `k8s/ingress.yaml` (placeholder `traefik-external`). +- [ ] First drive with 2.3.0: telemetry arrives in InfluxDB and at ABRP. ## To do @@ -81,19 +90,12 @@ car ──live telemetry───▶ car.stinnesbeck.com ──▶ ABRP api.ite (`ALLOWED_HOSTS`). For git.stinnesbeck.com it needs the host on the allowlist, and the response parsing checked against that server's releases API. Release assets must be named `NilsMG4-unstable-.apk` with a `.sha256` sidecar (`build-car` writes both). -- [ ] **ABRP upload is gone upstream.** Upstream commit `ea8032a` ("chore", 2026-10-09) removed the - ABRP tab, the config-file import (`ConfigImport`), the upload (`AbrpApi`, `TelemetryPayload`, - `UploadCadence`), `SecurePrefs` and `HeadUnitWeather`. The app now only tracks consumption - and syncs it to the MongoDB API. Leftover `strings.xml` entries (`btn_import`, `import_*`, - `hint_api_key`, …) belong to it. Decide whether to restore it. -- [ ] **Fix the unit tests.** They don't compile upstream: `AbrpApiTest`, `TelemetryPayloadTest` - and others still use `AbrpApi`/`TelemetryPayload`, which upstream commit `ea8032a` deleted. - [ ] **Charging currency.** Prices and costs are shown in Turkish lira (`₺/kWh`, `₺`) in `pane_charging.xml` and `MainActivity.java`. -- [ ] **Cloud consumption sync.** `CONSUMPTION_INSTALLATION_ID` in `app/build.gradle` is a fixed - UUID inherited from upstream. Set our own before configuring `CONSUMPTION_API_URL` / - `CONSUMPTION_API_TOKEN`, so our data cannot land under upstream's id. -- [ ] **Translate the rest.** The `web/` dashboard (Next.js) and docs (`CHANGELOG.md`, `OTA.md`'s +- [ ] **Dashboard language and branding.** The `web/` pages are still Turkish, say "FatihsMG4" and + show prices in lira. +- [ ] **Telemetry queue survives restarts.** Unsent live samples are kept in memory only. +- [ ] **Translate the rest.** The docs (`CHANGELOG.md`, `OTA.md`'s message table, comments in `mise.toml`/`app/build.gradle`) are still Turkish or French. - [ ] **Docs still use the old package ID.** `README.md`, `OTA.md`, `CHANGELOG.md`, `mise.toml` and `docs/MG4_PLATFORM_INSTALL.md` refer to `com.evsuite.abrp(.unstable)` and `FatihsMG4-*.apk`. @@ -109,6 +111,12 @@ car ──live telemetry───▶ car.stinnesbeck.com ──▶ ABRP api.ite - 2026-10-17 — Renamed to "Nils' MG4": package ID `com.stinnesbeck.nilsmg4` (`.unstable` for the car build), APK names `NilsMG4-*.apk`. It installs next to the old FatihsMG4 app instead of replacing it, and starts with empty settings and history. +- 2026-10-17 — Server rewritten for InfluxDB 2 with live telemetry and ABRP forwarding; Dockerfile; + Kubernetes manifests (not applied). +- 2026-10-17 — App 2.3.0: Server tab (URL, ingest token, installation id, config import), live + telemetry restored from before `ea8032a` and sent to the server, Upload-tab cadence controls + reconnected, dead switches removed. The removed ABRP classes' unit tests compile and pass again + (AbrpApi and HeadUnitWeather tests deleted with their classes). - 2026-10-17 — Temporary on-screen diagnostics (b2a5b32) to debug blank values without adb; removed again (1002962) once values showed on the car. Revert 1002962 to bring them back. diff --git a/app/build.gradle b/app/build.gradle index a82f37f..0c0fc65 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -34,17 +34,11 @@ android { applicationId "com.stinnesbeck.nilsmg4" minSdk 27 targetSdk 36 - versionCode 56 - versionName "2.2.39" + versionCode 57 + versionName "2.3.0" - // 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') ?: ''}\"" - // Fixed installation identity: baked into every build so an APK wipe cannot orphan - // the cloud history under a new random UUID. The car's own consumption history lives - // under this id in the daily_consumption collection. - buildConfigField "String", "CONSUMPTION_INSTALLATION_ID", "\"226b1614-4d18-48f0-8938-3609cfa3db93\"" + // Nothing about the server ships in the APK: its URL and ingest token are entered in + // the app's Server tab (ServerConfig), and the database is only reachable by the server. testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 608eaba..36b8413 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -8,6 +8,11 @@ + + + + + + + + + diff --git a/app/src/main/res/layout-w1000dp/activity_main.xml b/app/src/main/res/layout-w1000dp/activity_main.xml index c1cae25..bd06d3b 100644 --- a/app/src/main/res/layout-w1000dp/activity_main.xml +++ b/app/src/main/res/layout-w1000dp/activity_main.xml @@ -10,6 +10,7 @@ + diff --git a/app/src/main/res/layout-w1000dp/pane_service.xml b/app/src/main/res/layout-w1000dp/pane_service.xml index 9eced4d..0d904ea 100644 --- a/app/src/main/res/layout-w1000dp/pane_service.xml +++ b/app/src/main/res/layout-w1000dp/pane_service.xml @@ -3,12 +3,9 @@ - - + - - diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 4a92f4f..a0ea4c8 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -17,6 +17,7 @@ + diff --git a/app/src/main/res/layout/pane_server.xml b/app/src/main/res/layout/pane_server.xml new file mode 100644 index 0000000..455efa2 --- /dev/null +++ b/app/src/main/res/layout/pane_server.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/pane_service.xml b/app/src/main/res/layout/pane_service.xml index 9d8557f..cfdda55 100644 --- a/app/src/main/res/layout/pane_service.xml +++ b/app/src/main/res/layout/pane_service.xml @@ -3,12 +3,9 @@ - - + - - diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 341259d..ef89248 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2,7 +2,6 @@ Nils\' MG4 - ABRP Upload Overview Consumption @@ -11,6 +10,7 @@ Since start, motor, lifetime and trip counters Charging Live battery values and SOC/power curve + Server Wi-Fi Logs @@ -42,45 +42,45 @@ Front Right Rear Left Rear Right - Configuration - ABRP API Key - 8cfc314b-03cd-4efe-ab7d-4431cd8f2e2d - Default: open-source ABRP telemetry key. You may replace it with your own. - Enter your ABRP API key first - ABRP User Token - Find your token in the ABRP app: Settings → Connections → Generic (Manual Entry) - Show or hide API key - Show or hide user token Save Test connection Import from file + + Car server + Server URL + For example https://car.stinnesbeck.com + Ingest token + The server\'s INGEST_TOKEN, at least 24 characters + Installation ID + Your data is stored under this ID. Keep it when reinstalling. + Enter an https:// URL + The token must be at least 24 characters, without spaces + Not a valid ID (UUID) + Server settings saved + Server not configured — open the Server tab + Put a text file with server_url and token in %1$s (on the head unit or a USB stick in the same folder), then press Import. + - Config imported — press Test to verify + Config imported and saved — press Test to verify Could not read that file - No ABRP settings found in that file + No settings found in that file No config file found. Copy it to %1$s Testing… - Connected — API responding OK - Auth failed — check your API key and token + Connected — server accepts the token + Token rejected — check it against the server\'s INGEST_TOKEN Location denied — telemetry will have no position. Grant it in Settings to send GPS to ABRP. Vehicle data denied — no battery %%, speed or range will be sent. Grant it in Settings. Location and vehicle data denied — only a timestamp will be sent. Grant both in Settings. No internet connection Connection timed out Server error (HTTP %1$d) - Enter your ABRP token first - Upload Service - Enable background telemetry - Upload telemetry to ABRP - Turn this off when DiPlay sends ABRP data. Local consumption tracking and background telemetry keep running. - Start automatically with the car - Uploading resumes on its own when the head unit powers up. Turn this off to start it by hand from this screen. + Send live telemetry to the server + The server stores it and forwards it to ABRP. Turn this off when another app feeds ABRP; consumption and charging tracking keep running. - Service not running Upload frequency @@ -100,8 +100,8 @@ Stopped Starting… Running - Tracking locally — ABRP upload off - Tracking locally — ABRP upload off + Tracking locally — live telemetry off + Tracking locally — live telemetry off In error — last %1$d uploads failed diff --git a/app/src/main/res/values/strings_design.xml b/app/src/main/res/values/strings_design.xml index 8cce1d3..dbadaea 100644 --- a/app/src/main/res/values/strings_design.xml +++ b/app/src/main/res/values/strings_design.xml @@ -7,8 +7,8 @@ ON THE ROAD, ELECTRIC Your drive at a glance. Data from your car, all in one place. - Connect to your route. - Link your car with your ABRP account. + Where your data goes. + Your own server stores it and passes it on to ABRP. You focus on the road. Your data stays up to date in the background. Stay connected. @@ -18,9 +18,9 @@ BATTERY ENERGY AND TEMPERATURE TIRE PRESSURES - ACCOUNT LINK + SERVER LINK Your private connection. - The token links your car to your ABRP account. Your credentials are stored encrypted on this device. + The token lets the car write to your server. It is stored encrypted on this device; your ABRP credentials stay on the server. Values that cannot be read are shown as —. UPLOAD CONTROL UPDATE FREQUENCY diff --git a/app/src/test/java/com/evsuite/abrp/AbrpApiTest.java b/app/src/test/java/com/evsuite/abrp/AbrpApiTest.java deleted file mode 100644 index 6852511..0000000 --- a/app/src/test/java/com/evsuite/abrp/AbrpApiTest.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.evsuite.abrp; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import org.junit.Test; - -/** - * [T-912] Credentials must reach ABRP in the request body. A URL is logged by - * proxies, CDNs and crash reporters; a body is not. - */ -public class AbrpApiTest { - - @Test - public void endpointUrlCarriesNoCredentials() { - assertFalse(AbrpApi.API_URL.contains("?")); - assertFalse(AbrpApi.API_URL.contains("api_key")); - assertFalse(AbrpApi.API_URL.contains("token")); - } - - @Test - public void endpointIsHttps() { - assertTrue(AbrpApi.API_URL.startsWith("https://")); - assertTrue(AbrpApi.VERIFY_URL.startsWith("https://")); - } - - /** [T-913] The Test button must not be able to write telemetry. */ - @Test - public void credentialCheckDoesNotTargetTheTelemetryWriteEndpoint() { - assertFalse("le test de connexion ne doit jamais viser /tlm/send", - AbrpApi.VERIFY_URL.contains("/tlm/send")); - assertTrue(AbrpApi.VERIFY_URL.contains("get_next_charge")); - } - - @Test - public void bodyCarriesAllThreeParameters() throws Exception { - String body = AbrpApi.formBody("KEY", "TOK", "{\"utc\":1}"); - assertTrue(body.contains("api_key=KEY")); - assertTrue(body.contains("token=TOK")); - assertTrue(body.contains("tlm=")); - } - - @Test - public void bodyIsFormEncoded() throws Exception { - // A token with URL-significant characters must not break the body layout. - String body = AbrpApi.formBody("a+b/c", "d&e=f", "{\"utc\":1}"); - assertEquals("api_key=a%2Bb%2Fc&token=d%26e%3Df&tlm=%7B%22utc%22%3A1%7D", body); - } -} diff --git a/app/src/test/java/com/evsuite/abrp/ConfigImportTest.java b/app/src/test/java/com/evsuite/abrp/ConfigImportTest.java index 61dff8f..bde9e65 100644 --- a/app/src/test/java/com/evsuite/abrp/ConfigImportTest.java +++ b/app/src/test/java/com/evsuite/abrp/ConfigImportTest.java @@ -8,34 +8,37 @@ import static org.junit.Assert.assertTrue; import org.junit.Test; /** - * ABRP config file parsing — pure JVM, no Android needed. + * Config file parsing — pure JVM, no Android needed. * - * The file exists so a user does not have to type a long api_key and token on the car's + * The file exists so a user does not have to type a long server URL and token on the car's * on-screen keyboard, so the parser must be forgiving about whitespace, case and comments * while never inventing a value the file did not set. */ public class ConfigImportTest { @Test - public void parsesCredentials() { - ConfigImport c = ConfigImport.parse("api_key = abc123\ntoken = def456\n"); - assertEquals("abc123", c.apiKey); + public void parsesServerSettings() { + ConfigImport c = ConfigImport.parse("server_url = https://car.example.com\ntoken = def456\n" + + "installation_id = 6f1a2b3c-4d5e-4f60-8a9b-0c1d2e3f4a5b\n"); + assertEquals("https://car.example.com", c.serverUrl); assertEquals("def456", c.token); + assertEquals("6f1a2b3c-4d5e-4f60-8a9b-0c1d2e3f4a5b", c.installationId); assertFalse(c.isEmpty()); } @Test public void ignoresCommentsBlankLinesAndCase() { ConfigImport c = ConfigImport.parse( - "# my config\n\n API_KEY = key \n# token below\nTOKEN=tok\n"); - assertEquals("key", c.apiKey); + "# my config\n\n SERVER_URL = https://x.example \n# token below\nTOKEN=tok\n"); + assertEquals("https://x.example", c.serverUrl); assertEquals("tok", c.token); } @Test public void absentKeysStayNullSoNothingIsWiped() { ConfigImport c = ConfigImport.parse("token = only-token\n"); - assertNull(c.apiKey); + assertNull(c.serverUrl); + assertNull(c.installationId); assertEquals("only-token", c.token); assertNull(c.intervalSec); assertNull(c.boostLowSoc); @@ -80,8 +83,8 @@ public class ConfigImportTest { @Test public void aliasKeysAreAccepted() { - ConfigImport c = ConfigImport.parse("apikey = k\nuser_token = t\n"); - assertEquals("k", c.apiKey); + ConfigImport c = ConfigImport.parse("url = https://k.example\ningest_token = t\n"); + assertEquals("https://k.example", c.serverUrl); assertEquals("t", c.token); } } diff --git a/app/src/test/java/com/evsuite/abrp/HeadUnitWeatherTest.java b/app/src/test/java/com/evsuite/abrp/HeadUnitWeatherTest.java deleted file mode 100644 index 2f8f45e..0000000 --- a/app/src/test/java/com/evsuite/abrp/HeadUnitWeatherTest.java +++ /dev/null @@ -1,109 +0,0 @@ -package com.evsuite.abrp; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertTrue; - -import org.junit.Test; - -/** - * The broadcast parser, on the JVM. - * - * What is under test is not the vendor's payload — that shape is unverified and may well be - * something else on the car. It is that the parser finds a temperature wherever the payload - * puts it, refuses to invent one, and says what it was given when it fails, because the - * diagnostic is the only thing that reaches a driver with no adb. - */ -public class HeadUnitWeatherTest { - - @Test - public void readsATemperatureAndThePlaceItIsFor() { - HeadUnitWeather weather = new HeadUnitWeather(); - weather.acceptPayload(( - "{\"code\":0,\"weather\":{\"temperature\":27,\"unit\":\"C\"," - + "\"address\":\"Toulouse\",\"conditionType\":1}}")); - assertEquals(27f, weather.temperatureC(), 0.01f); - assertEquals("Toulouse", weather.place()); - // The diagnostic carries the reading's age: a broadcast arrives when the weather app - // refreshes, so "where from" without "how long ago" does not say whether to trust it. - assertEquals("Toulouse/0min", weather.diagnostic()); - } - - @Test - public void findsTheValueWhereverThePayloadNests_it() { - HeadUnitWeather weather = new HeadUnitWeather(); - weather.acceptPayload(( - "{\"data\":{\"current\":[{\"tempValue\":\"18.5\",\"tempUnit\":\"C\"}]}}")); - assertEquals(18.5f, weather.temperatureC(), 0.01f); - } - - @Test - public void convertsAFahrenheitPayload() { - HeadUnitWeather weather = new HeadUnitWeather(); - weather.acceptPayload(("{\"temperature\":81,\"temperatureUnit\":\"F\"}")); - assertEquals(27.2f, weather.temperatureC(), 0.1f); - } - - @Test - public void anUnnamedUnitIsTakenAsCelsius() { - HeadUnitWeather weather = new HeadUnitWeather(); - weather.acceptPayload(("{\"temp\":21}")); - assertEquals(21f, weather.temperatureC(), 0.01f); - } - - @Test - public void aPayloadWithoutATemperatureReportsWhatItWasGiven() { - HeadUnitWeather weather = new HeadUnitWeather(); - weather.acceptPayload(("{\"conditionType\":3,\"address\":\"Toulouse\"}")); - assertNull(weather.temperatureC()); - assertTrue(weather.diagnostic(), weather.diagnostic().startsWith("no temperature in {")); - assertTrue(weather.diagnostic(), weather.diagnostic().contains("conditionType")); - } - - @Test - public void readsThePlaceFromACityKeyToo() { - HeadUnitWeather weather = new HeadUnitWeather(); - weather.acceptPayload("{\"temp\":14,\"city\":\"Auzeville\"}"); - assertEquals(14f, weather.temperatureC(), 0.01f); - assertEquals("Auzeville", weather.place()); - } - - @Test - public void aBroadcastWithNothingReadableIsReportedNotGuessed() { - HeadUnitWeather weather = new HeadUnitWeather(); - weather.acceptPayload(null); - assertNull(weather.temperatureC()); - assertEquals("broadcast carried no JSON", weather.diagnostic()); - } - - @Test - public void beforeAnyBroadcastTheDiagnosticSaysSo() { - HeadUnitWeather weather = new HeadUnitWeather(); - assertNull(weather.temperatureC()); - assertEquals("no broadcast yet", weather.diagnostic()); - } - - @Test - public void malformedJsonDoesNotEscapeTheReceiver() { - HeadUnitWeather weather = new HeadUnitWeather(); - weather.acceptPayload(("{not json at all")); - assertNull(weather.temperatureC()); - assertTrue(weather.diagnostic(), weather.diagnostic().contains("reading broadcast")); - } - - @Test - public void aRestoredReadingIsReportedWithItsAge() { - HeadUnitWeather weather = new HeadUnitWeather(); - weather.restore(19.5f, "Toulouse", System.currentTimeMillis() - 45 * 60 * 1000L); - assertEquals(19.5f, weather.temperatureC(), 0.01f); - assertEquals("Toulouse/45min", weather.diagnostic()); - } - - @Test - public void anAbsentStickyBroadcastIsNotMistakenForAFailedParse() { - HeadUnitWeather weather = new HeadUnitWeather(); - weather.accept(null); - assertNull(weather.temperatureC()); - assertEquals("none held, none sent yet", weather.diagnostic()); - } -} diff --git a/app/src/test/java/com/evsuite/abrp/ServerConfigTest.java b/app/src/test/java/com/evsuite/abrp/ServerConfigTest.java new file mode 100644 index 0000000..f5bf0c8 --- /dev/null +++ b/app/src/test/java/com/evsuite/abrp/ServerConfigTest.java @@ -0,0 +1,47 @@ +package com.evsuite.abrp; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +/** What the Server tab and the config import accept — pure JVM. */ +public class ServerConfigTest { + + @Test + public void urlIsTrimmedAndLosesTrailingSlashes() { + assertEquals("https://car.example.com", ServerConfig.normalizeUrl(" https://car.example.com// ")); + assertEquals("https://car.example.com/base", ServerConfig.normalizeUrl("https://car.example.com/base/")); + } + + @Test + public void plainHttpAndJunkAreRefused() { + // The token travels in a header; over plain http anyone on the hotspot could read it. + assertNull(ServerConfig.normalizeUrl("http://car.example.com")); + assertNull(ServerConfig.normalizeUrl("car.example.com")); + assertNull(ServerConfig.normalizeUrl("https://")); + assertNull(ServerConfig.normalizeUrl("https:///path")); + assertNull(ServerConfig.normalizeUrl("https://user@car.example.com")); + assertNull(ServerConfig.normalizeUrl("https://car.example.com/?x=1")); + assertNull(ServerConfig.normalizeUrl(null)); + } + + @Test + public void tokenMustMeetTheServersMinimum() { + assertTrue(ServerConfig.isValidToken("0123456789abcdef01234567")); + assertFalse(ServerConfig.isValidToken("too-short")); + assertFalse(ServerConfig.isValidToken("0123456789abcdef 01234567")); + assertFalse(ServerConfig.isValidToken(null)); + } + + @Test + public void installationIdMustBeAUuidTheServerAccepts() { + assertTrue(ServerConfig.isValidInstallationId("6f1a2b3c-4d5e-4f60-8a9b-0c1d2e3f4a5b")); + assertTrue(ServerConfig.isValidInstallationId(java.util.UUID.randomUUID().toString())); + assertFalse(ServerConfig.isValidInstallationId("6f1a2b3c-4d5e-0f60-8a9b-0c1d2e3f4a5b")); // version 0 + assertFalse(ServerConfig.isValidInstallationId("not-a-uuid")); + assertFalse(ServerConfig.isValidInstallationId("")); + } +} diff --git a/app/src/test/java/com/evsuite/abrp/TelemetryQueueTest.java b/app/src/test/java/com/evsuite/abrp/TelemetryQueueTest.java new file mode 100644 index 0000000..823a491 --- /dev/null +++ b/app/src/test/java/com/evsuite/abrp/TelemetryQueueTest.java @@ -0,0 +1,40 @@ +package com.evsuite.abrp; + +import static org.junit.Assert.assertEquals; + +import java.util.List; + +import org.junit.Test; + +/** The offline buffer for live samples — pure JVM. */ +public class TelemetryQueueTest { + + @Test + public void batchesOldestFirstAndKeepsThemUntilConfirmed() { + TelemetryQueue queue = new TelemetryQueue(); + queue.add("a"); + queue.add("b"); + assertEquals(List.of("a", "b"), queue.peekBatch()); + // Not confirmed yet: a failed request must not lose them. + assertEquals(2, queue.size()); + queue.remove(2); + assertEquals(0, queue.size()); + } + + @Test + public void aBatchNeverExceedsWhatTheServerAccepts() { + TelemetryQueue queue = new TelemetryQueue(); + for (int i = 0; i < 1200; i++) queue.add(String.valueOf(i)); + List batch = queue.peekBatch(); + assertEquals(TelemetryQueue.MAX_BATCH, batch.size()); + assertEquals("0", batch.get(0)); + } + + @Test + public void whenFullTheOldestSamplesAreDropped() { + TelemetryQueue queue = new TelemetryQueue(3); + for (String s : new String[]{"a", "b", "c", "d", "e"}) queue.add(s); + assertEquals(List.of("c", "d", "e"), queue.peekBatch()); + assertEquals(2, queue.dropped()); + } +} From e39d9cdcf986fb6528a247a0e124b7f970e915a5 Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Sat, 17 Oct 2026 14:29:01 +0200 Subject: [PATCH 15/79] Replace the muted gold accent with MG red ev_accent is now #E0424C, the red of the launcher icon (about 4.6:1 on the black background, enough for accent-coloured text; the darker MG brand red is about 3:1). ev_on_accent and ev_gold_surface follow with a dark red tint; the gold hardcoded in two drawables and the consumption period buttons is replaced too. AGENTS.md's visual direction updated. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki --- AGENTS.md | 2 +- app/src/main/java/com/evsuite/abrp/MainActivity.java | 4 ++-- app/src/main/res/drawable/ic_info.xml | 2 +- app/src/main/res/drawable/ic_vehicle_outline.xml | 2 +- app/src/main/res/values/colors.xml | 10 ++++++---- 5 files changed, 11 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fe6a381..1f9ce97 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ workspace `DESIGN.md` apply; this file contains only telemetry-specific addition ## App-specific visual direction (2.2.6) The user explicitly requested a permanent OLED-black theme, charcoal glass-like cards, -muted gold and cyan accents. These app resources intentionally override the shared +MG red (#E0424C, replacing the earlier muted gold) and cyan accents. These app resources intentionally override the shared DESIGN.md palette, DayNight preference and flat-surface rules. Do not overwrite them with the shared token generator. Use static gradients rather than continuous effects; keep large touch targets, readable text and null telemetry shown as a dash. diff --git a/app/src/main/java/com/evsuite/abrp/MainActivity.java b/app/src/main/java/com/evsuite/abrp/MainActivity.java index 75608b6..76c4e64 100644 --- a/app/src/main/java/com/evsuite/abrp/MainActivity.java +++ b/app/src/main/java/com/evsuite/abrp/MainActivity.java @@ -852,9 +852,9 @@ public class MainActivity extends AppCompatActivity { MaterialButton button = consumptionPane.findViewById(id); boolean selected = id == selectedId; button.setSelected(selected); - button.setTextColor(selected ? 0xFFD8C39A : COLOR_PENDING); + button.setTextColor(selected ? 0xFFE0424C : COLOR_PENDING); button.setStrokeColor(android.content.res.ColorStateList.valueOf( - selected ? 0xFFD8C39A : 0xFF3A4143)); + selected ? 0xFFE0424C : 0xFF3A4143)); } } diff --git a/app/src/main/res/drawable/ic_info.xml b/app/src/main/res/drawable/ic_info.xml index 91a111e..78f93a3 100644 --- a/app/src/main/res/drawable/ic_info.xml +++ b/app/src/main/res/drawable/ic_info.xml @@ -1 +1 @@ - + diff --git a/app/src/main/res/drawable/ic_vehicle_outline.xml b/app/src/main/res/drawable/ic_vehicle_outline.xml index f1e7624..e6178ec 100644 --- a/app/src/main/res/drawable/ic_vehicle_outline.xml +++ b/app/src/main/res/drawable/ic_vehicle_outline.xml @@ -1,5 +1,5 @@ - + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 35475b4..db6aef8 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,5 +1,7 @@ - + #000000 #121212 @@ -7,11 +9,11 @@ #363B3D #F4F3EF #BDC4C6 - #D8C39A - #17140E + #E0424C + #1A0B0C #A0E2E0 #103436 - #28241B + #2A1416 #A4DCC1 #E9CA96 #FFB4AB From 818442db08fda639e8b4d3cbed174478439adc9f Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Sat, 17 Oct 2026 14:34:16 +0200 Subject: [PATCH 16/79] Rename the web app from FatihsMG4 to Nils' MG4 Browser title, login heading, hidden password-manager username, session cookie (nilsmg4_session) and package name. The README keeps the credit to fatihdonmezdev/FatihsMG4. Server version 1.1.1; the manifests pin it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki --- SPECS.md | 5 ++--- k8s/kustomization.yaml | 2 +- web/README.md | 2 +- web/package-lock.json | 8 ++++---- web/package.json | 4 ++-- web/src/app/layout.tsx | 2 +- web/src/app/login/page.tsx | 4 ++-- web/src/lib/session.ts | 2 +- 8 files changed, 14 insertions(+), 15 deletions(-) diff --git a/SPECS.md b/SPECS.md index 3991e21..5d8f158 100644 --- a/SPECS.md +++ b/SPECS.md @@ -74,7 +74,7 @@ car ──live telemetry───▶ car.stinnesbeck.com ──▶ ABRP api.ite - [x] Manifests in `k8s/`, validated offline. **Not applied** — that is Nils' step (`k8s/README.md`). - [x] Car app 2.3.0: Server tab with config import, live telemetry upload, cadence controls. Unit tests and lint pass; **not yet tried on the car**. -- [ ] Push the image to git.stinnesbeck.com (needs a registry login on the build machine). +- [ ] Push the image (`mg4-server:1.1.1`) to git.stinnesbeck.com (needs a registry login on the build machine). - [ ] Check the Traefik ingress class in `k8s/ingress.yaml` (placeholder `traefik-external`). - [ ] First drive with 2.3.0: telemetry arrives in InfluxDB and at ABRP. @@ -92,8 +92,7 @@ car ──live telemetry───▶ car.stinnesbeck.com ──▶ ABRP api.ite `NilsMG4-unstable-.apk` with a `.sha256` sidecar (`build-car` writes both). - [ ] **Charging currency.** Prices and costs are shown in Turkish lira (`₺/kWh`, `₺`) in `pane_charging.xml` and `MainActivity.java`. -- [ ] **Dashboard language and branding.** The `web/` pages are still Turkish, say "FatihsMG4" and - show prices in lira. +- [ ] **Dashboard language.** The `web/` pages are still Turkish and show prices in lira. - [ ] **Telemetry queue survives restarts.** Unsent live samples are kept in memory only. - [ ] **Translate the rest.** The docs (`CHANGELOG.md`, `OTA.md`'s message table, comments in `mise.toml`/`app/build.gradle`) are still Turkish or French. diff --git a/k8s/kustomization.yaml b/k8s/kustomization.yaml index 7a45bf6..c549b56 100644 --- a/k8s/kustomization.yaml +++ b/k8s/kustomization.yaml @@ -9,4 +9,4 @@ resources: - ingress.yaml images: - name: git.stinnesbeck.com/nils/mg4-server - newTag: "1.1.0" + newTag: "1.1.1" diff --git a/web/README.md b/web/README.md index 0b2d722..71ce266 100644 --- a/web/README.md +++ b/web/README.md @@ -2,7 +2,7 @@ Next.js app, self-hosted at car.stinnesbeck.com: the ingest API the car writes to, the ABRP forwarder, and a phone-shaped dashboard for reading the history back. Data lives in -InfluxDB 2. Based on upstream's Vercel/MongoDB app in FatihsMG4. +InfluxDB 2. Based on upstream's Vercel/MongoDB app in fatihdonmezdev/FatihsMG4. ``` car ──live telemetry───▶ server ──▶ ABRP api.iternio.com/1/tlm/send diff --git a/web/package-lock.json b/web/package-lock.json index 1dcb4ec..6f9ca13 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { - "name": "fatihsmg4-web", - "version": "1.1.0", + "name": "nilsmg4-web", + "version": "1.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "fatihsmg4-web", - "version": "1.1.0", + "name": "nilsmg4-web", + "version": "1.1.1", "dependencies": { "@influxdata/influxdb-client": "^1.35.0", "next": "^15.5.4", diff --git a/web/package.json b/web/package.json index a061eb4..0c9abcf 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { - "name": "fatihsmg4-web", - "version": "1.1.0", + "name": "nilsmg4-web", + "version": "1.1.1", "private": true, "scripts": { "dev": "next dev", diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index a3a2aeb..a39b78a 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -2,7 +2,7 @@ import type { Metadata, Viewport } from "next"; import "./globals.css"; export const metadata: Metadata = { - title: "FatihsMG4", + title: "Nils' MG4", description: "MG4 tüketim ve şarj geçmişi", }; diff --git a/web/src/app/login/page.tsx b/web/src/app/login/page.tsx index 459543d..8c67603 100644 --- a/web/src/app/login/page.tsx +++ b/web/src/app/login/page.tsx @@ -13,7 +13,7 @@ export default async function LoginPage({ return (
-

FatihsMG4

+

Nils' MG4

Tüketim ve şarj geçmişi

{/* A hidden username so password managers will offer to save this. @@ -24,7 +24,7 @@ export default async function LoginPage({ Date: Sat, 17 Oct 2026 14:39:59 +0200 Subject: [PATCH 17/79] Translate the dashboard to English; euro instead of lira MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dashboard: every label, empty state, chart legend and the login page are English; numbers and dates use en-GB (24-hour clock, day-month dates), money is EUR, percentages read 80% instead of %80, durations "2 h 05 min". html lang is en. Server version 1.1.2; manifests pin it. Car app: the charging tab's price suffix, validation message and total cost use € instead of ₺. Checked by rendering all three pages from the 1.1.2 image against InfluxDB 2.7.12 in podman. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki --- SPECS.md | 8 +-- .../java/com/evsuite/abrp/MainActivity.java | 4 +- app/src/main/res/layout/pane_charging.xml | 2 +- k8s/kustomization.yaml | 2 +- web/package-lock.json | 4 +- web/package.json | 2 +- web/src/app/charging/page.tsx | 42 ++++++------- web/src/app/consumption/page.tsx | 62 +++++++++---------- web/src/app/layout.tsx | 4 +- web/src/app/login/page.tsx | 8 +-- web/src/app/page.tsx | 50 +++++++-------- web/src/components/charts.tsx | 24 +++---- web/src/components/shell.tsx | 8 +-- web/src/lib/format.ts | 18 +++--- 14 files changed, 119 insertions(+), 119 deletions(-) diff --git a/SPECS.md b/SPECS.md index 5d8f158..dff6558 100644 --- a/SPECS.md +++ b/SPECS.md @@ -74,7 +74,7 @@ car ──live telemetry───▶ car.stinnesbeck.com ──▶ ABRP api.ite - [x] Manifests in `k8s/`, validated offline. **Not applied** — that is Nils' step (`k8s/README.md`). - [x] Car app 2.3.0: Server tab with config import, live telemetry upload, cadence controls. Unit tests and lint pass; **not yet tried on the car**. -- [ ] Push the image (`mg4-server:1.1.1`) to git.stinnesbeck.com (needs a registry login on the build machine). +- [ ] Push the image (`mg4-server:1.1.2`) to git.stinnesbeck.com (needs a registry login on the build machine). - [ ] Check the Traefik ingress class in `k8s/ingress.yaml` (placeholder `traefik-external`). - [ ] First drive with 2.3.0: telemetry arrives in InfluxDB and at ABRP. @@ -90,9 +90,6 @@ car ──live telemetry───▶ car.stinnesbeck.com ──▶ ABRP api.ite (`ALLOWED_HOSTS`). For git.stinnesbeck.com it needs the host on the allowlist, and the response parsing checked against that server's releases API. Release assets must be named `NilsMG4-unstable-.apk` with a `.sha256` sidecar (`build-car` writes both). -- [ ] **Charging currency.** Prices and costs are shown in Turkish lira (`₺/kWh`, `₺`) in - `pane_charging.xml` and `MainActivity.java`. -- [ ] **Dashboard language.** The `web/` pages are still Turkish and show prices in lira. - [ ] **Telemetry queue survives restarts.** Unsent live samples are kept in memory only. - [ ] **Translate the rest.** The docs (`CHANGELOG.md`, `OTA.md`'s message table, comments in `mise.toml`/`app/build.gradle`) are still Turkish or French. @@ -116,6 +113,9 @@ car ──live telemetry───▶ car.stinnesbeck.com ──▶ ABRP api.ite telemetry restored from before `ea8032a` and sent to the server, Upload-tab cadence controls reconnected, dead switches removed. The removed ABRP classes' unit tests compile and pass again (AbrpApi and HeadUnitWeather tests deleted with their classes). +- 2026-10-17 — Accent colour: MG red instead of muted gold. Web app renamed to Nils' MG4. +- 2026-10-17 — Dashboard translated to English (en-GB formatting); euro instead of lira in the + dashboard and the car app's charging tab. - 2026-10-17 — Temporary on-screen diagnostics (b2a5b32) to debug blank values without adb; removed again (1002962) once values showed on the car. Revert 1002962 to bring them back. diff --git a/app/src/main/java/com/evsuite/abrp/MainActivity.java b/app/src/main/java/com/evsuite/abrp/MainActivity.java index 76c4e64..7df5cbb 100644 --- a/app/src/main/java/com/evsuite/abrp/MainActivity.java +++ b/app/src/main/java/com/evsuite/abrp/MainActivity.java @@ -888,7 +888,7 @@ public class MainActivity extends AppCompatActivity { chargePriceInput.setText(String.format(java.util.Locale.US, "%.2f", value)); Toast.makeText(this, "Charging price saved", Toast.LENGTH_SHORT).show(); } catch (NumberFormatException e) { - chargePriceLayout.setError("Enter a valid ₺/kWh value"); + chargePriceLayout.setError("Enter a valid €/kWh value"); } } @@ -916,7 +916,7 @@ public class MainActivity extends AppCompatActivity { java.util.Locale.getDefault(), "%.2f kWh", session.gridEnergyKwh) : "—"); long sec = session.durationSeconds; chargeDuration.setText(session.startedAtMs > 0 ? String.format(java.util.Locale.getDefault(), "%02d:%02d:%02d", sec/3600,(sec%3600)/60,sec%60) : "—"); - chargeTotalCost.setText(session.startedAtMs > 0 ? String.format(java.util.Locale.getDefault(), "%.2f ₺", session.totalCost) : "—"); + chargeTotalCost.setText(session.startedAtMs > 0 ? String.format(java.util.Locale.getDefault(), "€%.2f", session.totalCost) : "—"); chargeGraph.setPoints(session.points); chargeCurveTable.setText(chargeGraph.tableText()); } diff --git a/app/src/main/res/layout/pane_charging.xml b/app/src/main/res/layout/pane_charging.xml index ce2c3f0..2a3f810 100644 --- a/app/src/main/res/layout/pane_charging.xml +++ b/app/src/main/res/layout/pane_charging.xml @@ -17,7 +17,7 @@ - + diff --git a/k8s/kustomization.yaml b/k8s/kustomization.yaml index c549b56..1acf22d 100644 --- a/k8s/kustomization.yaml +++ b/k8s/kustomization.yaml @@ -9,4 +9,4 @@ resources: - ingress.yaml images: - name: git.stinnesbeck.com/nils/mg4-server - newTag: "1.1.1" + newTag: "1.1.2" diff --git a/web/package-lock.json b/web/package-lock.json index 6f9ca13..88db69d 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "nilsmg4-web", - "version": "1.1.1", + "version": "1.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nilsmg4-web", - "version": "1.1.1", + "version": "1.1.2", "dependencies": { "@influxdata/influxdb-client": "^1.35.0", "next": "^15.5.4", diff --git a/web/package.json b/web/package.json index 0c9abcf..c1a59a3 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "nilsmg4-web", - "version": "1.1.1", + "version": "1.1.2", "private": true, "scripts": { "dev": "next dev", diff --git a/web/src/app/charging/page.tsx b/web/src/app/charging/page.tsx index 16f8279..6cf9e42 100644 --- a/web/src/app/charging/page.tsx +++ b/web/src/app/charging/page.tsx @@ -12,10 +12,10 @@ export default async function ChargingPage() { if (!installationId || sessions.length === 0) { return ( - +

- Şarj kaydı yok. Yalnızca 10 kW üstü DC şarjlar ölçülür; AC şarj kaydedilmez. + No charging sessions yet. Only DC charges above 10 kW are metered; AC charging is not recorded.

@@ -25,22 +25,22 @@ export default async function ChargingPage() { const total = sumCharging(sessions); return ( - +
- - - + + + 0 ? money(total.cost / total.gridEnergyKwh) : "—"} - note="kWh başına" + note="per kWh" tone="teal" /> - +
-

Seanslar

+

Sessions

    {sessions.map((session) => { const gained = @@ -59,7 +59,7 @@ export default async function ChargingPage() {
    {session.isDemo && "Demo · "} {duration(session.durationSeconds)} - {gained !== null && ` · %${num(session.startSocPercent as number)} → %${num(session.endSocPercent as number)}`} + {gained !== null && ` · ${num(session.startSocPercent as number)}% → ${num(session.endSocPercent as number)}%`}
@@ -77,16 +77,16 @@ export default async function ChargingPage() { }))} />
- - - - - - - 0 ? `${num(peak)} kW` : "—"} /> - 0 ? `${num(average)} kW` : "—"} /> - - + + + + + + + 0 ? `${num(peak)} kW` : "—"} /> + 0 ? `${num(average)} kW` : "—"} /> + +
diff --git a/web/src/app/consumption/page.tsx b/web/src/app/consumption/page.tsx index 163546a..d87b07a 100644 --- a/web/src/app/consumption/page.tsx +++ b/web/src/app/consumption/page.tsx @@ -18,9 +18,9 @@ export default async function ConsumptionPage({ if (!installationId || days.length === 0) { return ( - +
-

Günlük tüketim kaydı yok. Araç günü kapattığında yüklenir.

+

No daily consumption records yet. The car uploads them as it drives.

); @@ -46,14 +46,14 @@ export default async function ConsumptionPage({ return ( {/* Date filter */}
- + {hasFilter && ( - Temizle + Clear )}
@@ -80,60 +80,60 @@ export default async function ConsumptionPage({ {validFilter && selected ? ( <>
- - - + + + 0 ? num(selected.day.km / selected.day.hours) : "—"} unit={selected.day.hours > 0 ? "km/h" : undefined} />
- - - + + +
) : ( -

Bu tarih için tüketim kaydı yok.

+

No consumption recorded for this date.

)}
)}
- - - + + + 0 ? num(total.km / total.hours) : "—"} unit={total.hours > 0 ? "km/h" : undefined} />
-

Günlük enerji

+

Daily energy

-

Verimlilik eğilimi

+

Efficiency trend

-

Ömür boyu

+

Lifetime

- - - - + + + +
-

Gün gün

+

Day by day

    {table.map((day) => { const isSelected = hasFilter && day.date === selectedDate; @@ -158,13 +158,13 @@ export default async function ConsumptionPage({
    - + 0 ? `${num(day.day.km / day.day.hours)} km/h` : "—"} /> - - + +
    diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index a39b78a..d4242ec 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -3,7 +3,7 @@ import "./globals.css"; export const metadata: Metadata = { title: "Nils' MG4", - description: "MG4 tüketim ve şarj geçmişi", + description: "MG4 consumption and charging history", }; export const viewport: Viewport = { @@ -16,7 +16,7 @@ export const viewport: Viewport = { export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - + {children} ); diff --git a/web/src/app/login/page.tsx b/web/src/app/login/page.tsx index 8c67603..88cabd7 100644 --- a/web/src/app/login/page.tsx +++ b/web/src/app/login/page.tsx @@ -14,7 +14,7 @@ export default async function LoginPage({

    Nils' MG4

    -

    Tüketim ve şarj geçmişi

    +

    Consumption and charging history

    {/* A hidden username so password managers will offer to save this. Most of them ignore a lone password field — they key a saved entry on @@ -33,15 +33,15 @@ export default async function LoginPage({ className="field" type="password" name="password" - placeholder="Parola" + placeholder="Password" autoComplete="current-password" autoFocus required /> - {error &&

    Parola hatalı.

    } + {error &&

    Wrong password.

    }
    ); diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx index a6cae0f..6b1dc26 100644 --- a/web/src/app/page.tsx +++ b/web/src/app/page.tsx @@ -12,10 +12,10 @@ export default async function OverviewPage() { if (!installationId) { return ( - +

    - Henüz veri yok. Araç ilk günlük kaydı gönderdiğinde burada görünecek. + No data yet. It appears here once the car sends its first daily record.

    @@ -31,26 +31,26 @@ export default async function OverviewPage() { return ( 1 ? `${installationCount} araçtan en günceli` : "MG4"} + title="Overview" + subtitle={installationCount > 1 ? `Most recent of ${installationCount} cars` : "MG4"} >
    0 ? `${money(charge.cost / charge.gridEnergyKwh)}/kWh ort.` : undefined} + note={charge.gridEnergyKwh > 0 ? `${money(charge.cost / charge.gridEnergyKwh)}/kWh avg.` : undefined} tone="gold" /> - +
    -

    Son 30 gün

    +

    Last 30 days

    ({ date: day.date, kwh: day.day.kwh, km: day.day.km }))} />
    {lastSession && (
    -

    Son şarj

    +

    Last charge

    - - - + + + - - + +
    )}
    -

    Batarya ve toplamlar

    +

    Battery and totals

    - - - 0 ? `${num(charge.peakKw)} kW` : "—"} /> - 0 ? `%${num(charge.socGained)}` : "—"} /> - + + + 0 ? `${num(charge.peakKw)} kW` : "—"} /> + 0 ? `${num(charge.socGained)}%` : "—"} /> +
    diff --git a/web/src/components/charts.tsx b/web/src/components/charts.tsx index b02c6be..351f5f6 100644 --- a/web/src/components/charts.tsx +++ b/web/src/components/charts.tsx @@ -78,7 +78,7 @@ export interface DayDatum { * distinction the telemetry side makes between a failed read and a zero. */ export function DailyEnergyChart({ days }: { days: DayDatum[] }) { - if (days.length === 0) return

    Henüz günlük kayıt yok

    ; + if (days.length === 0) return

    No daily records yet

    ; const plot = PLOT; const max = niceMax(Math.max(...days.map((day) => day.kwh))); const slot = innerWidth(plot) / days.length; @@ -86,7 +86,7 @@ export function DailyEnergyChart({ days }: { days: DayDatum[] }) { return ( <> + aria-label="Energy used per day"> value.toFixed(0)} /> {days.map((day, index) => { const height = (day.kwh / max) * innerHeight(plot); @@ -116,7 +116,7 @@ export function DailyEnergyChart({ days }: { days: DayDatum[] }) {
    - Günlük enerji (kWh) + Daily energy (kWh)
    @@ -128,7 +128,7 @@ export function EfficiencyChart({ days }: { days: DayDatum[] }) { const points = days .filter((day) => day.km >= 1 && day.kwh > 0) .map((day) => ({ date: day.date, value: (day.kwh * 100) / day.km })); - if (points.length < 2) return

    Grafik için yeterli gün yok

    ; + if (points.length < 2) return

    Not enough days for a chart

    ; const plot = PLOT; const max = niceMax(Math.max(...points.map((point) => point.value))); const step = innerWidth(plot) / Math.max(1, points.length - 1); @@ -144,7 +144,7 @@ export function EfficiencyChart({ days }: { days: DayDatum[] }) { return ( <> + aria-label="Average consumption per day"> value.toFixed(0)} /> - Ortalama {average.toFixed(1)} + Average {average.toFixed(1)}
    @@ -196,7 +196,7 @@ export interface CurveDatum { * them — the taper is only legible next to the percentage that caused it. */ export function ChargeCurveChart({ curve }: { curve: CurveDatum[] }) { - if (curve.length < 2) return

    Bu seans için eğri kaydı yok

    ; + if (curve.length < 2) return

    No curve recorded for this session

    ; const plot = { ...PLOT, padRight: 26 }; const times = curve.map((point) => new Date(point.timestamp).getTime()); const start = times[0]; @@ -223,7 +223,7 @@ export function ChargeCurveChart({ curve }: { curve: CurveDatum[] }) { return ( <> + aria-label="Charging power and state-of-charge curve"> value.toFixed(0)} /> @@ -241,20 +241,20 @@ export function ChargeCurveChart({ curve }: { curve: CurveDatum[] }) { 0% - 0 dk + 0 min - {Math.round(span / 60000)} dk + {Math.round(span / 60000)} min
    - Güç (kW) + Power (kW) - Doluluk (%) + State of charge (%)
    diff --git a/web/src/components/shell.tsx b/web/src/components/shell.tsx index a9320aa..242adc8 100644 --- a/web/src/components/shell.tsx +++ b/web/src/components/shell.tsx @@ -2,9 +2,9 @@ import Link from "next/link"; import type { ReactNode } from "react"; const TABS = [ - { href: "/", label: "Özet", icon: "◈" }, - { href: "/consumption", label: "Tüketim", icon: "▤" }, - { href: "/charging", label: "Şarj", icon: "⚡" }, + { href: "/", label: "Overview", icon: "◈" }, + { href: "/consumption", label: "Consumption", icon: "▤" }, + { href: "/charging", label: "Charging", icon: "⚡" }, ] as const; export type Tab = (typeof TABS)[number]["href"]; @@ -42,7 +42,7 @@ export function Shell({ */}
    diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts index 19869fc..4343772 100644 --- a/web/src/lib/format.ts +++ b/web/src/lib/format.ts @@ -1,10 +1,10 @@ -/** Turkish formatting for every user-facing number and date. */ +/** English formatting (en-GB: 24-hour clock, day-month dates) with euro for every number and date. */ -const NUMBER = new Intl.NumberFormat("tr-TR", { maximumFractionDigits: 1 }); -const PRECISE = new Intl.NumberFormat("tr-TR", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); -const CURRENCY = new Intl.NumberFormat("tr-TR", { style: "currency", currency: "TRY", maximumFractionDigits: 2 }); -const DATE = new Intl.DateTimeFormat("tr-TR", { day: "numeric", month: "long", year: "numeric" }); -const DATE_TIME = new Intl.DateTimeFormat("tr-TR", { +const NUMBER = new Intl.NumberFormat("en-GB", { maximumFractionDigits: 1 }); +const PRECISE = new Intl.NumberFormat("en-GB", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); +const CURRENCY = new Intl.NumberFormat("en-GB", { style: "currency", currency: "EUR", maximumFractionDigits: 2 }); +const DATE = new Intl.DateTimeFormat("en-GB", { day: "numeric", month: "long", year: "numeric" }); +const DATE_TIME = new Intl.DateTimeFormat("en-GB", { day: "numeric", month: "short", hour: "2-digit", @@ -32,13 +32,13 @@ export function dateTime(value: Date | string): string { return DATE_TIME.format(new Date(value)); } -/** `2 sa 05 dk` — the same shape the car's charging pane uses. */ +/** `2 h 05 min` — the same shape the car's charging pane uses. */ export function duration(seconds: number): string { const total = Math.max(0, Math.round(seconds)); const hours = Math.floor(total / 3600); const minutes = Math.floor((total % 3600) / 60); - if (hours === 0) return `${minutes} dk`; - return `${hours} sa ${String(minutes).padStart(2, "0")} dk`; + if (hours === 0) return `${minutes} min`; + return `${hours} h ${String(minutes).padStart(2, "0")} min`; } /** Energy per 100 km, or a dash — never a ratio computed from a distance of nothing. */ From 51e6f6fd4893ee55ed2d1e88102751f63c4c370f Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Sat, 17 Oct 2026 15:17:42 +0200 Subject: [PATCH 18/79] Server: rewrite in Go as an API-only service Same endpoints, request formats, validation rules and InfluxDB layout as the Next.js server, so the car needs no change: POST /v1/telemetry, PUT /v1/consumption/daily, PUT /v1/charging-sessions/{id}, GET /v1/consumption/history/{id}, GET /v1/health. The dashboard, login and the /v1/history reads are gone; Grafana takes over the dashboards. - Go 1.26 standard library (net/http routing, log/slog) plus the official InfluxDB 2 client. Fails closed at startup on a missing InfluxDB setting or an ingest token under 24 characters. - ABRP forwarder ported: newest fresh sample, rate limited, never queued, credentials only in the form body. - 22 tests: validation, aggregation, forwarding, config, and the handlers through a fake store. govulncheck clean (x/net 0.60.0, toolchain go1.26.9). - Image: static binary on distroless, about 11 MB, UID 65532, port 8080; the build runs vet and the tests. - Dev shell: Go, gopls and govulncheck instead of Node. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki --- .../cache/_events_79120_d0eaad9b80692b5d.json | 1 - .../cache/_events_79235_382896d082e25d69.json | 1 - .next/dev/build-manifest.json | 15 - .next/dev/cache/.rscinfo | 1 - .../cache/turbopack/v16.4.0-e273d5b2/CURRENT | 1 - .next/dev/fallback-build-manifest.json | 15 - .next/dev/lock | 1 - .next/dev/logs/next-development.log | 3 - .next/dev/package.json | 3 - .next/dev/prerender-manifest.json | 6 - .next/dev/routes-manifest.json | 1 - .next/dev/server/app-paths-manifest.json | 1 - .../interception-route-rewrite-manifest.js | 1 - .next/dev/server/middleware-build-manifest.js | 15 - .next/dev/server/middleware-manifest.json | 6 - .next/dev/server/next-font-manifest.js | 1 - .next/dev/server/next-font-manifest.json | 6 - .next/dev/server/pages-manifest.json | 1 - .next/dev/server/preview-props.json | 5 - .next/dev/server/server-reference-manifest.js | 1 - .../dev/server/server-reference-manifest.json | 5 - .../dev/static/development/_buildManifest.js | 8 - .../development/_clientMiddlewareManifest.js | 1 - .next/dev/static/development/_ssgManifest.js | 1 - .next/dev/types/routes.d.ts | 55 - flake.nix | 2 +- server/.dockerignore | 4 + server/Dockerfile | 16 + server/README.md | 77 ++ server/abrp.go | 139 +++ server/api.go | 157 +++ server/api_test.go | 170 +++ server/days.go | 51 + server/go.mod | 15 + server/go.sum | 27 + server/logic_test.go | 121 ++ server/main.go | 113 ++ server/model.go | 108 ++ server/store.go | 162 +++ server/validate.go | 327 ++++++ server/validate_test.go | 185 +++ web/.dockerignore | 7 - web/.env.example | 18 - web/.gitignore | 7 - web/Dockerfile | 27 - web/README.md | 88 -- web/next.config.mjs | 8 - web/package-lock.json | 1002 ----------------- web/package.json | 27 - web/src/app/api/session/route.ts | 24 - web/src/app/charging/page.tsx | 100 -- web/src/app/consumption/page.tsx | 178 --- web/src/app/globals.css | 430 ------- web/src/app/layout.tsx | 23 - web/src/app/login/page.tsx | 48 - web/src/app/logout/route.ts | 22 - web/src/app/page.tsx | 99 -- .../v1/charging-sessions/[sessionId]/route.ts | 17 - web/src/app/v1/consumption/daily/route.ts | 21 - .../history/[installationId]/route.ts | 30 - web/src/app/v1/health/route.ts | 9 - .../[installationId]/charging/route.ts | 17 - .../[installationId]/consumption/route.ts | 17 - web/src/app/v1/telemetry/route.ts | 24 - web/src/components/charts.tsx | 267 ----- web/src/components/shell.tsx | 99 -- web/src/lib/abrp.ts | 88 -- web/src/lib/api.ts | 53 - web/src/lib/auth.ts | 20 - web/src/lib/data.ts | 83 -- web/src/lib/days.ts | 43 - web/src/lib/format.ts | 48 - web/src/lib/influx.ts | 40 - web/src/lib/session.ts | 80 -- web/src/lib/store.ts | 225 ---- web/src/lib/types.ts | 96 -- web/src/lib/validate.ts | 167 --- web/test/days.test.ts | 37 - web/test/session.test.ts | 44 - web/test/telemetry.test.ts | 56 - web/test/validate.test.ts | 99 -- web/tsconfig.json | 22 - 82 files changed, 1673 insertions(+), 3966 deletions(-) delete mode 100644 .next/cache/_events_79120_d0eaad9b80692b5d.json delete mode 100644 .next/cache/_events_79235_382896d082e25d69.json delete mode 100644 .next/dev/build-manifest.json delete mode 100644 .next/dev/cache/.rscinfo delete mode 100644 .next/dev/cache/turbopack/v16.4.0-e273d5b2/CURRENT delete mode 100644 .next/dev/fallback-build-manifest.json delete mode 100644 .next/dev/lock delete mode 100644 .next/dev/logs/next-development.log delete mode 100644 .next/dev/package.json delete mode 100644 .next/dev/prerender-manifest.json delete mode 100644 .next/dev/routes-manifest.json delete mode 100644 .next/dev/server/app-paths-manifest.json delete mode 100644 .next/dev/server/interception-route-rewrite-manifest.js delete mode 100644 .next/dev/server/middleware-build-manifest.js delete mode 100644 .next/dev/server/middleware-manifest.json delete mode 100644 .next/dev/server/next-font-manifest.js delete mode 100644 .next/dev/server/next-font-manifest.json delete mode 100644 .next/dev/server/pages-manifest.json delete mode 100644 .next/dev/server/preview-props.json delete mode 100644 .next/dev/server/server-reference-manifest.js delete mode 100644 .next/dev/server/server-reference-manifest.json delete mode 100644 .next/dev/static/development/_buildManifest.js delete mode 100644 .next/dev/static/development/_clientMiddlewareManifest.js delete mode 100644 .next/dev/static/development/_ssgManifest.js delete mode 100644 .next/dev/types/routes.d.ts create mode 100644 server/.dockerignore create mode 100644 server/Dockerfile create mode 100644 server/README.md create mode 100644 server/abrp.go create mode 100644 server/api.go create mode 100644 server/api_test.go create mode 100644 server/days.go create mode 100644 server/go.mod create mode 100644 server/go.sum create mode 100644 server/logic_test.go create mode 100644 server/main.go create mode 100644 server/model.go create mode 100644 server/store.go create mode 100644 server/validate.go create mode 100644 server/validate_test.go delete mode 100644 web/.dockerignore delete mode 100644 web/.env.example delete mode 100644 web/.gitignore delete mode 100644 web/Dockerfile delete mode 100644 web/README.md delete mode 100644 web/next.config.mjs delete mode 100644 web/package-lock.json delete mode 100644 web/package.json delete mode 100644 web/src/app/api/session/route.ts delete mode 100644 web/src/app/charging/page.tsx delete mode 100644 web/src/app/consumption/page.tsx delete mode 100644 web/src/app/globals.css delete mode 100644 web/src/app/layout.tsx delete mode 100644 web/src/app/login/page.tsx delete mode 100644 web/src/app/logout/route.ts delete mode 100644 web/src/app/page.tsx delete mode 100644 web/src/app/v1/charging-sessions/[sessionId]/route.ts delete mode 100644 web/src/app/v1/consumption/daily/route.ts delete mode 100644 web/src/app/v1/consumption/history/[installationId]/route.ts delete mode 100644 web/src/app/v1/health/route.ts delete mode 100644 web/src/app/v1/history/[installationId]/charging/route.ts delete mode 100644 web/src/app/v1/history/[installationId]/consumption/route.ts delete mode 100644 web/src/app/v1/telemetry/route.ts delete mode 100644 web/src/components/charts.tsx delete mode 100644 web/src/components/shell.tsx delete mode 100644 web/src/lib/abrp.ts delete mode 100644 web/src/lib/api.ts delete mode 100644 web/src/lib/auth.ts delete mode 100644 web/src/lib/data.ts delete mode 100644 web/src/lib/days.ts delete mode 100644 web/src/lib/format.ts delete mode 100644 web/src/lib/influx.ts delete mode 100644 web/src/lib/session.ts delete mode 100644 web/src/lib/store.ts delete mode 100644 web/src/lib/types.ts delete mode 100644 web/src/lib/validate.ts delete mode 100644 web/test/days.test.ts delete mode 100644 web/test/session.test.ts delete mode 100644 web/test/telemetry.test.ts delete mode 100644 web/test/validate.test.ts delete mode 100644 web/tsconfig.json diff --git a/.next/cache/_events_79120_d0eaad9b80692b5d.json b/.next/cache/_events_79120_d0eaad9b80692b5d.json deleted file mode 100644 index a25504d..0000000 --- a/.next/cache/_events_79120_d0eaad9b80692b5d.json +++ /dev/null @@ -1 +0,0 @@ -[{"eventName":"NEXT_CLI_SESSION_STOPPED","payload":{"nextVersion":"16.4.0","nodeVersion":"v24.12.0","cliCommand":"dev","durationMilliseconds":348,"turboFlag":true,"pagesDir":false,"appDir":true,"isRspack":false}}] \ No newline at end of file diff --git a/.next/cache/_events_79235_382896d082e25d69.json b/.next/cache/_events_79235_382896d082e25d69.json deleted file mode 100644 index 24ede18..0000000 --- a/.next/cache/_events_79235_382896d082e25d69.json +++ /dev/null @@ -1 +0,0 @@ -[{"eventName":"NEXT_CLI_SESSION_STOPPED","payload":{"nextVersion":"16.4.0","nodeVersion":"v24.12.0","cliCommand":"dev","durationMilliseconds":345,"turboFlag":true,"pagesDir":false,"appDir":true,"isRspack":false}}] \ No newline at end of file diff --git a/.next/dev/build-manifest.json b/.next/dev/build-manifest.json deleted file mode 100644 index cc102f9..0000000 --- a/.next/dev/build-manifest.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "pages": { - "/_app": [] - }, - "devFiles": [], - "polyfillFiles": [], - "lowPriorityFiles": [ - "static/development/_buildManifest.js", - "static/development/_ssgManifest.js", - "static/development/_clientMiddlewareManifest.js" - ], - "rootMainFiles": [], - "rootMainFilesTree": {}, - "pagesChunkGroupBootstrapParams": {} -} \ No newline at end of file diff --git a/.next/dev/cache/.rscinfo b/.next/dev/cache/.rscinfo deleted file mode 100644 index 4830a4e..0000000 --- a/.next/dev/cache/.rscinfo +++ /dev/null @@ -1 +0,0 @@ -{"encryption.key":"UT9PfJliySgklhhPCIM4wpEhmx9PMD9GPB4iuPIamyM=","encryption.expire_at":1792761221411} \ No newline at end of file diff --git a/.next/dev/cache/turbopack/v16.4.0-e273d5b2/CURRENT b/.next/dev/cache/turbopack/v16.4.0-e273d5b2/CURRENT deleted file mode 100644 index 5f9b019..0000000 --- a/.next/dev/cache/turbopack/v16.4.0-e273d5b2/CURRENT +++ /dev/null @@ -1 +0,0 @@ -{"max_sequence_number":0,"commit_time":"2026-10-09T13:13:41.418251Z"} diff --git a/.next/dev/fallback-build-manifest.json b/.next/dev/fallback-build-manifest.json deleted file mode 100644 index cc102f9..0000000 --- a/.next/dev/fallback-build-manifest.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "pages": { - "/_app": [] - }, - "devFiles": [], - "polyfillFiles": [], - "lowPriorityFiles": [ - "static/development/_buildManifest.js", - "static/development/_ssgManifest.js", - "static/development/_clientMiddlewareManifest.js" - ], - "rootMainFiles": [], - "rootMainFilesTree": {}, - "pagesChunkGroupBootstrapParams": {} -} \ No newline at end of file diff --git a/.next/dev/lock b/.next/dev/lock deleted file mode 100644 index faa33e6..0000000 --- a/.next/dev/lock +++ /dev/null @@ -1 +0,0 @@ -{"pid":79123,"port":3001,"hostname":"localhost","appUrl":"http://localhost:3001","startedAt":1791551621304} \ No newline at end of file diff --git a/.next/dev/logs/next-development.log b/.next/dev/logs/next-development.log deleted file mode 100644 index 2cc714a..0000000 --- a/.next/dev/logs/next-development.log +++ /dev/null @@ -1,3 +0,0 @@ -{"timestamp":"00:00:01.802","source":"Server","level":"ERROR","message":"Turbopack build encountered 1 error:\n./app\nError: Could not find the Next.js package (next/package.json)\nResolved from: /Users/tcfdonmez/Desktop/Coding/MG/EVABRPUploader/app\nFilesystem root used for resolution: /Users/tcfdonmez/Desktop/Coding/MG/EVABRPUploader\n\nPossible causes:\n - node_modules is being reorganized by a concurrent install (e.g. pnpm adding a package with a `next` peer dependency). This is transient and should clear once the install completes.\n - node_modules/next was removed, renamed, or has a broken symlink.\n - The workspace root is incorrect — see turbopack.root in the Next.js config docs for how to configure it.\n - In a monorepo, the Next.js package may only exist in a directory above the closest directory containing a package manager lockfile. The workspace root is detected by locating the nearest package manager lockfile.\n - Next.js is installed globally rather than as a project dependency. This is not supported; install it locally.\n\nNote: To ensure a hermetic build and a portable cache, files outside of the workspace root are not compiled.\n\nhttps://nextjs.org/docs/app/api-reference/config/next-config-js/turbopack#root-directory\n\n"} -{"timestamp":"00:00:02.079","source":"Server","level":"LOG","message":""} -{"timestamp":"00:00:02.080","source":"Server","level":"LOG","message":"✓ Generated AGENTS.md for AI agents. Set `agentRules: false` in next.config to disable."} diff --git a/.next/dev/package.json b/.next/dev/package.json deleted file mode 100644 index c9a4422..0000000 --- a/.next/dev/package.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "type": "commonjs" -} \ No newline at end of file diff --git a/.next/dev/prerender-manifest.json b/.next/dev/prerender-manifest.json deleted file mode 100644 index 4400a7a..0000000 --- a/.next/dev/prerender-manifest.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "version": 4, - "routes": {}, - "dynamicRoutes": {}, - "notFoundRoutes": [] -} \ No newline at end of file diff --git a/.next/dev/routes-manifest.json b/.next/dev/routes-manifest.json deleted file mode 100644 index 442224a..0000000 --- a/.next/dev/routes-manifest.json +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"caseSensitive":false,"basePath":"","rewrites":{"beforeFiles":[],"afterFiles":[],"fallback":[]},"redirects":[{"source":"/:path+/","destination":"/:path+","permanent":true,"internal":true,"priority":true,"regex":"^(?:\\/((?:[^\\/]+?)(?:\\/(?:[^\\/]+?))*))\\/$"}],"headers":[],"onMatchHeaders":[]} \ No newline at end of file diff --git a/.next/dev/server/app-paths-manifest.json b/.next/dev/server/app-paths-manifest.json deleted file mode 100644 index 9e26dfe..0000000 --- a/.next/dev/server/app-paths-manifest.json +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file diff --git a/.next/dev/server/interception-route-rewrite-manifest.js b/.next/dev/server/interception-route-rewrite-manifest.js deleted file mode 100644 index 24f77ba..0000000 --- a/.next/dev/server/interception-route-rewrite-manifest.js +++ /dev/null @@ -1 +0,0 @@ -self.__INTERCEPTION_ROUTE_REWRITE_MANIFEST="[]"; \ No newline at end of file diff --git a/.next/dev/server/middleware-build-manifest.js b/.next/dev/server/middleware-build-manifest.js deleted file mode 100644 index 4de4f4f..0000000 --- a/.next/dev/server/middleware-build-manifest.js +++ /dev/null @@ -1,15 +0,0 @@ -globalThis.__BUILD_MANIFEST = { - "pages": { - "/_app": [] - }, - "devFiles": [], - "polyfillFiles": [], - "lowPriorityFiles": [ - "static/development/_buildManifest.js", - "static/development/_ssgManifest.js", - "static/development/_clientMiddlewareManifest.js" - ], - "rootMainFiles": [], - "rootMainFilesTree": {}, - "pagesChunkGroupBootstrapParams": {} -}; diff --git a/.next/dev/server/middleware-manifest.json b/.next/dev/server/middleware-manifest.json deleted file mode 100644 index eb7130b..0000000 --- a/.next/dev/server/middleware-manifest.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "version": 3, - "middleware": {}, - "sortedMiddleware": [], - "functions": {} -} \ No newline at end of file diff --git a/.next/dev/server/next-font-manifest.js b/.next/dev/server/next-font-manifest.js deleted file mode 100644 index dcd0697..0000000 --- a/.next/dev/server/next-font-manifest.js +++ /dev/null @@ -1 +0,0 @@ -self.__NEXT_FONT_MANIFEST="{\n \"app\": {},\n \"appUsingSizeAdjust\": false,\n \"pages\": {},\n \"pagesUsingSizeAdjust\": false\n}" \ No newline at end of file diff --git a/.next/dev/server/next-font-manifest.json b/.next/dev/server/next-font-manifest.json deleted file mode 100644 index 7b7649c..0000000 --- a/.next/dev/server/next-font-manifest.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "app": {}, - "appUsingSizeAdjust": false, - "pages": {}, - "pagesUsingSizeAdjust": false -} \ No newline at end of file diff --git a/.next/dev/server/pages-manifest.json b/.next/dev/server/pages-manifest.json deleted file mode 100644 index 9e26dfe..0000000 --- a/.next/dev/server/pages-manifest.json +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file diff --git a/.next/dev/server/preview-props.json b/.next/dev/server/preview-props.json deleted file mode 100644 index 0969aea..0000000 --- a/.next/dev/server/preview-props.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "previewModeId": "a508c818777951bed52c0170453ef7e3", - "previewModeSigningKey": "591a19b72d1a845a81057e56fb15a2ced8409045c0ed580013f217b8c4a4ea61", - "previewModeEncryptionKey": "ded0359fbc3be263ec7ff240e7fb98fc5b836958070834f5bc954f1092afa91c" -} \ No newline at end of file diff --git a/.next/dev/server/server-reference-manifest.js b/.next/dev/server/server-reference-manifest.js deleted file mode 100644 index 272af9c..0000000 --- a/.next/dev/server/server-reference-manifest.js +++ /dev/null @@ -1 +0,0 @@ -self.__RSC_SERVER_MANIFEST="{\n \"node\": {},\n \"edge\": {},\n \"encryptionKey\": \"UT9PfJliySgklhhPCIM4wpEhmx9PMD9GPB4iuPIamyM=\"\n}" \ No newline at end of file diff --git a/.next/dev/server/server-reference-manifest.json b/.next/dev/server/server-reference-manifest.json deleted file mode 100644 index b5a989e..0000000 --- a/.next/dev/server/server-reference-manifest.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "node": {}, - "edge": {}, - "encryptionKey": "UT9PfJliySgklhhPCIM4wpEhmx9PMD9GPB4iuPIamyM=" -} \ No newline at end of file diff --git a/.next/dev/static/development/_buildManifest.js b/.next/dev/static/development/_buildManifest.js deleted file mode 100644 index bffda53..0000000 --- a/.next/dev/static/development/_buildManifest.js +++ /dev/null @@ -1,8 +0,0 @@ -self.__BUILD_MANIFEST = { - "__rewrites": { - "afterFiles": [], - "beforeFiles": [], - "fallback": [] - }, - "sortedPages": [] -};self.__BUILD_MANIFEST_CB && self.__BUILD_MANIFEST_CB() \ No newline at end of file diff --git a/.next/dev/static/development/_clientMiddlewareManifest.js b/.next/dev/static/development/_clientMiddlewareManifest.js deleted file mode 100644 index a8acaff..0000000 --- a/.next/dev/static/development/_clientMiddlewareManifest.js +++ /dev/null @@ -1 +0,0 @@ -self.__MIDDLEWARE_MATCHERS = [];self.__MIDDLEWARE_MATCHERS_CB && self.__MIDDLEWARE_MATCHERS_CB() \ No newline at end of file diff --git a/.next/dev/static/development/_ssgManifest.js b/.next/dev/static/development/_ssgManifest.js deleted file mode 100644 index 2260768..0000000 --- a/.next/dev/static/development/_ssgManifest.js +++ /dev/null @@ -1 +0,0 @@ -self.__SSG_MANIFEST=new Set;self.__SSG_MANIFEST_CB&&self.__SSG_MANIFEST_CB() \ No newline at end of file diff --git a/.next/dev/types/routes.d.ts b/.next/dev/types/routes.d.ts deleted file mode 100644 index 15617e1..0000000 --- a/.next/dev/types/routes.d.ts +++ /dev/null @@ -1,55 +0,0 @@ -// This file is generated automatically by Next.js -// Do not edit this file manually - -type AppRoutes = never -type PageRoutes = never -type LayoutRoutes = never -type RedirectRoutes = never -type RewriteRoutes = never -type Routes = AppRoutes | PageRoutes | LayoutRoutes | RedirectRoutes | RewriteRoutes - - -interface ParamMap { -} - - -export type ParamsOf = ParamMap[Route] - -interface LayoutSlotMap { -} - - -export type { AppRoutes, PageRoutes, LayoutRoutes, RedirectRoutes, RewriteRoutes, ParamMap } - -declare global { - /** - * Props for Next.js App Router page components - * @example - * ```tsx - * export default function Page(props: PageProps<'/blog/[slug]'>) { - * const { slug } = await props.params - * return
    Blog post: {slug}
    - * } - * ``` - */ - interface PageProps { - params: Promise - searchParams: Promise> - } - - /** - * Props for Next.js App Router layout components - * @example - * ```tsx - * export default function Layout(props: LayoutProps<'/dashboard'>) { - * return
    {props.children}
    - * } - * ``` - */ - type LayoutProps = { - params: Promise - children: React.ReactNode - } & { - [K in LayoutSlotMap[LayoutRoute]]: React.ReactNode - } -} diff --git a/flake.nix b/flake.nix index 88d38f0..a371adf 100644 --- a/flake.nix +++ b/flake.nix @@ -112,7 +112,7 @@ in { default = pkgs.mkShell { - packages = [ jdk android.androidsdk buildCar pkgs.nodejs_22 pkgs.influxdb2-server pkgs.influxdb2-cli ]; + packages = [ jdk android.androidsdk buildCar pkgs.go pkgs.gopls pkgs.govulncheck pkgs.influxdb2-server pkgs.influxdb2-cli ]; ANDROID_HOME = sdk; ANDROID_SDK_ROOT = sdk; diff --git a/server/.dockerignore b/server/.dockerignore new file mode 100644 index 0000000..6f1931b --- /dev/null +++ b/server/.dockerignore @@ -0,0 +1,4 @@ +* +!go.mod +!go.sum +!*.go diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..c373c44 --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,16 @@ +# The car server: one static Go binary on distroless, running as non-root. +# Configuration comes from the environment at runtime; nothing secret is baked in. + +FROM docker.io/library/golang:1.26.9-alpine AS build +WORKDIR /src +ENV CGO_ENABLED=0 GOFLAGS=-trimpath GOTOOLCHAIN=local +COPY go.mod go.sum ./ +RUN go mod download && go mod verify +COPY *.go ./ +RUN go vet ./... && go test ./... && go build -ldflags="-s -w" -o /out/server . + +FROM gcr.io/distroless/static-debian12:nonroot +COPY --from=build /out/server /server +USER 65532:65532 +EXPOSE 8080 +ENTRYPOINT ["/server"] diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..8dfba01 --- /dev/null +++ b/server/README.md @@ -0,0 +1,77 @@ +# Nils' MG4 — car server + +A small Go service at car.stinnesbeck.com: the API the car writes to, and the ABRP +forwarder. Data lives in InfluxDB 2; Grafana reads it from there for the dashboards +(`../k8s/grafana/`). + +``` +car ──live telemetry───▶ server ──▶ ABRP api.iternio.com/1/tlm/send + ──daily chunks─────▶ │ + ──charging sessions▶ ▼ + InfluxDB 2 ◀── Grafana +``` + +## Configuration + +Environment only; the server refuses to start when a required value is missing. + +| Name | Notes | +|---|---| +| `INFLUX_URL`, `INFLUX_TOKEN`, `INFLUX_ORG` | required; the token needs read/write on the bucket | +| `INFLUX_BUCKET` | default `mg4` | +| `INGEST_TOKEN` | required, at least 24 characters: `openssl rand -hex 32` | +| `ABRP_API_KEY`, `ABRP_USER_TOKEN` | optional; both needed to forward | +| `ABRP_MIN_INTERVAL_SECONDS` | default 5 | +| `LISTEN_ADDR` | default `:8080` | + +The ABRP credentials never reach the car. + +## Endpoints + +All but `/v1/health` need `Authorization: Bearer `. + +- `POST /v1/telemetry` — `{ installationId, samples: [...] }`, up to 500 samples in ABRP's `tlm` + field names and units (`utc` in epoch seconds; flags as 0/1 or booleans). Unknown fields are + dropped, known ones bounded. Stored, then the newest sample is forwarded to ABRP if it is under + 5 minutes old. +- `PUT /v1/consumption/daily` — one delta chunk of a day's consumption. +- `PUT /v1/charging-sessions/{sessionId}` — a finished DC session with its curve. +- `GET /v1/consumption/history/{installationId}` — the car reads its own totals back. +- `GET /v1/health` — `{"status":"ok"}`. + +A bad request is a 400 naming the field; a database failure is a 500 without detail. + +## Storage + +One bucket, `installationId` tag on every point: + +| Measurement | Timestamp | Tags | Fields | +|---|---|---|---| +| `telemetry` | sample `utc` | — | the ABRP fields that were sent | +| `consumption_chunk` | `recordedAt` | `date` | km, kwh, hours, socDrop, sohPercent | +| `charging_session` | `startedAt` | `sessionId` | endedAtMs, duration, energy, cost, start/end SOC | +| `charging_curve` | point time | `sessionId` | powerKw, socPercent | + +A point with the same measurement, tags and timestamp replaces the earlier one, so the car's +retries are idempotent: a cached chunk is re-sent with its original `recordedAt`. Days and the +lifetime total are summed from the chunks when read. Numbers are always written as floats, +because InfluxDB fixes a field's type on first write. + +## Development + +```sh +nix develop # from the repository root: Go, govulncheck, InfluxDB 2 +cd server +go test ./... +govulncheck ./... +``` + +## Container image + +```sh +podman build -t git.stinnesbeck.com/nils/mg4-server: . +podman push git.stinnesbeck.com/nils/mg4-server: +``` + +The build runs `go vet` and the tests. The image is a static binary on distroless (about +11 MB), running as UID 65532 on port 8080. diff --git a/server/abrp.go b/server/abrp.go new file mode 100644 index 0000000..9869cc6 --- /dev/null +++ b/server/abrp.go @@ -0,0 +1,139 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "net/url" + "strings" + "sync" + "time" +) + +// Forwarder sends live telemetry on to A Better Route Planner. +// +// The ABRP credentials exist only here, from ABRP_API_KEY and ABRP_USER_TOKEN in the +// server's environment; the car never holds them. Forwarding is off when either is empty. +// +// Only the newest sample of a batch is sent, and at most one per MinInterval. A failed +// forward is logged and dropped, never queued: ABRP plans from the car's state now, and a +// backlog replayed later would describe a car that has moved on. The stored copy in +// InfluxDB is unaffected either way. +type Forwarder struct { + APIKey string + UserToken string + MinInterval time.Duration + Endpoint string + Client *http.Client + + mu sync.Mutex + lastUTC int64 + lastSentAt time.Time +} + +const ( + abrpEndpoint = "https://api.iternio.com/1/tlm/send" + // Older than this, a sample is history rather than live state, and ABRP gets nothing. + abrpMaxAge = 5 * time.Minute +) + +func (f *Forwarder) configured() bool { + return f != nil && f.APIKey != "" && f.UserToken != "" +} + +// pick chooses the sample to forward, or false when there is nothing new or it is too +// soon. It records the choice, so two batches arriving together cannot both forward. +func (f *Forwarder) pick(samples []TelemetrySample, now time.Time) (TelemetrySample, bool) { + if len(samples) == 0 { + return TelemetrySample{}, false + } + newest := samples[0] + for _, s := range samples[1:] { + if s.UTC > newest.UTC { + newest = s + } + } + if now.Sub(time.Unix(newest.UTC, 0)) > abrpMaxAge { + return TelemetrySample{}, false + } + f.mu.Lock() + defer f.mu.Unlock() + if f.lastUTC != 0 && newest.UTC <= f.lastUTC { + return TelemetrySample{}, false + } + if !f.lastSentAt.IsZero() && now.Sub(f.lastSentAt) < f.MinInterval { + return TelemetrySample{}, false + } + f.lastUTC = newest.UTC + f.lastSentAt = now + return newest, true +} + +// Forward is fire-and-forget: the car's request is answered without waiting for ABRP. +func (f *Forwarder) Forward(samples []TelemetrySample) { + if !f.configured() { + return + } + sample, ok := f.pick(samples, time.Now()) + if !ok { + return + } + go func() { + if err := f.send(context.Background(), sample); err != nil { + slog.Warn("ABRP forward failed", "error", err) + } + }() +} + +// tlm is the object ABRP expects: the same field names, flags as 0/1. +func tlm(sample TelemetrySample) map[string]any { + out := map[string]any{"utc": sample.UTC} + for field, value := range sample.Numbers { + out[field] = value + } + for field, value := range sample.Flags { + if value { + out[field] = 1 + } else { + out[field] = 0 + } + } + return out +} + +func (f *Forwarder) send(ctx context.Context, sample TelemetrySample) error { + payload, err := json.Marshal(tlm(sample)) + if err != nil { + return err + } + // Credentials go in the form body, never the URL, which ends up in access logs. + form := url.Values{"api_key": {f.APIKey}, "token": {f.UserToken}, "tlm": {string(payload)}} + endpoint := f.Endpoint + if endpoint == "" { + endpoint = abrpEndpoint + } + ctx, cancel := context.WithTimeout(ctx, 8*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode())) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + client := f.Client + if client == nil { + client = http.DefaultClient + } + resp, err := client.Do(req) + if err != nil { + // A url.Error from the client quotes the URL, which holds no credentials. + return err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + // The response body can echo the request; only the status is reported. + return fmt.Errorf("rejected: HTTP %d", resp.StatusCode) + } + return nil +} diff --git a/server/api.go b/server/api.go new file mode 100644 index 0000000..89b8923 --- /dev/null +++ b/server/api.go @@ -0,0 +1,157 @@ +package main + +import ( + "crypto/subtle" + "encoding/json" + "errors" + "io" + "log/slog" + "net/http" + "strings" +) + +// API serves the car. Every endpoint but /v1/health needs the ingest token. +// +// POST /v1/telemetry live samples; stored, newest forwarded to ABRP +// PUT /v1/consumption/daily one delta chunk of a day's consumption +// PUT /v1/charging-sessions/{sessionId} one finished DC charging session +// GET /v1/consumption/history/{installationId} the car reads its own totals back +// GET /v1/health unauthenticated liveness probe +type API struct { + Store Store + IngestToken string + Forwarder *Forwarder +} + +const maxBodyBytes = 512 * 1024 + +func (a *API) Routes() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET /v1/health", func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) + }) + mux.HandleFunc("POST /v1/telemetry", a.ingest(a.telemetry)) + mux.HandleFunc("PUT /v1/consumption/daily", a.ingest(a.consumption)) + mux.HandleFunc("PUT /v1/charging-sessions/{sessionId}", a.ingest(a.chargingSession)) + mux.HandleFunc("GET /v1/consumption/history/{installationId}", a.ingest(a.history)) + return mux +} + +// hasIngestToken compares in constant time. main refuses to start with a token under 24 +// characters, so an empty or short configured token can never match here. +func (a *API) hasIngestToken(r *http.Request) bool { + header := r.Header.Get("Authorization") + supplied, ok := strings.CutPrefix(header, "Bearer ") + if !ok || len(a.IngestToken) < minIngestTokenLength { + return false + } + return subtle.ConstantTimeCompare([]byte(supplied), []byte(a.IngestToken)) == 1 +} + +type handler func(w http.ResponseWriter, r *http.Request, body []byte) error + +// ingest wraps a handler with the token check, the body limit and error mapping: a +// validation failure is a 400 naming the field; anything else is a 500 whose detail +// stays in the server log. +func (a *API) ingest(h handler) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !a.hasIngestToken(r) { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"}) + return + } + body, err := io.ReadAll(io.LimitReader(r.Body, maxBodyBytes+1)) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "body could not be read"}) + return + } + if len(body) > maxBodyBytes { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "body is too large"}) + return + } + if err := h(w, r, body); err != nil { + var verr *ValidationError + if errors.As(err, &verr) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": verr.Error()}) + return + } + slog.Error("request failed", "method", r.Method, "path", r.URL.Path, "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal server error"}) + } + } +} + +// telemetry stores the batch first; only once the write succeeded is the newest sample +// handed to ABRP, so the car's retry of a failed request cannot leave ABRP with data the +// database never got. +func (a *API) telemetry(w http.ResponseWriter, r *http.Request, body []byte) error { + batch, err := validateTelemetryBatch(body) + if err != nil { + return err + } + if err := a.Store.WriteTelemetry(r.Context(), batch.InstallationID, batch.Samples); err != nil { + return err + } + a.Forwarder.Forward(batch.Samples) + w.WriteHeader(http.StatusNoContent) + return nil +} + +// consumption stores one delta chunk at its recordedAt. Reads add a day's chunks up, so a +// chunk the car re-sends from its offline cache replaces itself instead of counting twice. +func (a *API) consumption(w http.ResponseWriter, r *http.Request, body []byte) error { + chunk, err := validateConsumption(body) + if err != nil { + return err + } + if err := a.Store.WriteConsumptionChunk(r.Context(), chunk); err != nil { + return err + } + w.WriteHeader(http.StatusNoContent) + return nil +} + +func (a *API) chargingSession(w http.ResponseWriter, r *http.Request, body []byte) error { + session, err := validateChargingSession(body, r.PathValue("sessionId")) + if err != nil { + return err + } + if err := a.Store.WriteChargingSession(r.Context(), session); err != nil { + return err + } + w.WriteHeader(http.StatusNoContent) + return nil +} + +// history is the car's read-back of its own uploaded totals, so an app reinstall does not +// blank its Consumption tab. Up to 400 days, newest first, plus the lifetime sum of all. +func (a *API) history(w http.ResponseWriter, r *http.Request, _ []byte) error { + id, err := validUUID(r.PathValue("installationId"), "installationId") + if err != nil { + return err + } + chunks, err := a.Store.ReadConsumptionChunks(r.Context(), id) + if err != nil { + return err + } + days := aggregateDays(chunks) + var lifetime Totals + if len(days) > 0 { + lifetime = days[0].Lifetime + } + if len(days) > 400 { + days = days[:400] + } + writeJSON(w, http.StatusOK, struct { + Records []DailyConsumption `json:"records"` + Lifetime Totals `json:"lifetime"` + }{Records: days, Lifetime: lifetime}) + return nil +} + +func writeJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(value); err != nil { + slog.Warn("response not written", "error", err) + } +} diff --git a/server/api_test.go b/server/api_test.go new file mode 100644 index 0000000..c466fde --- /dev/null +++ b/server/api_test.go @@ -0,0 +1,170 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" +) + +type fakeStore struct { + mu sync.Mutex + telemetry []TelemetrySample + chunks []ConsumptionChunk + sessions []ChargingSession + fail error +} + +func (f *fakeStore) WriteTelemetry(_ context.Context, _ string, s []TelemetrySample) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.fail != nil { + return f.fail + } + f.telemetry = append(f.telemetry, s...) + return nil +} + +func (f *fakeStore) WriteConsumptionChunk(_ context.Context, c ConsumptionChunk) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.fail != nil { + return f.fail + } + f.chunks = append(f.chunks, c) + return nil +} + +func (f *fakeStore) WriteChargingSession(_ context.Context, s ChargingSession) error { + f.mu.Lock() + defer f.mu.Unlock() + f.sessions = append(f.sessions, s) + return f.fail +} + +func (f *fakeStore) ReadConsumptionChunks(context.Context, string) ([]ConsumptionChunk, error) { + f.mu.Lock() + defer f.mu.Unlock() + return f.chunks, f.fail +} + +const testToken = "ingest-test-token-0123456789abcdef" + +func newTestAPI() (*API, *fakeStore) { + store := &fakeStore{} + return &API{Store: store, IngestToken: testToken, Forwarder: &Forwarder{}}, store +} + +func do(t *testing.T, h http.Handler, method, path, token, body string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(method, path, strings.NewReader(body)) + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec +} + +func TestHealthNeedsNoToken(t *testing.T) { + api, _ := newTestAPI() + rec := do(t, api.Routes(), "GET", "/v1/health", "", "") + if rec.Code != 200 || strings.TrimSpace(rec.Body.String()) != `{"status":"ok"}` { + t.Fatalf("%d %s", rec.Code, rec.Body) + } +} + +func TestEveryOtherEndpointNeedsTheIngestToken(t *testing.T) { + api, _ := newTestAPI() + h := api.Routes() + for _, c := range []struct{ method, path string }{ + {"POST", "/v1/telemetry"}, + {"PUT", "/v1/consumption/daily"}, + {"PUT", "/v1/charging-sessions/" + testSession}, + {"GET", "/v1/consumption/history/" + testInstallation}, + } { + for _, token := range []string{"", "wrong-token-0123456789abcdef", testToken + "x"} { + if rec := do(t, h, c.method, c.path, token, "{}"); rec.Code != 401 { + t.Errorf("%s %s with %q: want 401, got %d", c.method, c.path, token, rec.Code) + } + } + } +} + +func TestTelemetryIsStoredAndBadInputIsA400(t *testing.T) { + api, store := newTestAPI() + h := api.Routes() + rec := do(t, h, "POST", "/v1/telemetry", testToken, string(telemetryBody(`{"utc":1790000000,"soc":80}`))) + if rec.Code != 204 || len(store.telemetry) != 1 { + t.Fatalf("%d, stored %d", rec.Code, len(store.telemetry)) + } + rec = do(t, h, "POST", "/v1/telemetry", testToken, string(telemetryBody(`{"utc":1790000000,"soc":120}`))) + if rec.Code != 400 || !strings.Contains(rec.Body.String(), "samples[0].soc is invalid") { + t.Fatalf("%d %s", rec.Code, rec.Body) + } + if rec := do(t, h, "GET", "/v1/telemetry", testToken, ""); rec.Code != 405 { + t.Fatalf("GET on a POST route: want 405, got %d", rec.Code) + } +} + +func TestAFailedWriteIsA500WithoutDetail(t *testing.T) { + api, store := newTestAPI() + store.fail = errors.New("influx says: token xyz rejected") + rec := do(t, api.Routes(), "POST", "/v1/telemetry", testToken, string(telemetryBody(`{"utc":1790000000,"soc":80}`))) + if rec.Code != 500 || strings.Contains(rec.Body.String(), "xyz") { + t.Fatalf("%d %s", rec.Code, rec.Body) + } +} + +func TestOversizedBodiesAreRejected(t *testing.T) { + api, _ := newTestAPI() + big := `{"x":"` + strings.Repeat("a", maxBodyBytes) + `"}` + if rec := do(t, api.Routes(), "PUT", "/v1/consumption/daily", testToken, big); rec.Code != 400 { + t.Fatalf("want 400, got %d", rec.Code) + } +} + +func TestHistoryReturnsDaysNewestFirstAndTheLifetime(t *testing.T) { + api, _ := newTestAPI() + h := api.Routes() + day := func(date string, at int64) string { + return `{"installationId":"` + testInstallation + `","date":"` + date + `","recordedAt":` + + jsonInt(at) + `,"day":{"km":2.5,"kwh":0.4,"hours":0.03,"socDrop":0.6}}` + } + // Two chunks on one day and one on the next. Retry dedupe is InfluxDB's overwrite and is + // covered by the end-to-end check, not this fake. + for _, body := range []string{day("2026-10-16", 1000), day("2026-10-16", 2000), day("2026-10-17", 3000)} { + if rec := do(t, h, "PUT", "/v1/consumption/daily", testToken, body); rec.Code != 204 { + t.Fatalf("chunk: %d %s", rec.Code, rec.Body) + } + } + rec := do(t, h, "GET", "/v1/consumption/history/"+testInstallation, testToken, "") + if rec.Code != 200 { + t.Fatalf("%d %s", rec.Code, rec.Body) + } + var got struct { + Records []struct { + Date string `json:"date"` + Day Totals `json:"day"` + } `json:"records"` + Lifetime Totals `json:"lifetime"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if len(got.Records) != 2 || got.Records[0].Date != "2026-10-17" || got.Records[1].Day.Km != 5 || got.Lifetime.Km != 7.5 { + t.Fatalf("%s", rec.Body) + } + if rec := do(t, h, "GET", "/v1/consumption/history/not-a-uuid", testToken, ""); rec.Code != 400 { + t.Fatalf("bad id: want 400, got %d", rec.Code) + } +} + +func jsonInt(v int64) string { + b, _ := json.Marshal(v) + return string(b) +} diff --git a/server/days.go b/server/days.go new file mode 100644 index 0000000..2651bc6 --- /dev/null +++ b/server/days.go @@ -0,0 +1,51 @@ +package main + +import "sort" + +// aggregateDays folds chunks into days, newest day first, each with its running lifetime +// total. Pure, so the arithmetic is tested without a database. +func aggregateDays(chunks []ConsumptionChunk) []DailyConsumption { + type day struct { + DailyConsumption + sohAt int64 + hasSoh bool + } + byDate := map[string]*day{} + for _, chunk := range chunks { + d, ok := byDate[chunk.Date] + if !ok { + d = &day{DailyConsumption: DailyConsumption{ + InstallationID: chunk.InstallationID, + Date: chunk.Date, + RecordedAt: chunk.RecordedAt, + }} + byDate[chunk.Date] = d + } + d.Day.add(chunk.Day) + if chunk.RecordedAt.After(d.RecordedAt) { + d.RecordedAt = chunk.RecordedAt + } + // The newest manual SoH of the day wins. + at := chunk.RecordedAt.UnixMilli() + if chunk.SohPercent != nil && (!d.hasSoh || at > d.sohAt) { + soh := *chunk.SohPercent + d.SohPercent = &soh + d.sohAt = at + d.hasSoh = true + } + } + days := make([]DailyConsumption, 0, len(byDate)) + for _, d := range byDate { + days = append(days, d.DailyConsumption) + } + sort.Slice(days, func(i, j int) bool { return days[i].Date < days[j].Date }) + var running Totals + for i := range days { + running.add(days[i].Day) + days[i].Lifetime = running + } + for i, j := 0, len(days)-1; i < j; i, j = i+1, j-1 { + days[i], days[j] = days[j], days[i] + } + return days +} diff --git a/server/go.mod b/server/go.mod new file mode 100644 index 0000000..ed82e11 --- /dev/null +++ b/server/go.mod @@ -0,0 +1,15 @@ +module git.stinnesbeck.com/nils/mg4/server + +go 1.26.0 + +toolchain go1.26.9 + +require github.com/influxdata/influxdb-client-go/v2 v2.14.0 + +require ( + github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect + github.com/google/uuid v1.3.1 // indirect + github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839 // indirect + github.com/oapi-codegen/runtime v1.0.0 // indirect + golang.org/x/net v0.60.0 // indirect +) diff --git a/server/go.sum b/server/go.sum new file mode 100644 index 0000000..7303d63 --- /dev/null +++ b/server/go.sum @@ -0,0 +1,27 @@ +github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= +github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= +github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= +github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4= +github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/influxdata/influxdb-client-go/v2 v2.14.0 h1:AjbBfJuq+QoaXNcrova8smSjwJdUHnwvfjMF71M1iI4= +github.com/influxdata/influxdb-client-go/v2 v2.14.0/go.mod h1:Ahpm3QXKMJslpXl3IftVLVezreAUtBOTZssDrjZEFHI= +github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839 h1:W9WBk7wlPfJLvMCdtV4zPulc4uCPrlywQOmbFOhgQNU= +github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839/go.mod h1:xaLFMmpvUxqXtVkUJfg9QmT88cDaCJ3ZKgdZ78oO8Qo= +github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= +github.com/oapi-codegen/runtime v1.0.0 h1:P4rqFX5fMFWqRzY9M/3YF9+aPSPPB06IzP2P7oOxrWo= +github.com/oapi-codegen/runtime v1.0.0/go.mod h1:LmCUMQuPB4M/nLXilQXhHw+BLZdDb18B34OO356yJ/A= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= +golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/server/logic_test.go b/server/logic_test.go new file mode 100644 index 0000000..bf83bf9 --- /dev/null +++ b/server/logic_test.go @@ -0,0 +1,121 @@ +package main + +import ( + "io" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" +) + +func chunk(date string, atMs int64, km, kwh float64, soh *float64) ConsumptionChunk { + return ConsumptionChunk{ + InstallationID: testInstallation, Date: date, RecordedAt: time.UnixMilli(atMs), + Day: Totals{Km: km, Kwh: kwh, Hours: km / 50, SocDrop: kwh / 0.6}, SohPercent: soh, + } +} + +func ptr(f float64) *float64 { return &f } + +func TestAggregateDaysSumsPerDayNewestFirstWithRunningLifetime(t *testing.T) { + days := aggregateDays([]ConsumptionChunk{ + chunk("2026-10-16", 1000, 10, 2, nil), + chunk("2026-10-17", 3000, 5, 1, nil), + chunk("2026-10-16", 2000, 20, 3, nil), + }) + if len(days) != 2 || days[0].Date != "2026-10-17" || days[1].Date != "2026-10-16" { + t.Fatalf("order: %+v", days) + } + if days[1].Day.Km != 30 || days[1].Day.Kwh != 5 || days[1].RecordedAt.UnixMilli() != 2000 { + t.Fatalf("day sums: %+v", days[1]) + } + if days[1].Lifetime.Km != 30 || days[0].Lifetime.Km != 35 || days[0].Lifetime.Kwh != 6 { + t.Fatalf("lifetime: %+v / %+v", days[0].Lifetime, days[1].Lifetime) + } +} + +func TestAggregateDaysNewestSohWins(t *testing.T) { + days := aggregateDays([]ConsumptionChunk{ + chunk("2026-10-16", 2000, 1, 1, ptr(96)), + chunk("2026-10-16", 1000, 1, 1, ptr(98)), + chunk("2026-10-17", 3000, 1, 1, nil), + }) + if *days[1].SohPercent != 96 { + t.Fatalf("want 96, got %v", *days[1].SohPercent) + } + if days[0].SohPercent != nil { + t.Fatal("a day without SoH got one") + } +} + +func TestTlmSendsFlagsAsZeroOrOne(t *testing.T) { + got := tlm(TelemetrySample{UTC: testNowS, Numbers: map[string]float64{"soc": 50}, + Flags: map[string]bool{"is_charging": true, "is_parked": false}}) + if got["utc"] != int64(testNowS) || got["soc"] != 50.0 || got["is_charging"] != 1 || got["is_parked"] != 0 { + t.Fatalf("tlm %+v", got) + } +} + +func TestForwarderPicksOnlyTheNewestFreshSampleOncePerInterval(t *testing.T) { + f := &Forwarder{MinInterval: 5 * time.Second} + now := time.Unix(testNowS, 0) + got, ok := f.pick([]TelemetrySample{{UTC: testNowS - 10}, {UTC: testNowS - 2}, {UTC: testNowS - 5}}, now) + if !ok || got.UTC != testNowS-2 { + t.Fatalf("picked %+v %v", got, ok) + } + if _, ok := f.pick([]TelemetrySample{{UTC: testNowS}}, now.Add(time.Second)); ok { + t.Fatal("forwarded again within the interval") + } + if _, ok := f.pick([]TelemetrySample{{UTC: testNowS - 3}}, now.Add(time.Minute)); ok { + t.Fatal("forwarded an older sample than the last one") + } + // A sample from ten minutes ago describes a car that has moved on. + fresh := &Forwarder{MinInterval: 5 * time.Second} + if _, ok := fresh.pick([]TelemetrySample{{UTC: testNowS - 600}}, now); ok { + t.Fatal("forwarded a stale sample") + } +} + +func TestForwarderPutsCredentialsInTheBodyNotTheURL(t *testing.T) { + received := make(chan *http.Request, 1) + var form url.Values + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + form, _ = url.ParseQuery(string(body)) + received <- r + })) + defer srv.Close() + f := &Forwarder{APIKey: "key-123", UserToken: "token-456", Endpoint: srv.URL + "/1/tlm/send"} + if err := f.send(t.Context(), TelemetrySample{UTC: testNowS, Numbers: map[string]float64{"soc": 80}}); err != nil { + t.Fatal(err) + } + r := <-received + if r.URL.RawQuery != "" { + t.Fatalf("credentials leaked into the URL: %s", r.URL.RawQuery) + } + if form.Get("api_key") != "key-123" || form.Get("token") != "token-456" || form.Get("tlm") != `{"soc":80,"utc":1790000000}` { + t.Fatalf("form %v", form) + } +} + +func TestLoadConfigFailsClosed(t *testing.T) { + env := map[string]string{ + "INFLUX_URL": "http://influxdb:8086", "INFLUX_TOKEN": "t", "INFLUX_ORG": "mg4", + "INGEST_TOKEN": "0123456789abcdef01234567", + } + get := func(k string) string { return env[k] } + c, err := loadConfig(get) + if err != nil || c.listenAddr != ":8080" || c.influxBucket != "mg4" || c.abrpMinInterval != 5*time.Second { + t.Fatalf("defaults: %+v %v", c, err) + } + env["INGEST_TOKEN"] = "short" + if _, err := loadConfig(get); err == nil { + t.Fatal("short ingest token accepted") + } + env["INGEST_TOKEN"] = "0123456789abcdef01234567" + delete(env, "INFLUX_TOKEN") + if _, err := loadConfig(get); err == nil { + t.Fatal("missing InfluxDB token accepted") + } +} diff --git a/server/main.go b/server/main.go new file mode 100644 index 0000000..18ca0b6 --- /dev/null +++ b/server/main.go @@ -0,0 +1,113 @@ +// Command server is the Nils' MG4 car server: the API the car writes its telemetry, +// consumption and charging sessions to, stored in InfluxDB 2 and forwarded live to ABRP. +// Dashboards are Grafana's job; Grafana reads the same bucket directly. +package main + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "os" + "os/signal" + "strconv" + "syscall" + "time" +) + +const minIngestTokenLength = 24 + +type config struct { + listenAddr string + influxURL string + influxToken string + influxOrg string + influxBucket string + ingestToken string + abrpAPIKey string + abrpUserToken string + abrpMinInterval time.Duration +} + +// loadConfig reads the environment and fails closed: a missing InfluxDB setting or a +// short ingest token stops the server from starting rather than serving something broken. +func loadConfig(getenv func(string) string) (config, error) { + c := config{ + listenAddr: getenv("LISTEN_ADDR"), + influxURL: getenv("INFLUX_URL"), + influxToken: getenv("INFLUX_TOKEN"), + influxOrg: getenv("INFLUX_ORG"), + influxBucket: getenv("INFLUX_BUCKET"), + ingestToken: getenv("INGEST_TOKEN"), + abrpAPIKey: getenv("ABRP_API_KEY"), + abrpUserToken: getenv("ABRP_USER_TOKEN"), + } + if c.listenAddr == "" { + c.listenAddr = ":8080" + } + if c.influxBucket == "" { + c.influxBucket = "mg4" + } + for name, value := range map[string]string{"INFLUX_URL": c.influxURL, "INFLUX_TOKEN": c.influxToken, "INFLUX_ORG": c.influxOrg} { + if value == "" { + return config{}, fmt.Errorf("%s is not configured", name) + } + } + if len(c.ingestToken) < minIngestTokenLength { + return config{}, fmt.Errorf("INGEST_TOKEN must be at least %d characters", minIngestTokenLength) + } + c.abrpMinInterval = 5 * time.Second + if raw := getenv("ABRP_MIN_INTERVAL_SECONDS"); raw != "" { + seconds, err := strconv.ParseFloat(raw, 64) + if err != nil || seconds < 0 { + return config{}, errors.New("ABRP_MIN_INTERVAL_SECONDS must be a non-negative number") + } + c.abrpMinInterval = time.Duration(seconds * float64(time.Second)) + } + return c, nil +} + +func main() { + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, nil))) + cfg, err := loadConfig(os.Getenv) + if err != nil { + slog.Error("invalid configuration", "error", err) + os.Exit(1) + } + + store := NewInfluxStore(cfg.influxURL, cfg.influxToken, cfg.influxOrg, cfg.influxBucket) + defer store.Close() + forwarder := &Forwarder{ + APIKey: cfg.abrpAPIKey, + UserToken: cfg.abrpUserToken, + MinInterval: cfg.abrpMinInterval, + Client: &http.Client{Timeout: 10 * time.Second}, + } + api := &API{Store: store, IngestToken: cfg.ingestToken, Forwarder: forwarder} + + srv := &http.Server{ + Addr: cfg.listenAddr, + Handler: api.Routes(), + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 120 * time.Second, + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + go func() { + slog.Info("listening", "addr", cfg.listenAddr, "bucket", cfg.influxBucket, "abrp", forwarder.configured()) + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + slog.Error("server failed", "error", err) + os.Exit(1) + } + }() + <-ctx.Done() + shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := srv.Shutdown(shutdown); err != nil { + slog.Error("shutdown", "error", err) + } +} diff --git a/server/model.go b/server/model.go new file mode 100644 index 0000000..955f932 --- /dev/null +++ b/server/model.go @@ -0,0 +1,108 @@ +package main + +import "time" + +// Totals are one day's (or one chunk's) consumption counters. +type Totals struct { + Km float64 `json:"km"` + Kwh float64 `json:"kwh"` + Hours float64 `json:"hours"` + SocDrop float64 `json:"socDrop"` +} + +func (t *Totals) add(o Totals) { + t.Km += o.Km + t.Kwh += o.Kwh + t.Hours += o.Hours + t.SocDrop += o.SocDrop +} + +// ConsumptionChunk is one 2-minute delta of a day's consumption, as the car sends it. +// Days are built from these when history is read. +type ConsumptionChunk struct { + InstallationID string + Date string // the car's local date, YYYY-MM-DD + RecordedAt time.Time + Day Totals + SohPercent *float64 +} + +// DailyConsumption is a day folded from its chunks. +type DailyConsumption struct { + InstallationID string `json:"installationId"` + Date string `json:"date"` + RecordedAt time.Time `json:"recordedAt"` + Day Totals `json:"day"` + SohPercent *float64 `json:"sohPercent,omitempty"` + // Lifetime is the running total up to and including this day; not part of the + // car's history response, which carries one lifetime for all days. + Lifetime Totals `json:"-"` +} + +type CurvePoint struct { + Timestamp time.Time + PowerKw float64 + SocPercent *float64 +} + +type ChargingSession struct { + InstallationID string + SessionID string + StartedAt time.Time + EndedAt time.Time + DurationSeconds float64 + EnergyKwh float64 + ChargingLossPercent float64 + GridEnergyKwh float64 + PricePerKwh float64 + TotalCost float64 + IsDemo bool + StartSocPercent *float64 + EndSocPercent *float64 + Curve []CurvePoint +} + +// telemetryNumbers are the numeric live fields, named and unit-ed as ABRP's tlm/send +// expects them, with the range each one must fall in. +var telemetryNumbers = map[string][2]float64{ + "soc": {0, 100}, // % + "speed": {0, 400}, // km/h + "power": {-1000, 1000}, // kW, positive leaving the battery + "est_battery_range": {0, 2000}, // km + "capacity": {0, 500}, // kWh + "soe": {0, 500}, // kWh + "soh": {0, 100}, // % + "kwh_charged": {0, 1000}, // kWh, this charge session + "odometer": {0, 10_000_000}, // km + "lat": {-90, 90}, // ° + "lon": {-180, 180}, // ° + "elevation": {-1000, 10_000}, // m + "heading": {0, 360}, // ° + "batt_temp": {-60, 100}, // °C + "cabin_temp": {-60, 100}, // °C + "ext_temp": {-60, 70}, // °C + "hvac_setpoint": {0, 40}, // °C + "hvac_power": {0, 50}, // kW + "voltage": {0, 1500}, // V + "current": {-2000, 2000}, // A + "tire_pressure_fl": {0, 1000}, // kPa + "tire_pressure_fr": {0, 1000}, // kPa + "tire_pressure_rl": {0, 1000}, // kPa + "tire_pressure_rr": {0, 1000}, // kPa +} + +// telemetryFlags are the boolean live fields; ABRP takes them as 0/1. +var telemetryFlags = []string{"is_charging", "is_dcfc", "is_parked"} + +// TelemetrySample is one live sample. Only UTC is always present; an unread signal is +// absent from Numbers and Flags, never 0. +type TelemetrySample struct { + UTC int64 // epoch seconds + Numbers map[string]float64 + Flags map[string]bool +} + +type TelemetryBatch struct { + InstallationID string + Samples []TelemetrySample +} diff --git a/server/store.go b/server/store.go new file mode 100644 index 0000000..9f49601 --- /dev/null +++ b/server/store.go @@ -0,0 +1,162 @@ +package main + +import ( + "context" + "fmt" + "strings" + "time" + + influxdb2 "github.com/influxdata/influxdb-client-go/v2" + "github.com/influxdata/influxdb-client-go/v2/api" + "github.com/influxdata/influxdb-client-go/v2/api/write" +) + +// Store is what the handlers need from the database; a fake implements it in tests. +type Store interface { + WriteTelemetry(ctx context.Context, installationID string, samples []TelemetrySample) error + WriteConsumptionChunk(ctx context.Context, chunk ConsumptionChunk) error + WriteChargingSession(ctx context.Context, session ChargingSession) error + ReadConsumptionChunks(ctx context.Context, installationID string) ([]ConsumptionChunk, error) +} + +// InfluxStore keeps everything in one InfluxDB 2 bucket; every point carries an +// installationId tag. Grafana reads the same bucket directly. +// +// telemetry time = sample utc ABRP tlm fields (floats and booleans) +// consumption_chunk time = recordedAt tags: date km, kwh, hours, socDrop, sohPercent +// charging_session time = startedAt tags: sessionId summary fields +// charging_curve time = point time tags: sessionId powerKw, socPercent +// +// Idempotency comes from InfluxDB itself: a point with the same measurement, tags and +// timestamp replaces the earlier one. The car re-sends a cached chunk with its original +// recordedAt and a session under its own id, so a retry overwrites instead of adding. +// +// Every number is written as a float. InfluxDB fixes a field's type on first write, so +// a 0 written as an integer would make every later 0.5 a rejected write. +type InfluxStore struct { + client influxdb2.Client + write api.WriteAPIBlocking + query api.QueryAPI + bucket string +} + +const tagInstallation = "installationId" + +func NewInfluxStore(url, token, org, bucket string) *InfluxStore { + client := influxdb2.NewClientWithOptions(url, token, + influxdb2.DefaultOptions().SetHTTPRequestTimeout(10).SetPrecision(time.Millisecond)) + return &InfluxStore{ + client: client, + write: client.WriteAPIBlocking(org, bucket), + query: client.QueryAPI(org), + bucket: bucket, + } +} + +func (s *InfluxStore) Close() { s.client.Close() } + +func (s *InfluxStore) WriteTelemetry(ctx context.Context, id string, samples []TelemetrySample) error { + points := make([]*write.Point, 0, len(samples)) + for _, sample := range samples { + fields := make(map[string]any, len(sample.Numbers)+len(sample.Flags)) + for k, v := range sample.Numbers { + fields[k] = v + } + for k, v := range sample.Flags { + fields[k] = v + } + if len(fields) == 0 { + // A point needs at least one field; a bare timestamp says nothing anyway. + continue + } + points = append(points, influxdb2.NewPoint("telemetry", + map[string]string{tagInstallation: id}, fields, time.Unix(sample.UTC, 0))) + } + if len(points) == 0 { + return nil + } + return s.write.WritePoint(ctx, points...) +} + +func (s *InfluxStore) WriteConsumptionChunk(ctx context.Context, c ConsumptionChunk) error { + fields := map[string]any{"km": c.Day.Km, "kwh": c.Day.Kwh, "hours": c.Day.Hours, "socDrop": c.Day.SocDrop} + if c.SohPercent != nil { + fields["sohPercent"] = *c.SohPercent + } + return s.write.WritePoint(ctx, influxdb2.NewPoint("consumption_chunk", + map[string]string{tagInstallation: c.InstallationID, "date": c.Date}, fields, c.RecordedAt)) +} + +func (s *InfluxStore) WriteChargingSession(ctx context.Context, cs ChargingSession) error { + tags := map[string]string{tagInstallation: cs.InstallationID, "sessionId": cs.SessionID} + summary := map[string]any{ + "endedAtMs": cs.EndedAt.UnixMilli(), + "durationSeconds": cs.DurationSeconds, + "energyKwh": cs.EnergyKwh, + "chargingLossPercent": cs.ChargingLossPercent, + "gridEnergyKwh": cs.GridEnergyKwh, + "pricePerKwh": cs.PricePerKwh, + "totalCost": cs.TotalCost, + "isDemo": cs.IsDemo, + } + if cs.StartSocPercent != nil { + summary["startSocPercent"] = *cs.StartSocPercent + } + if cs.EndSocPercent != nil { + summary["endSocPercent"] = *cs.EndSocPercent + } + points := []*write.Point{influxdb2.NewPoint("charging_session", tags, summary, cs.StartedAt)} + for _, p := range cs.Curve { + fields := map[string]any{"powerKw": p.PowerKw} + if p.SocPercent != nil { + fields["socPercent"] = *p.SocPercent + } + points = append(points, influxdb2.NewPoint("charging_curve", tags, fields, p.Timestamp)) + } + return s.write.WritePoint(ctx, points...) +} + +// fluxString quotes a value as a Flux string literal. Ids are validated UUIDs before they +// get here; the escaping is for the bucket name and as a second line of defence. +func fluxString(value string) string { + return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`, "$", `\$`).Replace(value) + `"` +} + +func (s *InfluxStore) ReadConsumptionChunks(ctx context.Context, id string) ([]ConsumptionChunk, error) { + query := fmt.Sprintf(`from(bucket: %s) + |> range(start: 0) + |> filter(fn: (r) => r._measurement == "consumption_chunk" and r.installationId == %s) + |> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")`, + fluxString(s.bucket), fluxString(id)) + result, err := s.query.Query(ctx, query) + if err != nil { + return nil, err + } + defer result.Close() + var chunks []ConsumptionChunk + for result.Next() { + r := result.Record() + date, _ := r.ValueByKey("date").(string) + chunk := ConsumptionChunk{ + InstallationID: id, + Date: date, + RecordedAt: r.Time(), + Day: Totals{ + Km: floatValue(r.ValueByKey("km")), + Kwh: floatValue(r.ValueByKey("kwh")), + Hours: floatValue(r.ValueByKey("hours")), + SocDrop: floatValue(r.ValueByKey("socDrop")), + }, + } + if soh, ok := r.ValueByKey("sohPercent").(float64); ok { + chunk.SohPercent = &soh + } + chunks = append(chunks, chunk) + } + return chunks, result.Err() +} + +func floatValue(v any) float64 { + f, _ := v.(float64) + return f +} diff --git a/server/validate.go b/server/validate.go new file mode 100644 index 0000000..146a64e --- /dev/null +++ b/server/validate.go @@ -0,0 +1,327 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "math" + "regexp" + "time" +) + +// Request validation for the ingest endpoints, ported rule for rule from the TypeScript +// server. The car is the only writer and it is trusted, but a bad field is written once +// and read forever: every number is bounded, and an out-of-range value is rejected rather +// than stored and quietly plotted. + +// ValidationError is a 400: the message may name the bad field. +type ValidationError struct{ msg string } + +func (e *ValidationError) Error() string { return e.msg } + +func invalid(format string, args ...any) error { + return &ValidationError{msg: fmt.Sprintf(format, args...)} +} + +var ( + uuidPattern = regexp.MustCompile(`(?i)^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`) + isoDatePattern = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`) +) + +const ( + maxSamples = 500 + maxCurvePoints = 720 + maxSafeInteger = 1<<53 - 1 + utcMin = 1_000_000_000 // epoch seconds: 2001… + utcMax = 9_999_999_999 // …2286; milliseconds would be far above + totalsMaxAbs = 10_000_000 + chargingLossPct = 10 +) + +// object is a decoded JSON object whose numbers stay json.Number, so integers and +// floats can be told apart and nothing is rounded on the way in. +type object map[string]any + +func decodeObject(body []byte) (object, error) { + dec := json.NewDecoder(bytes.NewReader(body)) + dec.UseNumber() + var value any + if err := dec.Decode(&value); err != nil { + return nil, invalid("body is not valid JSON") + } + if dec.More() { + return nil, invalid("body is not valid JSON") + } + return asObject(value, "body") +} + +func asObject(value any, name string) (object, error) { + o, ok := value.(map[string]any) + if !ok { + if name == "body" { + return nil, invalid("body is required") + } + return nil, invalid("%s is invalid", name) + } + return object(o), nil +} + +func (o object) has(key string) bool { + v, ok := o[key] + return ok && v != nil +} + +func number(value any, name string) (float64, error) { + n, ok := value.(json.Number) + if !ok { + return 0, invalid("%s is invalid", name) + } + f, err := n.Float64() + if err != nil || math.IsNaN(f) || math.IsInf(f, 0) { + return 0, invalid("%s is invalid", name) + } + return f, nil +} + +func bounded(value any, min, max float64, name string) (float64, error) { + f, err := number(value, name) + if err != nil || f < min || f > max { + return 0, invalid("%s is invalid", name) + } + return f, nil +} + +// integer accepts a whole, non-negative number up to 2^53-1 — what JavaScript and the +// car's JSON can carry exactly. +func integer(value any, name string) (int64, error) { + f, err := number(value, name) + if err != nil || f != math.Trunc(f) || f < 0 || f > maxSafeInteger { + return 0, invalid("%s is invalid", name) + } + return int64(f), nil +} + +func validDate(value any, name string) (time.Time, error) { + ms, err := integer(value, name) + if err != nil { + return time.Time{}, err + } + return time.UnixMilli(ms).UTC(), nil +} + +func validUUID(value any, name string) (string, error) { + s, ok := value.(string) + if !ok || !uuidPattern.MatchString(s) { + return "", invalid("%s is invalid", name) + } + return s, nil +} + +func optionalBounded(o object, key string, min, max float64, name string) (*float64, error) { + if !o.has(key) { + return nil, nil + } + f, err := bounded(o[key], min, max, name) + if err != nil { + return nil, err + } + return &f, nil +} + +func validTotals(value any, name string) (Totals, error) { + o, err := asObject(value, name) + if err != nil { + return Totals{}, err + } + var t Totals + for _, field := range []struct { + key string + dst *float64 + }{{"km", &t.Km}, {"kwh", &t.Kwh}, {"hours", &t.Hours}, {"socDrop", &t.SocDrop}} { + f, err := number(o[field.key], name+"."+field.key) + if err != nil || math.Abs(f) > totalsMaxAbs { + return Totals{}, invalid("%s.%s is invalid", name, field.key) + } + *field.dst = f + } + return t, nil +} + +func validateConsumption(body []byte) (ConsumptionChunk, error) { + o, err := decodeObject(body) + if err != nil { + return ConsumptionChunk{}, err + } + date, ok := o["date"].(string) + if !ok || !isoDatePattern.MatchString(date) { + return ConsumptionChunk{}, invalid("date is invalid") + } + id, err := validUUID(o["installationId"], "installationId") + if err != nil { + return ConsumptionChunk{}, err + } + recordedAt, err := validDate(o["recordedAt"], "recordedAt") + if err != nil { + return ConsumptionChunk{}, err + } + day, err := validTotals(o["day"], "day") + if err != nil { + return ConsumptionChunk{}, err + } + // A lifetime from an older app is still validated, so a malformed one is still a 400, + // but it is not stored: lifetime is derived from the day totals when read. + if o.has("lifetime") { + if _, err := validTotals(o["lifetime"], "lifetime"); err != nil { + return ConsumptionChunk{}, err + } + } + soh, err := optionalBounded(o, "sohPercent", 0, 100, "sohPercent") + if err != nil { + return ConsumptionChunk{}, err + } + return ConsumptionChunk{InstallationID: id, Date: date, RecordedAt: recordedAt, Day: day, SohPercent: soh}, nil +} + +func validateChargingSession(body []byte, pathSessionID string) (ChargingSession, error) { + o, err := decodeObject(body) + if err != nil { + return ChargingSession{}, err + } + sessionID, err := validUUID(o["sessionId"], "sessionId") + if err != nil { + return ChargingSession{}, err + } + if sessionID != pathSessionID { + return ChargingSession{}, invalid("sessionId does not match path") + } + rawCurve, ok := o["curve"].([]any) + if !ok || len(rawCurve) > maxCurvePoints { + return ChargingSession{}, invalid("curve is invalid") + } + curve := make([]CurvePoint, 0, len(rawCurve)) + for _, entry := range rawCurve { + p, err := asObject(entry, "curve") + if err != nil { + return ChargingSession{}, err + } + ts, err := validDate(p["timestampMs"], "curve.timestampMs") + if err != nil { + return ChargingSession{}, err + } + power, err := bounded(p["powerKw"], 0, 1000, "curve.powerKw") + if err != nil { + return ChargingSession{}, err + } + soc, err := optionalBounded(p, "socPercent", 0, 100, "curve.socPercent") + if err != nil { + return ChargingSession{}, err + } + curve = append(curve, CurvePoint{Timestamp: ts, PowerKw: power, SocPercent: soc}) + } + s := ChargingSession{SessionID: sessionID, Curve: curve, ChargingLossPercent: chargingLossPct} + if s.EnergyKwh, err = bounded(o["energyKwh"], 0, 1000, "energyKwh"); err != nil { + return ChargingSession{}, err + } + if s.PricePerKwh, err = bounded(o["pricePerKwh"], 0, 1_000_000, "pricePerKwh"); err != nil { + return ChargingSession{}, err + } + if s.InstallationID, err = validUUID(o["installationId"], "installationId"); err != nil { + return ChargingSession{}, err + } + if s.StartedAt, err = validDate(o["startedAt"], "startedAt"); err != nil { + return ChargingSession{}, err + } + if s.EndedAt, err = validDate(o["endedAt"], "endedAt"); err != nil { + return ChargingSession{}, err + } + if s.DurationSeconds, err = bounded(o["durationSeconds"], 0, 604800, "durationSeconds"); err != nil { + return ChargingSession{}, err + } + if s.StartSocPercent, err = optionalBounded(o, "startSocPercent", 0, 100, "startSocPercent"); err != nil { + return ChargingSession{}, err + } + if s.EndSocPercent, err = optionalBounded(o, "endSocPercent", 0, 100, "endSocPercent"); err != nil { + return ChargingSession{}, err + } + // The car's own loss and grid figures are not trusted: the server applies its fixed 10 %. + s.GridEnergyKwh = s.EnergyKwh * 1.1 + s.TotalCost = s.GridEnergyKwh * s.PricePerKwh + s.IsDemo = o["isDemo"] == true + return s, nil +} + +// validateTelemetryBatch checks a batch of live samples in ABRP's tlm field names and +// units. Unknown fields are dropped rather than rejected, so a newer app can add one +// without a server release; every known field is bounded; a flag may be 0/1 or a boolean. +func validateTelemetryBatch(body []byte) (TelemetryBatch, error) { + o, err := decodeObject(body) + if err != nil { + return TelemetryBatch{}, err + } + id, err := validUUID(o["installationId"], "installationId") + if err != nil { + return TelemetryBatch{}, err + } + raw, ok := o["samples"].([]any) + if !ok || len(raw) == 0 || len(raw) > maxSamples { + return TelemetryBatch{}, invalid("samples is invalid") + } + samples := make([]TelemetrySample, 0, len(raw)) + for i, entry := range raw { + sample, err := validateSample(entry, fmt.Sprintf("samples[%d]", i)) + if err != nil { + return TelemetryBatch{}, err + } + samples = append(samples, sample) + } + return TelemetryBatch{InstallationID: id, Samples: samples}, nil +} + +func validateSample(entry any, name string) (TelemetrySample, error) { + o, err := asObject(entry, name) + if err != nil { + return TelemetrySample{}, err + } + utc, err := integer(o["utc"], name+".utc") + if err != nil || utc < utcMin || utc > utcMax { + return TelemetrySample{}, invalid("%s.utc is invalid", name) + } + sample := TelemetrySample{UTC: utc, Numbers: map[string]float64{}, Flags: map[string]bool{}} + for field, limits := range telemetryNumbers { + if !o.has(field) { + continue + } + f, err := bounded(o[field], limits[0], limits[1], name+"."+field) + if err != nil { + return TelemetrySample{}, err + } + sample.Numbers[field] = f + } + for _, field := range telemetryFlags { + if !o.has(field) { + continue + } + switch v := o[field].(type) { + case bool: + sample.Flags[field] = v + case json.Number: + switch v.String() { + case "1": + sample.Flags[field] = true + case "0": + sample.Flags[field] = false + default: + return TelemetrySample{}, invalid("%s.%s is invalid", name, field) + } + default: + return TelemetrySample{}, invalid("%s.%s is invalid", name, field) + } + } + // A position is only a position as a pair. + _, hasLat := sample.Numbers["lat"] + _, hasLon := sample.Numbers["lon"] + if hasLat != hasLon { + return TelemetrySample{}, invalid("%s.lat and lon must be sent together", name) + } + return sample, nil +} diff --git a/server/validate_test.go b/server/validate_test.go new file mode 100644 index 0000000..baead87 --- /dev/null +++ b/server/validate_test.go @@ -0,0 +1,185 @@ +package main + +import ( + "errors" + "strings" + "testing" +) + +const ( + testInstallation = "6f1a2b3c-4d5e-4f60-8a9b-0c1d2e3f4a5b" + testSession = "11111111-2222-4333-8444-555555555555" + testNowS = 1_790_000_000 +) + +func isValidationError(err error) bool { + var v *ValidationError + return errors.As(err, &v) +} + +func TestConsumptionAcceptsAWellFormedChunk(t *testing.T) { + c, err := validateConsumption([]byte(`{"installationId":"` + testInstallation + `","date":"2026-10-07", + "recordedAt":1760000000000,"day":{"km":42.5,"kwh":7.25,"hours":0.9,"socDrop":11.5}, + "lifetime":{"km":12000,"kwh":2100,"hours":240,"socDrop":3400},"sohPercent":97.5}`)) + if err != nil { + t.Fatal(err) + } + if c.Date != "2026-10-07" || c.Day.Kwh != 7.25 || c.RecordedAt.UnixMilli() != 1760000000000 || *c.SohPercent != 97.5 { + t.Fatalf("unexpected chunk %+v", c) + } +} + +func TestConsumptionMissingSohIsOmittedNotDefaulted(t *testing.T) { + // The car omits a field it could not read. Writing 0 would be a battery at nothing. + c, err := validateConsumption([]byte(`{"installationId":"` + testInstallation + `","date":"2026-10-07", + "recordedAt":1,"day":{"km":1,"kwh":1,"hours":1,"socDrop":1}}`)) + if err != nil { + t.Fatal(err) + } + if c.SohPercent != nil { + t.Fatalf("SoH should be absent, got %v", *c.SohPercent) + } +} + +func TestConsumptionRejectsBadInput(t *testing.T) { + base := map[string]string{ + "installationId": `"` + testInstallation + `"`, "date": `"2026-10-07"`, "recordedAt": "1", + "day": `{"km":1,"kwh":1,"hours":1,"socDrop":1}`, + } + cases := map[string][2]string{ + "bad id": {"installationId", `"nope"`}, + "german date": {"date", `"07.10.2026"`}, + "negative timestamp": {"recordedAt", "-5"}, + "fractional time": {"recordedAt", "1.5"}, + "string number": {"day", `{"km":"1","kwh":1,"hours":1,"socDrop":1}`}, + "huge totals": {"day", `{"km":1e9,"kwh":1,"hours":1,"socDrop":1}`}, + "missing field": {"day", `{"km":1,"kwh":1,"hours":1}`}, + "bad lifetime": {"lifetime", `{"km":1}`}, + "soh above 100": {"sohPercent", "101"}, + } + for name, change := range cases { + fields := map[string]string{} + for k, v := range base { + fields[k] = v + } + fields[change[0]] = change[1] + var parts []string + for k, v := range fields { + parts = append(parts, `"`+k+`":`+v) + } + if _, err := validateConsumption([]byte("{" + strings.Join(parts, ",") + "}")); !isValidationError(err) { + t.Errorf("%s: want ValidationError, got %v", name, err) + } + } + for _, body := range []string{"", "[]", "null", "{", `{"a":1}{"b":2}`} { + if _, err := validateConsumption([]byte(body)); !isValidationError(err) { + t.Errorf("body %q: want ValidationError, got %v", body, err) + } + } +} + +func chargingBody(overrides string) []byte { + return []byte(`{"installationId":"` + testInstallation + `","sessionId":"` + testSession + `", + "startedAt":1760500000000,"endedAt":1760501800000,"durationSeconds":1800,"energyKwh":30, + "pricePerKwh":0.59,"startSocPercent":20,"endSocPercent":80, + "curve":[{"timestampMs":1760500060000,"powerKw":95,"socPercent":21},{"timestampMs":1760500120000,"powerKw":110}]` + + overrides + `}`) +} + +func TestChargingSessionAppliesTheServersLossAndCost(t *testing.T) { + // The car's own grid and cost figures are not trusted; the server applies 10 % loss. + s, err := validateChargingSession(chargingBody(`,"gridEnergyKwh":1,"totalCost":1`), testSession) + if err != nil { + t.Fatal(err) + } + if s.GridEnergyKwh != 33 || s.TotalCost != 33*0.59 || s.ChargingLossPercent != 10 { + t.Fatalf("grid %v cost %v loss %v", s.GridEnergyKwh, s.TotalCost, s.ChargingLossPercent) + } + if len(s.Curve) != 2 || s.Curve[1].SocPercent != nil || *s.Curve[0].SocPercent != 21 { + t.Fatalf("curve not kept as sent: %+v", s.Curve) + } +} + +func TestChargingSessionMustMatchItsPath(t *testing.T) { + if _, err := validateChargingSession(chargingBody(""), "22222222-2222-4333-8444-555555555555"); !isValidationError(err) { + t.Fatalf("want ValidationError, got %v", err) + } +} + +func TestChargingSessionRejectsOversizedCurvesAndBadPoints(t *testing.T) { + points := strings.Repeat(`{"timestampMs":1,"powerKw":1},`, 721) + long := []byte(`{"installationId":"` + testInstallation + `","sessionId":"` + testSession + `","curve":[` + + strings.TrimSuffix(points, ",") + `]}`) + if _, err := validateChargingSession(long, testSession); !isValidationError(err) { + t.Errorf("721 points: want ValidationError, got %v", err) + } + bad := []byte(strings.Replace(string(chargingBody("")), `"powerKw":95`, `"powerKw":-5`, 1)) + if _, err := validateChargingSession(bad, testSession); !isValidationError(err) { + t.Errorf("negative power: want ValidationError, got %v", err) + } +} + +func telemetryBody(samples string) []byte { + return []byte(`{"installationId":"` + testInstallation + `","samples":[` + samples + `]}`) +} + +func TestTelemetryKeepsOnlyWhatWasSent(t *testing.T) { + b, err := validateTelemetryBatch(telemetryBody( + `{"utc":1790000000,"soc":81,"power":-12.5,"speed":0,"lat":52.1,"lon":7.6,"is_charging":1,"is_dcfc":false}`)) + if err != nil { + t.Fatal(err) + } + s := b.Samples[0] + if s.UTC != testNowS || s.Numbers["soc"] != 81 || s.Numbers["power"] != -12.5 || s.Numbers["lat"] != 52.1 { + t.Fatalf("numbers %+v", s.Numbers) + } + if v, ok := s.Numbers["speed"]; !ok || v != 0 { + t.Fatal("a real 0 must be kept") + } + if s.Flags["is_charging"] != true || s.Flags["is_dcfc"] != false || len(s.Flags) != 2 { + t.Fatalf("flags %+v", s.Flags) + } + // An unread signal stays absent: a missing SOC must never become 0. + if _, ok := s.Numbers["est_battery_range"]; ok { + t.Fatal("absent field invented") + } +} + +func TestTelemetryDropsUnknownFields(t *testing.T) { + b, err := validateTelemetryBatch(telemetryBody(`{"utc":1790000000,"future_field":3}`)) + if err != nil { + t.Fatal(err) + } + if len(b.Samples[0].Numbers) != 0 || len(b.Samples[0].Flags) != 0 { + t.Fatalf("unknown field kept: %+v", b.Samples[0]) + } +} + +func TestTelemetryRejectsBadSamples(t *testing.T) { + for name, sample := range map[string]string{ + "milliseconds": `{"utc":1790000000000}`, + "fractional utc": `{"utc":1790000000.5}`, + "soc above 100": `{"utc":1790000000,"soc":101}`, + "string speed": `{"utc":1790000000,"speed":"fast"}`, + "flag 2": `{"utc":1790000000,"is_parked":2}`, + "half position": `{"utc":1790000000,"lat":52.1}`, + "not an object": `5`, + } { + if _, err := validateTelemetryBatch(telemetryBody(sample)); !isValidationError(err) { + t.Errorf("%s: want ValidationError, got %v", name, err) + } + } +} + +func TestTelemetryRejectsEmptyAndOversizedBatches(t *testing.T) { + if _, err := validateTelemetryBatch(telemetryBody("")); !isValidationError(err) { + t.Error("empty batch accepted") + } + many := strings.TrimSuffix(strings.Repeat(`{"utc":1790000000},`, 501), ",") + if _, err := validateTelemetryBatch(telemetryBody(many)); !isValidationError(err) { + t.Error("501 samples accepted") + } + if _, err := validateTelemetryBatch([]byte(`{"installationId":"nope","samples":[{"utc":1790000000}]}`)); !isValidationError(err) { + t.Error("bad installation id accepted") + } +} diff --git a/web/.dockerignore b/web/.dockerignore deleted file mode 100644 index 6614541..0000000 --- a/web/.dockerignore +++ /dev/null @@ -1,7 +0,0 @@ -node_modules -.next -.env* -!.env.example -*.tsbuildinfo -Dockerfile -.dockerignore diff --git a/web/.env.example b/web/.env.example deleted file mode 100644 index 1a81b62..0000000 --- a/web/.env.example +++ /dev/null @@ -1,18 +0,0 @@ -# InfluxDB 2 — a token with read/write on the bucket. -INFLUX_URL=http://influxdb:8086 -INFLUX_TOKEN=replace-with-an-influxdb-token -INFLUX_ORG=mg4 -INFLUX_BUCKET=mg4 - -# The car's write token (at least 24 characters): openssl rand -hex 32 -INGEST_TOKEN=replace-with-a-long-random-write-token -# Signs dashboard sessions; rotating it signs every browser out: openssl rand -hex 32 -SESSION_SECRET=replace-with-a-long-random-session-secret -# Dashboard login, at least 8 characters. -DASHBOARD_PASSWORD=replace-with-a-strong-dashboard-password - -# ABRP forwarding; off when either is empty. -ABRP_API_KEY= -ABRP_USER_TOKEN= -# Minimum seconds between two samples forwarded to ABRP. -ABRP_MIN_INTERVAL_SECONDS=5 diff --git a/web/.gitignore b/web/.gitignore deleted file mode 100644 index 27dfd45..0000000 --- a/web/.gitignore +++ /dev/null @@ -1,7 +0,0 @@ -node_modules -.next -# Every .env variant is secret except the committed template. -.env* -!.env.example -next-env.d.ts -*.tsbuildinfo diff --git a/web/Dockerfile b/web/Dockerfile deleted file mode 100644 index 22663d7..0000000 --- a/web/Dockerfile +++ /dev/null @@ -1,27 +0,0 @@ -# Self-hosted image of the car server: Next.js standalone output on Node 22. -# Configuration comes entirely from the environment at runtime (see .env.example); -# nothing secret is baked in. - -FROM node:22-alpine AS deps -WORKDIR /app -COPY package.json package-lock.json ./ -RUN npm ci --no-audit --no-fund - -FROM node:22-alpine AS build -WORKDIR /app -ENV NEXT_TELEMETRY_DISABLED=1 -COPY --from=deps /app/node_modules ./node_modules -COPY . . -RUN npm test && npm run build - -FROM node:22-alpine -WORKDIR /app -ENV NODE_ENV=production \ - NEXT_TELEMETRY_DISABLED=1 \ - PORT=3000 \ - HOSTNAME=0.0.0.0 -COPY --from=build --chown=10001:10001 /app/.next/standalone ./ -COPY --from=build --chown=10001:10001 /app/.next/static ./.next/static -USER 10001:10001 -EXPOSE 3000 -CMD ["node", "server.js"] diff --git a/web/README.md b/web/README.md deleted file mode 100644 index 71ce266..0000000 --- a/web/README.md +++ /dev/null @@ -1,88 +0,0 @@ -# Nils' MG4 — car server - -Next.js app, self-hosted at car.stinnesbeck.com: the ingest API the car writes to, the ABRP -forwarder, and a phone-shaped dashboard for reading the history back. Data lives in -InfluxDB 2. Based on upstream's Vercel/MongoDB app in fatihdonmezdev/FatihsMG4. - -``` -car ──live telemetry───▶ server ──▶ ABRP api.iternio.com/1/tlm/send - ──daily chunks─────▶ │ - ──charging sessions▶ ▼ - InfluxDB 2 -``` - -## Credentials - -| | Who holds it | What it opens | -|---|---|---| -| `INGEST_TOKEN` | the car, entered in the app | the write endpoints, and the car's own history read | -| `DASHBOARD_PASSWORD` | you, in a browser | the dashboard and the `GET /v1/history` reads | -| `ABRP_API_KEY`, `ABRP_USER_TOKEN` | only the server | forwarding live telemetry to ABRP | - -The ABRP credentials never reach the car. A missing or empty one switches forwarding off. - -## Environment variables - -See `.env.example`. In Kubernetes they come from the Secret in `../k8s/`. - -| Name | Notes | -|---|---| -| `INFLUX_URL`, `INFLUX_TOKEN`, `INFLUX_ORG`, `INFLUX_BUCKET` | bucket defaults to `mg4`; the token needs read/write on it | -| `INGEST_TOKEN` | at least 24 characters, or every write is refused: `openssl rand -hex 32` | -| `DASHBOARD_PASSWORD` | at least 8 characters, or login is refused outright | -| `SESSION_SECRET` | `openssl rand -hex 32`; rotating it signs every browser out | -| `ABRP_API_KEY`, `ABRP_USER_TOKEN` | optional; both needed to forward | -| `ABRP_MIN_INTERVAL_SECONDS` | default 5 | - -## Endpoints - -Write, `Authorization: Bearer `: - -- `POST /v1/telemetry` — `{ installationId, samples: [...] }`, up to 500 samples in ABRP's `tlm` - field names and units (`utc` in epoch seconds; flags as 0/1 or booleans). Stored, then the - newest sample is forwarded to ABRP if it is under 5 minutes old. -- `PUT /v1/consumption/daily` — one delta chunk of a day's consumption. -- `PUT /v1/charging-sessions/:sessionId` - -Read: - -- `GET /v1/consumption/history/:installationId` — ingest token; the car reads its own totals back. -- `GET /v1/history/:installationId/consumption`, `.../charging` — session cookie or - `Authorization: Bearer `. - -`GET /v1/health` is unauthenticated and returns `{"status":"ok"}`. - -## Storage - -One bucket, `installationId` tag on every point: - -| Measurement | Timestamp | Tags | Fields | -|---|---|---|---| -| `telemetry` | sample `utc` | — | the ABRP fields that were sent | -| `consumption_chunk` | `recordedAt` | `date` | km, kwh, hours, socDrop, sohPercent | -| `charging_session` | `startedAt` | `sessionId` | endedAtMs, duration, energy, cost, start/end SOC | -| `charging_curve` | point time | `sessionId` | powerKw, socPercent | - -A point with the same measurement, tags and timestamp replaces the earlier one, so the car's -retries are idempotent: a cached chunk is re-sent with its original `recordedAt`. Days and the -lifetime total are summed from the chunks when read. All numbers are written as floats, because -InfluxDB fixes a field's type on first write. - -## Local development - -```sh -nix develop # from the repository root: Node 22 and InfluxDB 2 -cp .env.example .env.local # Next loads .env.local automatically -npm install -npm run dev -npm test # validation, aggregation and forwarding rules; no database -``` - -## Container image - -```sh -podman build -t git.stinnesbeck.com/nils/mg4-server: . -podman push git.stinnesbeck.com/nils/mg4-server: -``` - -The build runs the unit tests. The image runs as UID 10001 and listens on port 3000. diff --git a/web/next.config.mjs b/web/next.config.mjs deleted file mode 100644 index 7bd72cb..0000000 --- a/web/next.config.mjs +++ /dev/null @@ -1,8 +0,0 @@ -/** @type {import('next').NextConfig} */ -const nextConfig = { - // A self-contained server in .next/standalone, for the container image. - output: "standalone", - // Every page reads live telemetry; a cached shell would show yesterday's drive. - experimental: { serverActions: { bodySizeLimit: "1mb" } }, -}; -export default nextConfig; diff --git a/web/package-lock.json b/web/package-lock.json deleted file mode 100644 index 88db69d..0000000 --- a/web/package-lock.json +++ /dev/null @@ -1,1002 +0,0 @@ -{ - "name": "nilsmg4-web", - "version": "1.1.2", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "nilsmg4-web", - "version": "1.1.2", - "dependencies": { - "@influxdata/influxdb-client": "^1.35.0", - "next": "^15.5.4", - "react": "^19.1.0", - "react-dom": "^19.1.0" - }, - "devDependencies": { - "@types/node": "^22.15.3", - "@types/react": "^19.1.2", - "typescript": "^5.8.3" - }, - "engines": { - "node": ">=22" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.11.3", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", - "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@img/colour": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", - "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@img/sharp-darwin-arm64": { - "version": "0.35.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz", - "integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.3.4" - } - }, - "node_modules/@img/sharp-darwin-x64": { - "version": "0.35.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz", - "integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.3.4" - } - }, - "node_modules/@img/sharp-freebsd-wasm32": { - "version": "0.35.5", - "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz", - "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==", - "license": "Apache-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "dependencies": { - "@img/sharp-wasm32": "0.35.5" - }, - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz", - "integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz", - "integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz", - "integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==", - "cpu": [ - "arm" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz", - "integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz", - "integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==", - "cpu": [ - "ppc64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz", - "integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==", - "cpu": [ - "riscv64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz", - "integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==", - "cpu": [ - "s390x" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz", - "integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz", - "integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz", - "integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-linux-arm": { - "version": "0.35.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz", - "integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==", - "cpu": [ - "arm" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.3.4" - } - }, - "node_modules/@img/sharp-linux-arm64": { - "version": "0.35.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz", - "integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.3.4" - } - }, - "node_modules/@img/sharp-linux-ppc64": { - "version": "0.35.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz", - "integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==", - "cpu": [ - "ppc64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.3.4" - } - }, - "node_modules/@img/sharp-linux-riscv64": { - "version": "0.35.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz", - "integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==", - "cpu": [ - "riscv64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.3.4" - } - }, - "node_modules/@img/sharp-linux-s390x": { - "version": "0.35.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz", - "integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==", - "cpu": [ - "s390x" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.3.4" - } - }, - "node_modules/@img/sharp-linux-x64": { - "version": "0.35.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz", - "integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.3.4" - } - }, - "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.35.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz", - "integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" - } - }, - "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.35.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz", - "integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.3.4" - } - }, - "node_modules/@img/sharp-wasm32": { - "version": "0.35.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz", - "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==", - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", - "optional": true, - "dependencies": { - "@emnapi/runtime": "^1.11.3" - }, - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-webcontainers-wasm32": { - "version": "0.35.5", - "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz", - "integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==", - "cpu": [ - "wasm32" - ], - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@img/sharp-wasm32": "0.35.5" - }, - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-arm64": { - "version": "0.35.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz", - "integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-ia32": { - "version": "0.35.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz", - "integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==", - "cpu": [ - "ia32" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-x64": { - "version": "0.35.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz", - "integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@influxdata/influxdb-client": { - "version": "1.35.0", - "resolved": "https://registry.npmjs.org/@influxdata/influxdb-client/-/influxdb-client-1.35.0.tgz", - "integrity": "sha512-woWMi8PDpPQpvTsRaUw4Ig+nOGS/CWwAwS66Fa1Vr/EkW+NEwxI8YfPBsdBMn33jK2Y86/qMiiuX/ROHIkJLTw==", - "license": "MIT" - }, - "node_modules/@next/env": { - "version": "15.5.27", - "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.27.tgz", - "integrity": "sha512-WC5hvVqiuFOcRyz6gKPeJkiVvSMf3scNeAV49CdWtVqxmUlzh8UKR6YHt9yiNkh8LngtzqSlVz07/Ma3ZAzchA==", - "license": "MIT" - }, - "node_modules/@next/swc-darwin-arm64": { - "version": "15.5.27", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.27.tgz", - "integrity": "sha512-fBA9QurmK4+uj4wNhmRTuO5zMVRaBHCvp6H93zTG/5i6Yi6TewbvbesXB8YQNj8T99Cwrow87hHiFV6wL/hyMA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-darwin-x64": { - "version": "15.5.27", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.27.tgz", - "integrity": "sha512-FGZwJwry8qZNHxD/nfwrCwCHHrqx6epqItkkxbYcBQEQ2uw5S43bY83fQB5zbAsZE7TELMjoYkbhsQ4oKM7wwg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-gnu": { - "version": "15.5.27", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.27.tgz", - "integrity": "sha512-GQulAaJYNF1RYuatlsw9/uzbKz4if2V7Ic9nVO+P0ljYPm4yBRTFIl6g5YkKTw40lMLbpy9edLoH44g3jSHMYA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-musl": { - "version": "15.5.27", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.27.tgz", - "integrity": "sha512-URFFsUmu8Pi0/1qdnUI7tx7keZw1Mt5zHfrIX1RfDG9Nih8DYxK54WZsDJUX2ZTlii1jgotL7qcbwKnSAES6xQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-x64-gnu": { - "version": "15.5.27", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.27.tgz", - "integrity": "sha512-aJp74z7uvU5jQhyH7lyk+4qSRcTdKbKT9NQE3jiO9kWYkoHZjKi0zzHDbCoZ+v2sJDEiJ/z58R0UlWMGWNF3HA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-x64-musl": { - "version": "15.5.27", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.27.tgz", - "integrity": "sha512-ILl2bQ8MbpvyMpY8AXljb3BXs0MJSC8HXZ6QV5AWpVS7ZELqNPtrHvhUk+SNarvjvICXX8bCS287TCi+gUFVYg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-arm64-msvc": { - "version": "15.5.27", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.27.tgz", - "integrity": "sha512-1MB9Rh5y8TOPN2x7naNTJNkCZQav9c70AI7EBYXxsEewwOffC4DjjwBwKf20NBvcA+u+5jDv7nVmHxunlP2d7Q==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-x64-msvc": { - "version": "15.5.27", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.27.tgz", - "integrity": "sha512-XtYgpGOswgnyBWXzOEG6q9RhvnSw0CEZ0MbWqCsl5PISgt9EzvJvNW5mU5n1PsphX/lnamzxjgQXH2GUJL8nqg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@swc/helpers": { - "version": "0.5.15", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", - "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.8.0" - } - }, - "node_modules/@types/node": { - "version": "22.20.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz", - "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/@types/react": { - "version": "19.3.0", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", - "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", - "dev": true, - "license": "MIT", - "dependencies": { - "csstype": "^3.2.2" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001815", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001815.tgz", - "integrity": "sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/client-only": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", - "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", - "license": "MIT" - }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", - "optional": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/nanoid": { - "version": "3.3.20", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", - "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/next": { - "version": "15.5.27", - "resolved": "https://registry.npmjs.org/next/-/next-15.5.27.tgz", - "integrity": "sha512-F82CrlPZ8GRxBH9RIIoR3qU1t5RT3dv1K8moe8SjlDEquiFipvEI13R1Om90TaUOEwShkWCMlg8+gvTNzfMhvg==", - "license": "MIT", - "dependencies": { - "@next/env": "15.5.27", - "@swc/helpers": "0.5.15", - "caniuse-lite": "^1.0.30001579", - "postcss": "8.4.31", - "styled-jsx": "5.1.6" - }, - "bin": { - "next": "dist/bin/next" - }, - "engines": { - "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" - }, - "optionalDependencies": { - "@next/swc-darwin-arm64": "15.5.27", - "@next/swc-darwin-x64": "15.5.27", - "@next/swc-linux-arm64-gnu": "15.5.27", - "@next/swc-linux-arm64-musl": "15.5.27", - "@next/swc-linux-x64-gnu": "15.5.27", - "@next/swc-linux-x64-musl": "15.5.27", - "@next/swc-win32-arm64-msvc": "15.5.27", - "@next/swc-win32-x64-msvc": "15.5.27", - "sharp": "^0.34.3 || ^0.35.4" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.1.0", - "@playwright/test": "^1.51.1", - "babel-plugin-react-compiler": "*", - "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", - "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", - "sass": "^1.3.0" - }, - "peerDependenciesMeta": { - "@opentelemetry/api": { - "optional": true - }, - "@playwright/test": { - "optional": true - }, - "babel-plugin-react-compiler": { - "optional": true - }, - "sass": { - "optional": true - } - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "license": "ISC" - }, - "node_modules/postcss": { - "version": "8.4.31", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", - "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.6", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/react": { - "version": "19.3.0", - "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", - "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "19.3.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", - "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", - "license": "MIT", - "dependencies": { - "scheduler": "^0.28.0" - }, - "peerDependencies": { - "react": "^19.3.0" - } - }, - "node_modules/scheduler": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", - "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", - "license": "MIT" - }, - "node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "license": "ISC", - "optional": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/sharp": { - "version": "0.35.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz", - "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@img/colour": "^1.1.0", - "detect-libc": "^2.1.2", - "semver": "^7.8.5" - }, - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.35.5", - "@img/sharp-darwin-x64": "0.35.5", - "@img/sharp-freebsd-wasm32": "0.35.5", - "@img/sharp-libvips-darwin-arm64": "1.3.4", - "@img/sharp-libvips-darwin-x64": "1.3.4", - "@img/sharp-libvips-linux-arm": "1.3.4", - "@img/sharp-libvips-linux-arm64": "1.3.4", - "@img/sharp-libvips-linux-ppc64": "1.3.4", - "@img/sharp-libvips-linux-riscv64": "1.3.4", - "@img/sharp-libvips-linux-s390x": "1.3.4", - "@img/sharp-libvips-linux-x64": "1.3.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", - "@img/sharp-libvips-linuxmusl-x64": "1.3.4", - "@img/sharp-linux-arm": "0.35.5", - "@img/sharp-linux-arm64": "0.35.5", - "@img/sharp-linux-ppc64": "0.35.5", - "@img/sharp-linux-riscv64": "0.35.5", - "@img/sharp-linux-s390x": "0.35.5", - "@img/sharp-linux-x64": "0.35.5", - "@img/sharp-linuxmusl-arm64": "0.35.5", - "@img/sharp-linuxmusl-x64": "0.35.5", - "@img/sharp-webcontainers-wasm32": "0.35.5", - "@img/sharp-win32-arm64": "0.35.5", - "@img/sharp-win32-ia32": "0.35.5", - "@img/sharp-win32-x64": "0.35.5" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/source-map-js": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", - "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/styled-jsx": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", - "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", - "license": "MIT", - "dependencies": { - "client-only": "0.0.1" - }, - "engines": { - "node": ">= 12.0.0" - }, - "peerDependencies": { - "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" - }, - "peerDependenciesMeta": { - "@babel/core": { - "optional": true - }, - "babel-plugin-macros": { - "optional": true - } - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - } - } -} diff --git a/web/package.json b/web/package.json deleted file mode 100644 index c1a59a3..0000000 --- a/web/package.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "name": "nilsmg4-web", - "version": "1.1.2", - "private": true, - "scripts": { - "dev": "next dev", - "build": "next build", - "start": "next start", - "lint": "next lint", - "test": "node --test --experimental-strip-types test/*.test.ts" - }, - "engines": { - "node": ">=22" - }, - "dependencies": { - "@influxdata/influxdb-client": "^1.35.0", - "next": "^15.5.4", - "react": "^19.1.0", - "react-dom": "^19.1.0" - }, - "devDependencies": { - "@types/node": "^22.15.3", - "@types/react": "^19.1.2", - "typescript": "^5.8.3" - }, - "type": "module" -} diff --git a/web/src/app/api/session/route.ts b/web/src/app/api/session/route.ts deleted file mode 100644 index 9cbbcda..0000000 --- a/web/src/app/api/session/route.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { NextResponse } from "next/server"; -import { SESSION_COOKIE, checkDashboardPassword, issueSessionValue, sessionCookieOptions } from "@/lib/session"; - -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; - -/** - * Deliberately slow to fail. The password is the only thing between the open internet and - * the history, and a quarter second is unnoticeable once but ruinous for anyone working - * through a list. - */ -export async function POST(request: Request) { - const form = await request.formData(); - const password = String(form.get("password") ?? ""); - if (!checkDashboardPassword(password)) { - await new Promise((resolve) => setTimeout(resolve, 250)); - return NextResponse.redirect(new URL("/login?error=1", request.url), { status: 303 }); - } - const response = NextResponse.redirect(new URL("/", request.url), { status: 303 }); - // Set on the response rather than through the cookies() store: a freshly constructed - // redirect does not reliably carry what that store recorded. - response.cookies.set(SESSION_COOKIE, issueSessionValue(), sessionCookieOptions); - return response; -} diff --git a/web/src/app/charging/page.tsx b/web/src/app/charging/page.tsx deleted file mode 100644 index 6cf9e42..0000000 --- a/web/src/app/charging/page.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import { requireSession } from "@/lib/auth"; -import { loadOverview, sumCharging } from "@/lib/data"; -import { dateTime, duration, money, num, precise } from "@/lib/format"; -import { ChargeCurveChart } from "@/components/charts"; -import { Row, Shell, Tile } from "@/components/shell"; - -export const dynamic = "force-dynamic"; - -export default async function ChargingPage() { - await requireSession(); - const { installationId, sessions } = await loadOverview(1, 200); - - if (!installationId || sessions.length === 0) { - return ( - -
    -

    - No charging sessions yet. Only DC charges above 10 kW are metered; AC charging is not recorded. -

    -
    -
    - ); - } - - const total = sumCharging(sessions); - - return ( - -
    - - - - 0 ? money(total.cost / total.gridEnergyKwh) : "—"} - note="per kWh" - tone="teal" - /> - -
    - -
    -

    Sessions

    -
      - {sessions.map((session) => { - const gained = - session.startSocPercent !== undefined && session.endSocPercent !== undefined - ? session.endSocPercent - session.startSocPercent - : null; - const peak = session.curve.reduce((max, point) => Math.max(max, point.powerKw), 0); - const average = - session.durationSeconds > 0 ? (session.energyKwh * 3600) / session.durationSeconds : 0; - return ( -
    • -
      - -
      -
      {dateTime(session.startedAt)}
      -
      - {session.isDemo && "Demo · "} - {duration(session.durationSeconds)} - {gained !== null && ` · ${num(session.startSocPercent as number)}% → ${num(session.endSocPercent as number)}%`} -
      -
      -
      - {precise(session.energyKwh)} - kWh -
      {money(session.totalCost)}
      -
      -
      -
      - ({ - timestamp: new Date(point.timestamp).toISOString(), - powerKw: point.powerKw, - socPercent: point.socPercent, - }))} - /> -
      - - - - - - - 0 ? `${num(peak)} kW` : "—"} /> - 0 ? `${num(average)} kW` : "—"} /> - - -
      -
      -
      -
    • - ); - })} -
    -
    -
    - ); -} diff --git a/web/src/app/consumption/page.tsx b/web/src/app/consumption/page.tsx deleted file mode 100644 index d87b07a..0000000 --- a/web/src/app/consumption/page.tsx +++ /dev/null @@ -1,178 +0,0 @@ -import Link from "next/link"; -import { requireSession } from "@/lib/auth"; -import { loadOverview, sumDays } from "@/lib/data"; -import { duration, efficiency, isoDate, num, precise } from "@/lib/format"; -import { DailyEnergyChart, EfficiencyChart } from "@/components/charts"; -import { Row, Shell, Tile } from "@/components/shell"; - -export const dynamic = "force-dynamic"; - -export default async function ConsumptionPage({ - searchParams, -}: { - searchParams: Promise<{ date?: string }>; -}) { - await requireSession(); - const { date: selectedDate } = await searchParams; - const { installationId, days } = await loadOverview(90, 1); - - if (!installationId || days.length === 0) { - return ( - -
    -

    No daily consumption records yet. The car uploads them as it drives.

    -
    -
    - ); - } - - const chartDays = days.map((day) => ({ date: day.date, kwh: day.day.kwh, km: day.day.km })); - const total = sumDays(days); - // Lifetime is derived: the sum of every day's totals, same as the backend computes. - const lifetime = { km: total.km, kwh: total.kwh, hours: total.hours, socDrop: 0 }; - // Newest first for reading; the charts above keep chronological order. - const table = [...days].reverse(); - - // Find the selected day's record (if any). table is newest-first; a Map would be overkill - // for ≤90 entries. - const selected = selectedDate ? table.find((d) => d.date === selectedDate) : null; - const hasFilter = !!selectedDate; - const validFilter = hasFilter && !!selected; - - // Date input bounds: oldest record to today. - const minDate = days[0].date; // days is chronological (oldest first after loadOverview reverse) - const maxDate = new Date().toISOString().slice(0, 10); - - return ( - - {/* Date filter */} -
    -
    - -
    - - - {hasFilter && ( - Clear - )} -
    -
    -
    - - {/* Selected day detail — shown above the period tiles when a filter is active */} - {hasFilter && ( -
    -

    {isoDate(selectedDate!)}

    - {validFilter && selected ? ( - <> -
    - - - - 0 ? num(selected.day.km / selected.day.hours) : "—"} - unit={selected.day.hours > 0 ? "km/h" : undefined} - /> -
    -
    - - - -
    - - ) : ( -

    No consumption recorded for this date.

    - )} -
    - )} - -
    - - - - 0 ? num(total.km / total.hours) : "—"} - unit={total.hours > 0 ? "km/h" : undefined} - /> -
    - -
    -

    Daily energy

    - -
    - -
    -

    Efficiency trend

    - -
    - -
    -

    Lifetime

    -
    - - - - -
    -
    - -
    -

    Day by day

    -
      - {table.map((day) => { - const isSelected = hasFilter && day.date === selectedDate; - return ( -
    • -
      - -
      -
      - - {isoDate(day.date)} - -
      -
      - {num(day.day.km)} km · {efficiency(day.day.kwh, day.day.km)} -
      -
      -
      - {precise(day.day.kwh)} - kWh -
      -
      -
      -
      - - 0 ? `${num(day.day.km / day.day.hours)} km/h` : "—"} - /> - - -
      -
      -
      -
    • - ); - })} -
    -
    -
    - ); -} diff --git a/web/src/app/globals.css b/web/src/app/globals.css deleted file mode 100644 index d10dbbc..0000000 --- a/web/src/app/globals.css +++ /dev/null @@ -1,430 +0,0 @@ -/* - * The car's palette, from app/src/main/res/values/colors.xml. - * - * Permanent OLED black with charcoal cards and muted gold/cyan accents — the same visual - * direction AGENTS.md pins for the head unit, so the phone and the dash read as one app. - * No light mode: this is looked at in a dark car as often as anywhere else. - */ -:root { - --bg: #000000; - --surface: #121212; - --raised: #1b1d1e; - --outline: #363b3d; - --text: #f4f3ef; - --muted: #bdc4c6; - --accent: #d8c39a; - --on-accent: #17140e; - --cyan: #a0e2e0; - --cyan-deep: #103436; - --gold-surface: #28241b; - --ok: #a4dcc1; - --warn: #e9ca96; - --error: #ffb4ab; - - --radius: 18px; - --gap: 12px; -} - -* { - box-sizing: border-box; -} - -html, -body { - margin: 0; - padding: 0; - background: var(--bg); - color: var(--text); - font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; - -webkit-font-smoothing: antialiased; - font-variant-numeric: tabular-nums; -} - -a { - color: inherit; - text-decoration: none; -} - -/* The phone frame. Wide windows get the same column rather than a stretched dashboard. */ -.shell { - max-width: 480px; - margin: 0 auto; - min-height: 100dvh; - padding: 20px 16px calc(84px + env(safe-area-inset-bottom)); - display: flex; - flex-direction: column; - gap: var(--gap); -} - -.header { - display: flex; - align-items: baseline; - justify-content: space-between; - margin-bottom: 4px; -} - -.title { - font-size: 26px; - font-weight: 650; - letter-spacing: -0.4px; - margin: 0; -} - -.subtitle { - color: var(--muted); - font-size: 13px; - margin: 2px 0 0; -} - -.card { - background: var(--surface); - border: 1px solid var(--outline); - border-radius: var(--radius); - padding: 16px; -} - -.card-title { - font-size: 12px; - letter-spacing: 1.1px; - text-transform: uppercase; - color: var(--muted); - margin: 0 0 12px; - font-weight: 600; -} - -/* Headline numbers: two per row, gold for energy/cost, cyan for distance/efficiency. */ -.tiles { - display: grid; - grid-template-columns: 1fr 1fr; - gap: var(--gap); -} - -.tile { - background: var(--surface); - border: 1px solid var(--outline); - border-radius: var(--radius); - padding: 14px 16px; -} - -.tile.gold { - background: linear-gradient(160deg, var(--gold-surface), var(--surface) 70%); -} - -.tile.teal { - background: linear-gradient(160deg, var(--cyan-deep), var(--surface) 70%); -} - -.tile-label { - font-size: 11px; - letter-spacing: 0.8px; - text-transform: uppercase; - color: var(--muted); - margin: 0; -} - -.tile-value { - font-size: 24px; - font-weight: 650; - margin: 6px 0 0; - letter-spacing: -0.5px; -} - -.tile-unit { - font-size: 13px; - font-weight: 500; - color: var(--muted); - margin-left: 3px; -} - -.tile-note { - font-size: 11px; - color: var(--muted); - margin: 4px 0 0; -} - -/* Key/value rows inside a card. */ -.rows { - display: flex; - flex-direction: column; - gap: 10px; -} - -.row { - display: flex; - justify-content: space-between; - align-items: baseline; - gap: 12px; - font-size: 14px; -} - -.row + .row { - border-top: 1px solid var(--outline); - padding-top: 10px; -} - -.row-key { - color: var(--muted); -} - -.row-value { - font-weight: 600; -} - -.list { - display: flex; - flex-direction: column; - gap: 10px; - list-style: none; - margin: 0; - padding: 0; -} - -.session { - background: var(--surface); - border: 1px solid var(--outline); - border-radius: var(--radius); - padding: 14px 16px; -} - -.session summary { - cursor: pointer; - list-style: none; - display: flex; - justify-content: space-between; - align-items: center; - gap: 10px; -} - -.session summary::-webkit-details-marker { - display: none; -} - -.session-when { - font-size: 14px; - font-weight: 600; -} - -.session-meta { - font-size: 12px; - color: var(--muted); - margin-top: 3px; -} - -.session-energy { - text-align: right; - font-size: 18px; - font-weight: 650; - color: var(--accent); - white-space: nowrap; -} - -.session-cost { - font-size: 12px; - color: var(--muted); - font-weight: 500; -} - -.session-body { - margin-top: 14px; - padding-top: 14px; - border-top: 1px solid var(--outline); -} - -.chart { - width: 100%; - height: auto; - display: block; - overflow: visible; -} - -.legend { - display: flex; - gap: 14px; - font-size: 11px; - color: var(--muted); - margin-top: 8px; -} - -.swatch { - display: inline-block; - width: 9px; - height: 9px; - border-radius: 2px; - margin-right: 5px; - vertical-align: baseline; -} - -.empty { - color: var(--muted); - font-size: 14px; - text-align: center; - padding: 28px 12px; -} - -/* Bottom navigation — the car app falls back to the same pattern below 1000dp. */ -.nav { - position: fixed; - left: 0; - right: 0; - bottom: 0; - background: rgba(10, 10, 10, 0.92); - backdrop-filter: blur(14px); - border-top: 1px solid var(--outline); - padding: 8px 0 calc(8px + env(safe-area-inset-bottom)); -} - -.nav-inner { - max-width: 480px; - margin: 0 auto; - display: grid; - grid-template-columns: repeat(3, 1fr); -} - -.nav a { - text-align: center; - padding: 8px 4px; - font-size: 11px; - color: var(--muted); - font-weight: 600; - letter-spacing: 0.3px; -} - -.nav a[aria-current="page"] { - color: var(--accent); -} - -.nav-icon { - display: block; - font-size: 17px; - margin-bottom: 2px; -} - -/* Login */ -.login { - min-height: 100dvh; - display: flex; - align-items: center; - justify-content: center; - padding: 24px; -} - -.login-card { - width: 100%; - max-width: 360px; - background: var(--surface); - border: 1px solid var(--outline); - border-radius: var(--radius); - padding: 28px 24px; -} - -.field { - width: 100%; - background: var(--raised); - border: 1px solid var(--outline); - border-radius: 12px; - color: var(--text); - padding: 13px 14px; - font-size: 16px; - margin-top: 16px; -} - -.field:focus { - outline: none; - border-color: var(--accent); -} - -.button { - width: 100%; - margin-top: 14px; - background: var(--accent); - color: var(--on-accent); - border: none; - border-radius: 12px; - padding: 13px; - font-size: 15px; - font-weight: 650; - cursor: pointer; -} - -.error { - color: var(--error); - font-size: 13px; - margin: 14px 0 0; -} - -.signout { - font: inherit; - font-size: 12px; - color: var(--muted); - background: none; - border: 1px solid var(--outline); - border-radius: 999px; - padding: 5px 12px; - cursor: pointer; -} - -/* ---------- Consumption date filter ---------- */ - -.filter-form { - display: flex; - flex-direction: column; - gap: 10px; -} - -.filter-label { - font-size: 12px; - text-transform: uppercase; - letter-spacing: 0.5px; - color: var(--muted); -} - -.filter-row { - display: flex; - align-items: center; - gap: 10px; - flex-wrap: wrap; -} - -.date-input { - width: auto; - min-width: 0; - flex: 1 1 150px; - margin-top: 0; - color-scheme: dark; -} - -.filter-button { - width: auto; - margin-top: 0; - padding: 12px 18px; - flex: 0 0 auto; -} - -.filter-clear { - font: inherit; - font-size: 13px; - color: var(--muted); - text-decoration: none; - border: 1px solid var(--outline); - border-radius: 12px; - padding: 11px 14px; - flex: 0 0 auto; -} - -.filter-clear:active { - border-color: var(--accent); -} - -/* Highlighted selected day in the day-by-day list */ -.session.selected { - border-color: var(--accent); -} - -.day-link { - color: var(--text); - text-decoration: none; -} - -.day-link:active { - color: var(--accent); -} diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx deleted file mode 100644 index d4242ec..0000000 --- a/web/src/app/layout.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import type { Metadata, Viewport } from "next"; -import "./globals.css"; - -export const metadata: Metadata = { - title: "Nils' MG4", - description: "MG4 consumption and charging history", -}; - -export const viewport: Viewport = { - themeColor: "#000000", - width: "device-width", - initialScale: 1, - // The dashboard is pinned to a phone-width column; zooming it is still the reader's call. - maximumScale: 5, -}; - -export default function RootLayout({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ); -} diff --git a/web/src/app/login/page.tsx b/web/src/app/login/page.tsx deleted file mode 100644 index 88cabd7..0000000 --- a/web/src/app/login/page.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { redirect } from "next/navigation"; -import { hasSession } from "@/lib/auth"; - -export const dynamic = "force-dynamic"; - -export default async function LoginPage({ - searchParams, -}: { - searchParams: Promise<{ error?: string }>; -}) { - if (await hasSession()) redirect("/"); - const { error } = await searchParams; - return ( -
    -
    -

    Nils' MG4

    -

    Consumption and charging history

    - {/* - A hidden username so password managers will offer to save this. - Most of them ignore a lone password field — they key a saved entry on - (origin, username), and with nothing to key on they stay quiet. There is only - ever one account here, so the value is fixed and the server never reads it. - */} - - - - {error &&

    Wrong password.

    } -
    -
    - ); -} diff --git a/web/src/app/logout/route.ts b/web/src/app/logout/route.ts deleted file mode 100644 index 8b016f6..0000000 --- a/web/src/app/logout/route.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { NextResponse } from "next/server"; -import { SESSION_COOKIE } from "@/lib/session"; - -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; - -/** - * POST only, deliberately. - * - * This used to answer GET behind a ``, so Next's viewport prefetch called it on - * every render and cleared the cookie before the reader had clicked anything — which - * presented as the session expiring on every navigation. Anything that changes state has - * to be unreachable by a prefetch, a crawler or a preloading browser, and that means not - * answering GET. - */ -export async function POST(request: Request) { - const response = NextResponse.redirect(new URL("/login", request.url), { status: 303 }); - // Cleared on the response itself rather than through the cookies() store: a freshly - // constructed redirect does not reliably carry what that store recorded. - response.cookies.set(SESSION_COOKIE, "", { path: "/", maxAge: 0 }); - return response; -} diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx deleted file mode 100644 index 6b1dc26..0000000 --- a/web/src/app/page.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { requireSession } from "@/lib/auth"; -import { loadOverview, sumCharging, sumDays } from "@/lib/data"; -import { dateTime, duration, efficiency, money, num, precise } from "@/lib/format"; -import { DailyEnergyChart } from "@/components/charts"; -import { Row, Shell, Tile } from "@/components/shell"; - -export const dynamic = "force-dynamic"; - -export default async function OverviewPage() { - await requireSession(); - const { installationId, installationCount, days, sessions } = await loadOverview(30, 200); - - if (!installationId) { - return ( - -
    -

    - No data yet. It appears here once the car sends its first daily record. -

    -
    -
    - ); - } - - const charge = sumCharging(sessions); - const recent = sumDays(days); - const lifetime = days.length > 0 ? days[days.length - 1].lifetime : null; - const soh = [...days].reverse().find((day) => day.sohPercent !== undefined)?.sohPercent; - const lastSession = sessions[0]; - - return ( - 1 ? `Most recent of ${installationCount} cars` : "MG4"} - > -
    - - 0 ? `${money(charge.cost / charge.gridEnergyKwh)}/kWh avg.` : undefined} - tone="gold" - /> - - -
    - -
    -

    Last 30 days

    - ({ date: day.date, kwh: day.day.kwh, km: day.day.km }))} /> -
    - - {lastSession && ( -
    -

    Last charge

    -
    - - - - - - -
    -
    - )} - -
    -

    Battery and totals

    -
    - - - 0 ? `${num(charge.peakKw)} kW` : "—"} /> - 0 ? `${num(charge.socGained)}%` : "—"} /> - -
    -
    -
    - ); -} diff --git a/web/src/app/v1/charging-sessions/[sessionId]/route.ts b/web/src/app/v1/charging-sessions/[sessionId]/route.ts deleted file mode 100644 index 55ee73a..0000000 --- a/web/src/app/v1/charging-sessions/[sessionId]/route.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { NextResponse } from "next/server"; -import { handle, parseJson, requireIngest } from "@/lib/api"; -import { writeChargingSession } from "@/lib/store"; -import { validateChargingSession } from "@/lib/validate"; - -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; - -export async function PUT(request: Request, context: { params: Promise<{ sessionId: string }> }) { - const denied = requireIngest(request); - if (denied) return denied; - return handle(async () => { - const { sessionId } = await context.params; - await writeChargingSession(validateChargingSession(await parseJson(request), sessionId)); - return new NextResponse(null, { status: 204 }); - }); -} diff --git a/web/src/app/v1/consumption/daily/route.ts b/web/src/app/v1/consumption/daily/route.ts deleted file mode 100644 index 691fab5..0000000 --- a/web/src/app/v1/consumption/daily/route.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { NextResponse } from "next/server"; -import { handle, parseJson, requireIngest } from "@/lib/api"; -import { writeConsumptionChunk } from "@/lib/store"; -import { validateConsumption } from "@/lib/validate"; - -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; - -/** - * One delta chunk (not a cumulative total) of a day's consumption. Stored as its own point - * at its recordedAt; reads add a day's chunks up. A chunk the car re-sends from its offline - * cache carries the same recordedAt and replaces itself, so a retry is never counted twice. - */ -export async function PUT(request: Request) { - const denied = requireIngest(request); - if (denied) return denied; - return handle(async () => { - await writeConsumptionChunk(validateConsumption(await parseJson(request))); - return new NextResponse(null, { status: 204 }); - }); -} diff --git a/web/src/app/v1/consumption/history/[installationId]/route.ts b/web/src/app/v1/consumption/history/[installationId]/route.ts deleted file mode 100644 index ffd2bc3..0000000 --- a/web/src/app/v1/consumption/history/[installationId]/route.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { NextResponse } from "next/server"; -import { handle, requireIngest } from "@/lib/api"; -import { readDays } from "@/lib/store"; -import { validateUuid } from "@/lib/validate"; - -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; - -/** - * Ingest-token-authenticated history read for the head-unit app. - * - * The dashboard history endpoints under /v1/history use the dashboard password (a - * browser-only credential). The head unit only holds the ingest token, which was - * write-only by design — but the car's consumption UI now reads back its own uploaded - * totals so an APK wipe does not blank the display. The installationId is a fixed UUID - * baked into the APK, so an extracted token plus a guessed installationId is the only - * exposure, and the data revealed is the owner's own consumption history. - */ -export async function GET(request: Request, context: { params: Promise<{ installationId: string }> }) { - const denied = requireIngest(request); - if (denied) return denied; - return handle(async () => { - const { installationId } = await context.params; - const days = await readDays(validateUuid(installationId, "installationId")); - // Lifetime = sum of every day's totals, which is the newest day's running total. - const lifetime = days[0]?.lifetime ?? { km: 0, kwh: 0, hours: 0, socDrop: 0 }; - const records = days.slice(0, 400).map(({ lifetime: _lifetime, ...day }) => day); - return NextResponse.json({ records, lifetime }); - }); -} diff --git a/web/src/app/v1/health/route.ts b/web/src/app/v1/health/route.ts deleted file mode 100644 index 4efb85e..0000000 --- a/web/src/app/v1/health/route.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { NextResponse } from "next/server"; - -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; - -/** Unauthenticated on purpose: it reveals nothing and the car has no other probe. */ -export function GET() { - return NextResponse.json({ status: "ok" }); -} diff --git a/web/src/app/v1/history/[installationId]/charging/route.ts b/web/src/app/v1/history/[installationId]/charging/route.ts deleted file mode 100644 index 434bf4d..0000000 --- a/web/src/app/v1/history/[installationId]/charging/route.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { NextResponse } from "next/server"; -import { handle, requireReader } from "@/lib/api"; -import { readChargingSessions } from "@/lib/store"; -import { validateUuid } from "@/lib/validate"; - -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; - -export async function GET(request: Request, context: { params: Promise<{ installationId: string }> }) { - const denied = await requireReader(request); - if (denied) return denied; - return handle(async () => { - const { installationId } = await context.params; - const records = await readChargingSessions(validateUuid(installationId, "installationId"), 200); - return NextResponse.json({ records }); - }); -} diff --git a/web/src/app/v1/history/[installationId]/consumption/route.ts b/web/src/app/v1/history/[installationId]/consumption/route.ts deleted file mode 100644 index 158b0a8..0000000 --- a/web/src/app/v1/history/[installationId]/consumption/route.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { NextResponse } from "next/server"; -import { handle, requireReader } from "@/lib/api"; -import { readDays } from "@/lib/store"; -import { validateUuid } from "@/lib/validate"; - -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; - -export async function GET(request: Request, context: { params: Promise<{ installationId: string }> }) { - const denied = await requireReader(request); - if (denied) return denied; - return handle(async () => { - const { installationId } = await context.params; - const records = (await readDays(validateUuid(installationId, "installationId"))).slice(0, 400); - return NextResponse.json({ records }); - }); -} diff --git a/web/src/app/v1/telemetry/route.ts b/web/src/app/v1/telemetry/route.ts deleted file mode 100644 index ffbf8fb..0000000 --- a/web/src/app/v1/telemetry/route.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { NextResponse } from "next/server"; -import { forwardToAbrp } from "@/lib/abrp"; -import { handle, parseJson, requireIngest } from "@/lib/api"; -import { writeTelemetry } from "@/lib/store"; -import { validateTelemetryBatch } from "@/lib/validate"; - -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; - -/** - * A batch of live samples from the car. Stored first; only once the write succeeded is the - * newest sample handed to ABRP, so the car's retry of a failed request cannot leave ABRP - * with data the database never got. - */ -export async function POST(request: Request) { - const denied = requireIngest(request); - if (denied) return denied; - return handle(async () => { - const batch = validateTelemetryBatch(await parseJson(request)); - await writeTelemetry(batch.installationId, batch.samples); - forwardToAbrp(batch.samples); - return new NextResponse(null, { status: 204 }); - }); -} diff --git a/web/src/components/charts.tsx b/web/src/components/charts.tsx deleted file mode 100644 index 351f5f6..0000000 --- a/web/src/components/charts.tsx +++ /dev/null @@ -1,267 +0,0 @@ -/* - * Hand-rolled SVG charts. - * - * A charting library would be the larger part of the bundle for three small plots, and - * these are server-rendered: no hydration, no client JavaScript, and they draw correctly - * before anything loads. Each one states its own units in the legend rather than relying - * on a tooltip, because a tooltip needs a pointer and this is read on a phone. - */ - -const GOLD = "#d8c39a"; -const CYAN = "#a0e2e0"; -const GRID = "#363b3d"; -const MUTED = "#bdc4c6"; - -interface Plot { - width: number; - height: number; - padTop: number; - padRight: number; - padBottom: number; - padLeft: number; -} - -const PLOT: Plot = { width: 320, height: 150, padTop: 10, padRight: 8, padBottom: 20, padLeft: 34 }; - -function innerWidth(plot: Plot) { - return plot.width - plot.padLeft - plot.padRight; -} -function innerHeight(plot: Plot) { - return plot.height - plot.padTop - plot.padBottom; -} - -/** A rounded axis maximum, so the gridline labels are readable numbers. */ -function niceMax(value: number): number { - if (!(value > 0)) return 1; - const magnitude = 10 ** Math.floor(Math.log10(value)); - const normalised = value / magnitude; - const step = normalised <= 1 ? 1 : normalised <= 2 ? 2 : normalised <= 5 ? 5 : 10; - return step * magnitude; -} - -function Grid({ plot, max, format }: { plot: Plot; max: number; format: (value: number) => string }) { - const lines = [0, 0.5, 1]; - return ( - - {lines.map((fraction) => { - const y = plot.padTop + innerHeight(plot) * (1 - fraction); - return ( - - - - {format(max * fraction)} - - - ); - })} - - ); -} - -export interface DayDatum { - date: string; - kwh: number; - km: number; -} - -/** - * Daily energy as bars. Days with no record are absent rather than drawn as zero — the - * car was not driven, which is not the same as a day of zero consumption, and the same - * distinction the telemetry side makes between a failed read and a zero. - */ -export function DailyEnergyChart({ days }: { days: DayDatum[] }) { - if (days.length === 0) return

    No daily records yet

    ; - const plot = PLOT; - const max = niceMax(Math.max(...days.map((day) => day.kwh))); - const slot = innerWidth(plot) / days.length; - const barWidth = Math.max(1.5, Math.min(14, slot * 0.62)); - return ( - <> - - value.toFixed(0)} /> - {days.map((day, index) => { - const height = (day.kwh / max) * innerHeight(plot); - const x = plot.padLeft + slot * (index + 0.5) - barWidth / 2; - return ( - 0 ? 1 : 0)} - rx={2} - fill={GOLD} - opacity={0.92} - > - {`${day.date} — ${day.kwh.toFixed(2)} kWh, ${day.km.toFixed(1)} km`} - - ); - })} - - {shortDate(days[0].date)} - - - {shortDate(days[days.length - 1].date)} - - -
    - - - Daily energy (kWh) - -
    - - ); -} - -/** Efficiency over time. Days under 1 km are dropped: the ratio is meaningless there. */ -export function EfficiencyChart({ days }: { days: DayDatum[] }) { - const points = days - .filter((day) => day.km >= 1 && day.kwh > 0) - .map((day) => ({ date: day.date, value: (day.kwh * 100) / day.km })); - if (points.length < 2) return

    Not enough days for a chart

    ; - const plot = PLOT; - const max = niceMax(Math.max(...points.map((point) => point.value))); - const step = innerWidth(plot) / Math.max(1, points.length - 1); - const path = points - .map((point, index) => { - const x = plot.padLeft + step * index; - const y = plot.padTop + innerHeight(plot) * (1 - point.value / max); - return `${index === 0 ? "M" : "L"}${x.toFixed(2)} ${y.toFixed(2)}`; - }) - .join(" "); - const average = points.reduce((total, point) => total + point.value, 0) / points.length; - const averageY = plot.padTop + innerHeight(plot) * (1 - average / max); - return ( - <> - - value.toFixed(0)} /> - - - {points.map((point, index) => ( - - {`${point.date} — ${point.value.toFixed(1)} kWh/100 km`} - - ))} - -
    - - - kWh/100 km - - - - Average {average.toFixed(1)} - -
    - - ); -} - -export interface CurveDatum { - timestamp: string; - powerKw: number; - socPercent?: number; -} - -/** - * Charge curve: power against time, with SOC on its own 0–100 scale. - * - * Two quantities on one plot because the shape of a DC charge is the relationship between - * them — the taper is only legible next to the percentage that caused it. - */ -export function ChargeCurveChart({ curve }: { curve: CurveDatum[] }) { - if (curve.length < 2) return

    No curve recorded for this session

    ; - const plot = { ...PLOT, padRight: 26 }; - const times = curve.map((point) => new Date(point.timestamp).getTime()); - const start = times[0]; - const span = Math.max(1, times[times.length - 1] - start); - const maxPower = niceMax(Math.max(...curve.map((point) => point.powerKw))); - - const x = (time: number) => plot.padLeft + (innerWidth(plot) * (time - start)) / span; - const yPower = (kw: number) => plot.padTop + innerHeight(plot) * (1 - kw / maxPower); - const ySoc = (percent: number) => plot.padTop + innerHeight(plot) * (1 - percent / 100); - - const powerPath = curve - .map((point, index) => `${index === 0 ? "M" : "L"}${x(times[index]).toFixed(2)} ${yPower(point.powerKw).toFixed(2)}`) - .join(" "); - const areaPath = `${powerPath} L${x(times[times.length - 1]).toFixed(2)} ${(plot.padTop + innerHeight(plot)).toFixed(2)} L${x(start).toFixed(2)} ${(plot.padTop + innerHeight(plot)).toFixed(2)} Z`; - - const socPoints = curve - .map((point, index) => ({ point, index })) - .filter((entry) => typeof entry.point.socPercent === "number"); - const socPath = socPoints - .map((entry, order) => - `${order === 0 ? "M" : "L"}${x(times[entry.index]).toFixed(2)} ${ySoc(entry.point.socPercent as number).toFixed(2)}`) - .join(" "); - - return ( - <> - - value.toFixed(0)} /> - - - - - - - - - {socPath && } - - 100% - - - 0% - - - 0 min - - - {Math.round(span / 60000)} min - - -
    - - - Power (kW) - - - - State of charge (%) - -
    - - ); -} - -function shortDate(iso: string): string { - const [, month, day] = iso.split("-"); - return `${day}.${month}`; -} diff --git a/web/src/components/shell.tsx b/web/src/components/shell.tsx deleted file mode 100644 index 242adc8..0000000 --- a/web/src/components/shell.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import Link from "next/link"; -import type { ReactNode } from "react"; - -const TABS = [ - { href: "/", label: "Overview", icon: "◈" }, - { href: "/consumption", label: "Consumption", icon: "▤" }, - { href: "/charging", label: "Charging", icon: "⚡" }, -] as const; - -export type Tab = (typeof TABS)[number]["href"]; - -/** - * The phone frame and the bottom tab bar. - * - * `active` is passed in rather than read from the pathname so every page stays a server - * component — the whole dashboard renders without shipping a line of client JavaScript. - */ -export function Shell({ - active, - title, - subtitle, - children, -}: { - active: Tab; - title: string; - subtitle?: string; - children: ReactNode; -}) { - return ( - <> -
    -
    -
    -

    {title}

    - {subtitle &&

    {subtitle}

    } -
    - {/* - A form, not a link. Next prefetches links that are in the viewport, and this - one is in every page header — as a GET route it was fetched on render and - signed the reader out before they touched it, which looked like the session - expiring on every navigation. Nothing with a side effect may answer GET. - */} -
    - -
    -
    - {children} -
    - - - ); -} - -export function Tile({ - label, - value, - unit, - note, - tone, -}: { - label: string; - value: string; - unit?: string; - note?: string; - tone?: "gold" | "teal"; -}) { - return ( -
    -

    {label}

    -

    - {value} - {unit && {unit}} -

    - {note &&

    {note}

    } -
    - ); -} - -export function Row({ label, value }: { label: string; value: string }) { - return ( -
    - {label} - {value} -
    - ); -} diff --git a/web/src/lib/abrp.ts b/web/src/lib/abrp.ts deleted file mode 100644 index 87a7756..0000000 --- a/web/src/lib/abrp.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { TELEMETRY_FLAGS, type TelemetrySample } from "./types.ts"; - -/** - * Forwards live telemetry to A Better Route Planner. - * - * The ABRP credentials exist only here, as ABRP_API_KEY and ABRP_USER_TOKEN in the server's - * environment; the car never holds them. Forwarding is off when either is unset. - * - * Only the newest sample of a batch is sent, and at most one every ABRP_MIN_INTERVAL_SECONDS - * (default 5). A failed forward is logged and dropped, never queued: ABRP plans from the - * car's state now, and a backlog replayed later would describe a car that has moved on. - * The stored copy in InfluxDB is unaffected either way. - */ - -const API_URL = "https://api.iternio.com/1/tlm/send"; -const TIMEOUT_MS = 8_000; -/** Older than this, a sample is history rather than live state, and ABRP gets nothing. */ -const MAX_AGE_SECONDS = 300; - -const state = globalThis as unknown as { abrpLastUtc?: number; abrpLastSentMs?: number }; - -export function abrpConfigured(): boolean { - return Boolean(process.env.ABRP_API_KEY && process.env.ABRP_USER_TOKEN); -} - -function minIntervalMs(): number { - const seconds = Number(process.env.ABRP_MIN_INTERVAL_SECONDS ?? "5"); - return Number.isFinite(seconds) && seconds >= 0 ? seconds * 1000 : 5000; -} - -/** The tlm object ABRP expects: the same field names, flags as 0/1. */ -export function toTlm(sample: TelemetrySample): Record { - const tlm: Record = {}; - for (const [field, value] of Object.entries(sample)) { - if (typeof value === "number") tlm[field] = value; - } - for (const field of TELEMETRY_FLAGS) { - const value = sample[field]; - if (value !== undefined) tlm[field] = value ? 1 : 0; - } - return tlm; -} - -/** Picks the sample to forward, or null when there is nothing new or it is too soon. */ -export function pickForForward(samples: TelemetrySample[], nowMs: number): TelemetrySample | null { - if (samples.length === 0) return null; - const newest = samples.reduce((a, b) => (b.utc > a.utc ? b : a)); - if (nowMs / 1000 - newest.utc > MAX_AGE_SECONDS) return null; - if (state.abrpLastUtc !== undefined && newest.utc <= state.abrpLastUtc) return null; - if (state.abrpLastSentMs !== undefined && nowMs - state.abrpLastSentMs < minIntervalMs()) return null; - return newest; -} - -/** Fire-and-forget: the car's request is answered without waiting for ABRP. */ -export function forwardToAbrp(samples: TelemetrySample[]): void { - if (!abrpConfigured()) return; - const nowMs = Date.now(); - const sample = pickForForward(samples, nowMs); - if (!sample) return; - state.abrpLastUtc = sample.utc; - state.abrpLastSentMs = nowMs; - void send(sample).catch((error) => console.warn("ABRP forward failed:", error instanceof Error ? error.message : error)); -} - -async function send(sample: TelemetrySample): Promise { - // Credentials go in the form body, never the URL, which ends up in access logs. - const body = new URLSearchParams({ - api_key: process.env.ABRP_API_KEY ?? "", - token: process.env.ABRP_USER_TOKEN ?? "", - tlm: JSON.stringify(toTlm(sample)), - }); - const response = await fetch(API_URL, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body, - signal: AbortSignal.timeout(TIMEOUT_MS), - }); - if (!response.ok) { - // The response body can echo the request; only the status is logged. - console.warn(`ABRP forward rejected: HTTP ${response.status}`); - } -} - -/** For tests: forget what was forwarded. */ -export function resetAbrpState(): void { - state.abrpLastUtc = undefined; - state.abrpLastSentMs = undefined; -} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts deleted file mode 100644 index a266559..0000000 --- a/web/src/lib/api.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { NextResponse } from "next/server"; -import { hasIngestToken, hasSession, checkDashboardPassword } from "./auth"; -import { ValidationError } from "./validate"; - -/** Every endpoint touches InfluxDB, which rules out the edge runtime. */ -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; - -export function unauthorized(): NextResponse { - return NextResponse.json({ error: "unauthorized" }, { status: 401 }); -} - -/** - * Wraps a handler so a validation failure is a 400 and anything else is a 500 whose - * detail stays in the server log. An error message is allowed to name a bad field; it is - * never allowed to carry a connection string. - */ -export async function handle(work: () => Promise): Promise { - try { - return await work(); - } catch (error) { - if (error instanceof ValidationError) { - return NextResponse.json({ error: error.message }, { status: 400 }); - } - console.error("Request failed", error); - return NextResponse.json({ error: "internal server error" }, { status: 500 }); - } -} - -export function requireIngest(request: Request): NextResponse | null { - return hasIngestToken(request) ? null : unauthorized(); -} - -/** - * Read access: the dashboard's own session cookie, or the dashboard password as a bearer - * token so a native client can read the same history without a browser. - */ -export async function requireReader(request: Request): Promise { - if (await hasSession()) return null; - const header = request.headers.get("authorization") ?? ""; - if (header.startsWith("Bearer ") && checkDashboardPassword(header.slice(7))) return null; - return unauthorized(); -} - -export async function parseJson(request: Request): Promise { - const raw = await request.text(); - if (raw.length > 512 * 1024) throw new ValidationError("body is too large"); - try { - return JSON.parse(raw); - } catch { - throw new ValidationError("body is not valid JSON"); - } -} diff --git a/web/src/lib/auth.ts b/web/src/lib/auth.ts deleted file mode 100644 index c1355b5..0000000 --- a/web/src/lib/auth.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * The Next-aware half of authentication. Everything cryptographic lives in - * {@link ./session}, which imports nothing from Next and is therefore unit-testable - * without a request, a build, or a running server. - */ -import { cookies } from "next/headers"; -import { redirect } from "next/navigation"; -import { SESSION_COOKIE, isValidSessionValue } from "./session"; - -export * from "./session"; - -export async function hasSession(): Promise { - const store = await cookies(); - return isValidSessionValue(store.get(SESSION_COOKIE)?.value); -} - -/** For pages: send an unauthenticated visitor to the password screen. */ -export async function requireSession(): Promise { - if (!(await hasSession())) redirect("/login"); -} diff --git a/web/src/lib/data.ts b/web/src/lib/data.ts deleted file mode 100644 index 50c1d07..0000000 --- a/web/src/lib/data.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { installationIds, readChargingSessions, readDays, readLatestTelemetry } from "./store"; -import type { ChargingSession, DailyConsumption, TelemetrySample } from "./types"; - -/** - * Reads for the dashboard. - * - * Every query is scoped to one installation id. There is one car today, but the id is on - * every point, so scoping now costs nothing and means a second head unit does not - * silently merge its history into the first one's charts. - */ - -export interface Overview { - installationId: string | null; - installationCount: number; - days: DailyConsumption[]; - sessions: ChargingSession[]; - latest: (TelemetrySample & { at: Date }) | null; -} - -/** The installation that reported most recently — the car in use, if there are ever two. */ -export async function latestInstallationId(): Promise<{ id: string | null; count: number }> { - const ids = await installationIds(); - return { id: ids[0] ?? null, count: ids.length }; -} - -export async function loadOverview(dayLimit = 60, sessionLimit = 60): Promise { - const { id, count } = await latestInstallationId(); - if (!id) return { installationId: null, installationCount: 0, days: [], sessions: [], latest: null }; - const [days, sessions, latest] = await Promise.all([ - readDays(id), - readChargingSessions(id, sessionLimit), - readLatestTelemetry(id), - ]); - // Charts read left-to-right in time; the reads come newest-first so the limit keeps - // the recent end rather than the beginning of history. - return { - installationId: id, - installationCount: count, - days: days.slice(0, dayLimit).reverse(), - sessions, - latest, - }; -} - -export interface ChargeTotals { - energyKwh: number; - gridEnergyKwh: number; - cost: number; - sessions: number; - seconds: number; - socGained: number; - peakKw: number; -} - -export function sumCharging(sessions: ChargingSession[]): ChargeTotals { - return sessions.reduce( - (total, session) => ({ - energyKwh: total.energyKwh + session.energyKwh, - gridEnergyKwh: total.gridEnergyKwh + (session.gridEnergyKwh ?? session.energyKwh * 1.1), - cost: total.cost + session.totalCost, - sessions: total.sessions + 1, - seconds: total.seconds + session.durationSeconds, - socGained: - total.socGained + - (session.startSocPercent !== undefined && session.endSocPercent !== undefined - ? Math.max(0, session.endSocPercent - session.startSocPercent) - : 0), - peakKw: Math.max(total.peakKw, ...session.curve.map((point) => point.powerKw), 0), - }), - { energyKwh: 0, gridEnergyKwh: 0, cost: 0, sessions: 0, seconds: 0, socGained: 0, peakKw: 0 }, - ); -} - -export function sumDays(days: DailyConsumption[]) { - return days.reduce( - (total, record) => ({ - km: total.km + record.day.km, - kwh: total.kwh + record.day.kwh, - hours: total.hours + record.day.hours, - }), - { km: 0, kwh: 0, hours: 0 }, - ); -} diff --git a/web/src/lib/days.ts b/web/src/lib/days.ts deleted file mode 100644 index e5a36fd..0000000 --- a/web/src/lib/days.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { ConsumptionChunk, DailyConsumption, Totals } from "./types.ts"; - -/** - * Folds chunks into days, newest day first, each with its running lifetime total. - * Pure, so the arithmetic is tested without a database. - */ -export function aggregateDays(chunks: ConsumptionChunk[]): DailyConsumption[] { - const byDate = new Map(); - for (const chunk of chunks) { - let day = byDate.get(chunk.date); - if (!day) { - day = { - installationId: chunk.installationId, - date: chunk.date, - recordedAt: chunk.recordedAt, - day: { km: 0, kwh: 0, hours: 0, socDrop: 0 }, - sohAt: -Infinity, - }; - byDate.set(chunk.date, day); - } - day.day.km += chunk.day.km; - day.day.kwh += chunk.day.kwh; - day.day.hours += chunk.day.hours; - day.day.socDrop += chunk.day.socDrop; - const at = chunk.recordedAt.getTime(); - if (at > day.recordedAt.getTime()) day.recordedAt = chunk.recordedAt; - // The newest manual SoH of the day wins. - if (chunk.sohPercent !== undefined && at > day.sohAt) { - day.sohPercent = chunk.sohPercent; - day.sohAt = at; - } - } - const ascending = [...byDate.values()].sort((a, b) => a.date.localeCompare(b.date)); - const running: Totals = { km: 0, kwh: 0, hours: 0, socDrop: 0 }; - const days = ascending.map(({ sohAt: _sohAt, ...day }) => { - running.km += day.day.km; - running.kwh += day.day.kwh; - running.hours += day.day.hours; - running.socDrop += day.day.socDrop; - return { ...day, lifetime: { ...running } }; - }); - return days.reverse(); -} diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts deleted file mode 100644 index 4343772..0000000 --- a/web/src/lib/format.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** English formatting (en-GB: 24-hour clock, day-month dates) with euro for every number and date. */ - -const NUMBER = new Intl.NumberFormat("en-GB", { maximumFractionDigits: 1 }); -const PRECISE = new Intl.NumberFormat("en-GB", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); -const CURRENCY = new Intl.NumberFormat("en-GB", { style: "currency", currency: "EUR", maximumFractionDigits: 2 }); -const DATE = new Intl.DateTimeFormat("en-GB", { day: "numeric", month: "long", year: "numeric" }); -const DATE_TIME = new Intl.DateTimeFormat("en-GB", { - day: "numeric", - month: "short", - hour: "2-digit", - minute: "2-digit", -}); - -export function num(value: number): string { - return NUMBER.format(value); -} - -export function precise(value: number): string { - return PRECISE.format(value); -} - -export function money(value: number): string { - return CURRENCY.format(value); -} - -export function isoDate(iso: string): string { - const [year, month, day] = iso.split("-").map(Number); - return DATE.format(new Date(Date.UTC(year, month - 1, day))); -} - -export function dateTime(value: Date | string): string { - return DATE_TIME.format(new Date(value)); -} - -/** `2 h 05 min` — the same shape the car's charging pane uses. */ -export function duration(seconds: number): string { - const total = Math.max(0, Math.round(seconds)); - const hours = Math.floor(total / 3600); - const minutes = Math.floor((total % 3600) / 60); - if (hours === 0) return `${minutes} min`; - return `${hours} h ${String(minutes).padStart(2, "0")} min`; -} - -/** Energy per 100 km, or a dash — never a ratio computed from a distance of nothing. */ -export function efficiency(kwh: number, km: number): string { - if (km < 1) return "—"; - return `${num((kwh * 100) / km)} kWh/100 km`; -} diff --git a/web/src/lib/influx.ts b/web/src/lib/influx.ts deleted file mode 100644 index 4bb8c7d..0000000 --- a/web/src/lib/influx.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { InfluxDB, type QueryApi, type WriteApi } from "@influxdata/influxdb-client"; - -/** - * One InfluxDB client per server process. - * - * Configured entirely from the environment so the image carries no credentials: - * INFLUX_URL, INFLUX_TOKEN, INFLUX_ORG and INFLUX_BUCKET (default "mg4"). The global - * survives module reloads in dev, where Next re-evaluates modules on every edit. - */ -const globalForInflux = globalThis as unknown as { influx?: InfluxDB }; - -function env(name: string): string { - const value = process.env[name]; - if (!value) throw new Error(`${name} is not configured`); - return value; -} - -function client(): InfluxDB { - if (!globalForInflux.influx) { - globalForInflux.influx = new InfluxDB({ url: env("INFLUX_URL"), token: env("INFLUX_TOKEN"), timeout: 10_000 }); - } - return globalForInflux.influx; -} - -export function bucket(): string { - return process.env.INFLUX_BUCKET || "mg4"; -} - -/** A write API with millisecond precision; the caller must close() it to flush. */ -export function writeApi(): WriteApi { - return client().getWriteApi(env("INFLUX_ORG"), bucket(), "ms", { - // The caller waits for close(); a failed write must reach it rather than be retried - // in the background after the car has already been told it succeeded. - maxRetries: 0, - }); -} - -export function queryApi(): QueryApi { - return client().getQueryApi(env("INFLUX_ORG")); -} diff --git a/web/src/lib/session.ts b/web/src/lib/session.ts deleted file mode 100644 index 07fbf8a..0000000 --- a/web/src/lib/session.ts +++ /dev/null @@ -1,80 +0,0 @@ -import crypto from "node:crypto"; - -/** - * Two separate credentials, deliberately. - * - * The car holds {@link INGEST_TOKEN} and can only write. It is baked into a build that - * lives on a head unit and is published as an APK, so it must be assumed extractable — - * and an extracted write token buys nothing but the ability to post junk under an - * installation id the attacker would have to guess. - * - * Reading the history needs the dashboard password, which never leaves the browser's - * cookie jar and is never compiled into anything. - */ -export const SESSION_COOKIE = "nilsmg4_session"; -const SESSION_TTL_SECONDS = 60 * 60 * 24 * 30; - -/** Constant-time compare that does not leak length through an early return. */ -function sameSecret(supplied: string, expected: string): boolean { - const a = crypto.createHash("sha256").update(supplied).digest(); - const b = crypto.createHash("sha256").update(expected).digest(); - return crypto.timingSafeEqual(a, b); -} - -function requireEnv(name: string): string { - const value = process.env[name]; - if (!value) throw new Error(`${name} is not configured`); - return value; -} - -/** Does this request carry the car's write token? */ -export function hasIngestToken(request: Request): boolean { - const expected = process.env.INGEST_TOKEN; - if (!expected || expected.length < 24) return false; - const header = request.headers.get("authorization") ?? ""; - if (!header.startsWith("Bearer ")) return false; - return sameSecret(header.slice(7), expected); -} - -export function checkDashboardPassword(supplied: string): boolean { - const expected = process.env.DASHBOARD_PASSWORD; - if (!expected || expected.length < 8) return false; - return sameSecret(supplied, expected); -} - -/** - * `.` — no server-side session store, which a serverless deployment has - * nowhere to keep anyway. The expiry is inside the signed payload, so a browser that - * keeps the cookie past its date cannot use it. - */ -export function issueSessionValue(): string { - const expiresAt = Math.floor(Date.now() / 1000) + SESSION_TTL_SECONDS; - return `${expiresAt}.${sign(String(expiresAt))}`; -} - -function sign(payload: string): string { - return crypto.createHmac("sha256", requireEnv("SESSION_SECRET")).update(payload).digest("hex"); -} - -export function isValidSessionValue(value: string | undefined): boolean { - if (!value) return false; - const separator = value.lastIndexOf("."); - if (separator <= 0) return false; - const expiresAt = Number(value.slice(0, separator)); - if (!Number.isSafeInteger(expiresAt) || expiresAt * 1000 < Date.now()) return false; - try { - const expected = Buffer.from(sign(String(expiresAt)), "hex"); - const supplied = Buffer.from(value.slice(separator + 1), "hex"); - return expected.length === supplied.length && crypto.timingSafeEqual(expected, supplied); - } catch { - return false; - } -} - -export const sessionCookieOptions = { - httpOnly: true, - secure: process.env.NODE_ENV === "production", - sameSite: "lax", - path: "/", - maxAge: SESSION_TTL_SECONDS, -} as const; diff --git a/web/src/lib/store.ts b/web/src/lib/store.ts deleted file mode 100644 index 7836506..0000000 --- a/web/src/lib/store.ts +++ /dev/null @@ -1,225 +0,0 @@ -import { Point, flux } from "@influxdata/influxdb-client"; -import { aggregateDays } from "./days.ts"; -import { bucket, queryApi, writeApi } from "./influx.ts"; -import { - TELEMETRY_FLAGS, - TELEMETRY_NUMBERS, - type ChargingSession, - type ConsumptionChunk, - type CurvePoint, - type DailyConsumption, - type TelemetrySample, -} from "./types.ts"; - -/** - * Storage on InfluxDB 2. One bucket; every point carries an `installationId` tag. - * - * consumption_chunk time = recordedAt tags: date km, kwh, hours, socDrop, sohPercent - * charging_session time = startedAt tags: sessionId summary fields - * charging_curve time = point time tags: sessionId powerKw, socPercent - * telemetry time = sample utc ABRP tlm fields - * - * Idempotency comes from InfluxDB itself: a point with the same measurement, tags and - * timestamp replaces the earlier one. The car re-sends a cached chunk with its original - * recordedAt and a session under its own id, so a retry overwrites instead of adding. - * - * Every numeric field is written as a float. InfluxDB fixes a field's type on first - * write, so a 0 sent as an integer would make every later 0.5 a rejected write. - */ - -const TAG = "installationId"; - -async function write(points: Point[]): Promise { - const api = writeApi(); - api.writePoints(points); - await api.close(); -} - -export async function writeConsumptionChunk(chunk: ConsumptionChunk): Promise { - const point = new Point("consumption_chunk") - .tag(TAG, chunk.installationId) - .tag("date", chunk.date) - .floatField("km", chunk.day.km) - .floatField("kwh", chunk.day.kwh) - .floatField("hours", chunk.day.hours) - .floatField("socDrop", chunk.day.socDrop) - .timestamp(chunk.recordedAt); - if (chunk.sohPercent !== undefined) point.floatField("sohPercent", chunk.sohPercent); - await write([point]); -} - -export async function writeChargingSession(session: ChargingSession): Promise { - const summary = new Point("charging_session") - .tag(TAG, session.installationId) - .tag("sessionId", session.sessionId) - .intField("endedAtMs", session.endedAt.getTime()) - .floatField("durationSeconds", session.durationSeconds) - .floatField("energyKwh", session.energyKwh) - .floatField("chargingLossPercent", session.chargingLossPercent) - .floatField("gridEnergyKwh", session.gridEnergyKwh) - .floatField("pricePerKwh", session.pricePerKwh) - .floatField("totalCost", session.totalCost) - .booleanField("isDemo", session.isDemo === true) - .timestamp(session.startedAt); - if (session.startSocPercent !== undefined) summary.floatField("startSocPercent", session.startSocPercent); - if (session.endSocPercent !== undefined) summary.floatField("endSocPercent", session.endSocPercent); - const curve = session.curve.map((p) => { - const point = new Point("charging_curve") - .tag(TAG, session.installationId) - .tag("sessionId", session.sessionId) - .floatField("powerKw", p.powerKw) - .timestamp(p.timestamp); - if (p.socPercent !== undefined) point.floatField("socPercent", p.socPercent); - return point; - }); - await write([summary, ...curve]); -} - -export async function writeTelemetry(installationId: string, samples: TelemetrySample[]): Promise { - const points = samples.map((sample) => { - const point = new Point("telemetry").tag(TAG, installationId).timestamp(new Date(sample.utc * 1000)); - for (const field of Object.keys(TELEMETRY_NUMBERS) as (keyof typeof TELEMETRY_NUMBERS)[]) { - const value = sample[field]; - if (value !== undefined) point.floatField(field, value); - } - for (const field of TELEMETRY_FLAGS) { - const value = sample[field]; - if (value !== undefined) point.booleanField(field, value); - } - return point; - }); - await write(points); -} - -// ---------- Reads ---------- - -type Row = Record & { _time: string }; - -async function rows(query: ReturnType): Promise { - return queryApi().collectRows(query); -} - -function num(row: Row, field: string): number | undefined { - const value = row[field]; - return typeof value === "number" && Number.isFinite(value) ? value : undefined; -} - -export async function readConsumptionChunks(installationId: string): Promise { - const result = await rows(flux` - from(bucket: ${bucket()}) - |> range(start: 0) - |> filter(fn: (r) => r._measurement == "consumption_chunk" and r.installationId == ${installationId}) - |> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")`); - return result.map((row) => { - const sohPercent = num(row, "sohPercent"); - return { - installationId, - date: String(row.date), - recordedAt: new Date(row._time), - day: { - km: num(row, "km") ?? 0, - kwh: num(row, "kwh") ?? 0, - hours: num(row, "hours") ?? 0, - socDrop: num(row, "socDrop") ?? 0, - }, - ...(sohPercent === undefined ? {} : { sohPercent }), - }; - }); -} - -export async function readDays(installationId: string): Promise { - return aggregateDays(await readConsumptionChunks(installationId)); -} - -export async function readChargingSessions(installationId: string, limit: number): Promise { - const summaries = await rows(flux` - from(bucket: ${bucket()}) - |> range(start: 0) - |> filter(fn: (r) => r._measurement == "charging_session" and r.installationId == ${installationId}) - |> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value") - |> group() - |> sort(columns: ["_time"], desc: true) - |> limit(n: ${limit})`); - if (summaries.length === 0) return []; - - const sessions: ChargingSession[] = summaries.map((row) => { - const startSocPercent = num(row, "startSocPercent"); - const endSocPercent = num(row, "endSocPercent"); - return { - installationId, - sessionId: String(row.sessionId), - startedAt: new Date(row._time), - endedAt: new Date(num(row, "endedAtMs") ?? Date.parse(row._time)), - durationSeconds: num(row, "durationSeconds") ?? 0, - energyKwh: num(row, "energyKwh") ?? 0, - chargingLossPercent: num(row, "chargingLossPercent") ?? 10, - gridEnergyKwh: num(row, "gridEnergyKwh") ?? 0, - pricePerKwh: num(row, "pricePerKwh") ?? 0, - totalCost: num(row, "totalCost") ?? 0, - ...(row.isDemo === true ? { isDemo: true } : {}), - ...(startSocPercent === undefined ? {} : { startSocPercent }), - ...(endSocPercent === undefined ? {} : { endSocPercent }), - curve: [], - }; - }); - - // Curves for exactly these sessions: bounded by the oldest start and the newest end. - const start = new Date(Math.min(...sessions.map((s) => s.startedAt.getTime()))); - const stop = new Date(Math.max(...sessions.map((s) => s.endedAt.getTime())) + 1000); - const curveRows = await rows(flux` - from(bucket: ${bucket()}) - |> range(start: ${start}, stop: ${stop}) - |> filter(fn: (r) => r._measurement == "charging_curve" and r.installationId == ${installationId}) - |> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")`); - const bySession = new Map(sessions.map((s) => [s.sessionId, s])); - for (const row of curveRows) { - const session = bySession.get(String(row.sessionId)); - const powerKw = num(row, "powerKw"); - if (!session || powerKw === undefined) continue; - const socPercent = num(row, "socPercent"); - const point: CurvePoint = { timestamp: new Date(row._time), powerKw }; - if (socPercent !== undefined) point.socPercent = socPercent; - session.curve.push(point); - } - for (const session of sessions) session.curve.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); - return sessions; -} - -/** Installations that reported in the last 400 days, the most recent first. */ -export async function installationIds(): Promise { - const result = await rows(flux` - from(bucket: ${bucket()}) - |> range(start: -400d) - |> filter(fn: (r) => r._measurement == "consumption_chunk" or r._measurement == "charging_session" or r._measurement == "telemetry") - |> keep(columns: ["_time", "installationId"]) - |> group(columns: ["installationId"]) - |> max(column: "_time") - |> group() - |> sort(columns: ["_time"], desc: true)`); - return result.map((row) => String(row.installationId)); -} - -/** - * The latest known value of every live field over the last 30 days, for the dashboard. - * Per field rather than per sample: the newest sample may lack a GPS fix that the one - * before it had. `at` is when the newest of them arrived. Null when nothing was sent. - */ -export async function readLatestTelemetry(installationId: string): Promise<(TelemetrySample & { at: Date }) | null> { - const result = await rows(flux` - from(bucket: ${bucket()}) - |> range(start: -30d) - |> filter(fn: (r) => r._measurement == "telemetry" and r.installationId == ${installationId}) - |> last()`); - if (result.length === 0) return null; - const newest = Math.max(...result.map((row) => Date.parse(row._time))); - const sample: TelemetrySample & { at: Date } = { utc: Math.floor(newest / 1000), at: new Date(newest) }; - const numbers = TELEMETRY_NUMBERS as Record; - const flags = TELEMETRY_FLAGS as readonly string[]; - for (const row of result) { - const field = String(row._field); - const value = row._value; - if (field in numbers && typeof value === "number") (sample as Record)[field] = value; - if (flags.includes(field) && typeof value === "boolean") (sample as Record)[field] = value; - } - return sample; -} diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts deleted file mode 100644 index c523a46..0000000 --- a/web/src/lib/types.ts +++ /dev/null @@ -1,96 +0,0 @@ -/** Record shapes shared by validation, storage and the dashboard. */ - -export interface Totals { - km: number; - kwh: number; - hours: number; - socDrop: number; -} - -/** One day's consumption: the sum of every chunk the car sent for that date. */ -export interface DailyConsumption { - installationId: string; - /** The car's local date, YYYY-MM-DD. */ - date: string; - /** When the newest chunk of the day was recorded. */ - recordedAt: Date; - day: Totals; - /** Running total up to and including this day, derived when history is read. */ - lifetime?: Totals; - sohPercent?: number; -} - -/** One 2-minute delta chunk as the car sends it; days are built from these. */ -export interface ConsumptionChunk { - installationId: string; - date: string; - recordedAt: Date; - day: Totals; - sohPercent?: number; -} - -export interface CurvePoint { - timestamp: Date; - powerKw: number; - socPercent?: number; -} - -export interface ChargingSession { - installationId: string; - sessionId: string; - startedAt: Date; - endedAt: Date; - durationSeconds: number; - energyKwh: number; - chargingLossPercent: number; - gridEnergyKwh: number; - pricePerKwh: number; - totalCost: number; - isDemo?: boolean; - startSocPercent?: number; - endSocPercent?: number; - curve: CurvePoint[]; -} - -/** Numeric live-telemetry fields, named and unit-ed as ABRP's tlm/send expects them. */ -export const TELEMETRY_NUMBERS = { - soc: [0, 100], // % - speed: [0, 400], // km/h - power: [-1000, 1000], // kW, positive leaving the battery - est_battery_range: [0, 2000], // km - capacity: [0, 500], // kWh - soe: [0, 500], // kWh - soh: [0, 100], // % - kwh_charged: [0, 1000], // kWh, this charge session - odometer: [0, 10_000_000], // km - lat: [-90, 90], - lon: [-180, 180], - elevation: [-1000, 10_000], // m - heading: [0, 360], // ° - batt_temp: [-60, 100], // °C - cabin_temp: [-60, 100], // °C - ext_temp: [-60, 70], // °C - hvac_setpoint: [0, 40], // °C - hvac_power: [0, 50], // kW - voltage: [0, 1500], // V - current: [-2000, 2000], // A - tire_pressure_fl: [0, 1000], // kPa - tire_pressure_fr: [0, 1000], - tire_pressure_rl: [0, 1000], - tire_pressure_rr: [0, 1000], -} as const satisfies Record; - -/** Boolean live-telemetry fields; ABRP takes them as 0/1. */ -export const TELEMETRY_FLAGS = ["is_charging", "is_dcfc", "is_parked"] as const; - -export type TelemetryNumber = keyof typeof TELEMETRY_NUMBERS; -export type TelemetryFlag = (typeof TELEMETRY_FLAGS)[number]; - -/** One live sample. Only `utc` is always present; an unread signal is absent, never 0. */ -export type TelemetrySample = { utc: number } & Partial> & - Partial>; - -export interface TelemetryBatch { - installationId: string; - samples: TelemetrySample[]; -} diff --git a/web/src/lib/validate.ts b/web/src/lib/validate.ts deleted file mode 100644 index 945657e..0000000 --- a/web/src/lib/validate.ts +++ /dev/null @@ -1,167 +0,0 @@ -/** - * Request validation for the ingest endpoints — a direct port of the Express service's - * rules, kept pure so it can be unit-tested without a database or a request. - * - * The car is the only writer and it is trusted, but a bad field here is written once and - * read forever. Every number is bounded; an out-of-range value is rejected rather than - * stored and quietly plotted. - */ -import { - TELEMETRY_FLAGS, - TELEMETRY_NUMBERS, - type ChargingSession, - type ConsumptionChunk, - type CurvePoint, - type TelemetryBatch, - type TelemetrySample, - type Totals, -} from "./types.ts"; - -export class ValidationError extends Error {} - -const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; -const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/; - -export function validateConsumption(body: unknown): ConsumptionChunk { - const record = asObject(body); - const date = record.date; - if (typeof date !== "string" || !ISO_DATE.test(date)) throw new ValidationError("date is invalid"); - const sohPercent = record.sohPercent; - // A lifetime from an older app is still validated, so a malformed one is still a 400, - // but it is not stored: the backend derives lifetime from the day totals. - if (record.lifetime !== undefined) validateTotals(record.lifetime, "lifetime"); - return { - installationId: validateUuid(record.installationId, "installationId"), - date, - recordedAt: validateDate(record.recordedAt, "recordedAt"), - day: validateTotals(record.day, "day"), - ...(sohPercent === undefined ? {} : { sohPercent: bounded(sohPercent, 0, 100, "sohPercent") }), - }; -} - -export function validateChargingSession(body: unknown, pathSessionId: string): ChargingSession { - const record = asObject(body); - const sessionId = validateUuid(record.sessionId, "sessionId"); - if (sessionId !== pathSessionId) throw new ValidationError("sessionId does not match path"); - if (!Array.isArray(record.curve) || record.curve.length > 720) { - throw new ValidationError("curve is invalid"); - } - const curve: CurvePoint[] = record.curve.map((entry) => { - const point = asObject(entry); - const socPercent = point.socPercent; - return { - timestamp: validateDate(point.timestampMs, "curve.timestampMs"), - powerKw: bounded(point.powerKw, 0, 1000, "curve.powerKw"), - ...(socPercent === undefined ? {} : { socPercent: bounded(socPercent, 0, 100, "curve.socPercent") }), - }; - }); - const startSocPercent = record.startSocPercent; - const endSocPercent = record.endSocPercent; - const isDemo = record.isDemo; - const energyKwh = bounded(record.energyKwh, 0, 1000, "energyKwh"); - const pricePerKwh = bounded(record.pricePerKwh, 0, 1_000_000, "pricePerKwh"); - const chargingLossPercent = 10; - const gridEnergyKwh = energyKwh * 1.1; - return { - installationId: validateUuid(record.installationId, "installationId"), - sessionId, - startedAt: validateDate(record.startedAt, "startedAt"), - endedAt: validateDate(record.endedAt, "endedAt"), - durationSeconds: bounded(record.durationSeconds, 0, 604800, "durationSeconds"), - energyKwh, - chargingLossPercent, - gridEnergyKwh, - pricePerKwh, - totalCost: gridEnergyKwh * pricePerKwh, - ...(isDemo === true ? { isDemo: true } : {}), - ...(startSocPercent === undefined ? {} : { startSocPercent: bounded(startSocPercent, 0, 100, "startSocPercent") }), - ...(endSocPercent === undefined ? {} : { endSocPercent: bounded(endSocPercent, 0, 100, "endSocPercent") }), - curve, - }; -} - -const MAX_SAMPLES = 500; -/** Epoch seconds: 2001 to 2286. Milliseconds would be far above the upper bound. */ -const UTC_MIN = 1_000_000_000; -const UTC_MAX = 9_999_999_999; - -/** - * A batch of live samples in ABRP's tlm field names and units. Fields the server does not - * know are dropped rather than rejected, so a newer app can add one without a server - * release; every known field is bounded, and a flag may be sent as 0/1 or a boolean. - */ -export function validateTelemetryBatch(body: unknown): TelemetryBatch { - const record = asObject(body); - const installationId = validateUuid(record.installationId, "installationId"); - const samples = record.samples; - if (!Array.isArray(samples) || samples.length === 0 || samples.length > MAX_SAMPLES) { - throw new ValidationError("samples is invalid"); - } - return { installationId, samples: samples.map((entry, i) => validateSample(entry, `samples[${i}]`)) }; -} - -function validateSample(entry: unknown, name: string): TelemetrySample { - const raw = asObject(entry); - const utc = raw.utc; - if (typeof utc !== "number" || !Number.isSafeInteger(utc) || utc < UTC_MIN || utc > UTC_MAX) { - throw new ValidationError(`${name}.utc is invalid`); - } - const sample: TelemetrySample = { utc }; - for (const [field, [min, max]] of Object.entries(TELEMETRY_NUMBERS)) { - const value = raw[field]; - if (value === undefined || value === null) continue; - (sample as Record)[field] = bounded(value, min, max, `${name}.${field}`); - } - for (const field of TELEMETRY_FLAGS) { - const value = raw[field]; - if (value === undefined || value === null) continue; - if (value === true || value === 1) sample[field] = true; - else if (value === false || value === 0) sample[field] = false; - else throw new ValidationError(`${name}.${field} is invalid`); - } - // A position is only a position as a pair. - if ((sample.lat === undefined) !== (sample.lon === undefined)) { - throw new ValidationError(`${name}.lat and lon must be sent together`); - } - return sample; -} - -export function validateUuid(value: unknown, name: string): string { - if (typeof value !== "string" || !UUID.test(value)) throw new ValidationError(`${name} is invalid`); - return value; -} - -function asObject(value: unknown): Record { - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new ValidationError("body is required"); - } - return value as Record; -} - -/** Epoch milliseconds on the wire; a Date in the database. */ -function validateDate(value: unknown, name: string): Date { - if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) { - throw new ValidationError(`${name} is invalid`); - } - return new Date(value); -} - -function bounded(value: unknown, min: number, max: number, name: string): number { - if (typeof value !== "number" || !Number.isFinite(value) || value < min || value > max) { - throw new ValidationError(`${name} is invalid`); - } - return value; -} - -function validateTotals(value: unknown, name: string): Totals { - const record = asObject(value); - const result = {} as Totals; - for (const field of ["km", "kwh", "hours", "socDrop"] as const) { - const number = record[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/web/test/days.test.ts b/web/test/days.test.ts deleted file mode 100644 index 6fb6486..0000000 --- a/web/test/days.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; -import { aggregateDays } from "../src/lib/days.ts"; - -const ID = "6f1a2b3c-4d5e-4f60-8a9b-0c1d2e3f4a5b"; -const chunk = (date: string, at: number, km: number, kwh: number, sohPercent?: number) => ({ - installationId: ID, - date, - recordedAt: new Date(at), - day: { km, kwh, hours: km / 50, socDrop: kwh / 0.6 }, - ...(sohPercent === undefined ? {} : { sohPercent }), -}); - -test("adds chunks up per day, newest day first, with a running lifetime", () => { - const days = aggregateDays([ - chunk("2026-10-16", 1_000, 10, 2), - chunk("2026-10-17", 3_000, 5, 1), - chunk("2026-10-16", 2_000, 20, 3), - ]); - assert.deepEqual(days.map((d) => d.date), ["2026-10-17", "2026-10-16"]); - assert.equal(days[1].day.km, 30); - assert.equal(days[1].day.kwh, 5); - assert.equal(days[1].recordedAt.getTime(), 2_000); - assert.equal(days[1].lifetime?.km, 30); - assert.equal(days[0].lifetime?.km, 35); - assert.equal(days[0].lifetime?.kwh, 6); -}); - -test("the newest manual SoH of a day wins, and a day without one has none", () => { - const days = aggregateDays([ - chunk("2026-10-16", 2_000, 1, 1, 96), - chunk("2026-10-16", 1_000, 1, 1, 98), - chunk("2026-10-17", 3_000, 1, 1), - ]); - assert.equal(days[1].sohPercent, 96); - assert.equal("sohPercent" in days[0], false); -}); diff --git a/web/test/session.test.ts b/web/test/session.test.ts deleted file mode 100644 index 6d14b6f..0000000 --- a/web/test/session.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; - -process.env.SESSION_SECRET = "0123456789abcdef0123456789abcdef"; -process.env.DASHBOARD_PASSWORD = "correct-horse-battery"; -process.env.INGEST_TOKEN = "a".repeat(64); - -const { checkDashboardPassword, hasIngestToken, isValidSessionValue, issueSessionValue } = - await import("../src/lib/session.ts"); - -test("a freshly issued session validates and a tampered one does not", () => { - const value = issueSessionValue(); - assert.equal(isValidSessionValue(value), true); - const [expiry, signature] = value.split("."); - // Moving the expiry out invalidates the signature, so a cookie cannot extend itself. - assert.equal(isValidSessionValue(`${Number(expiry) + 86_400}.${signature}`), false); - assert.equal(isValidSessionValue(`${expiry}.${"0".repeat(signature.length)}`), false); - assert.equal(isValidSessionValue(undefined), false); - assert.equal(isValidSessionValue("garbage"), false); -}); - -test("an expired session is refused even with a valid signature", async () => { - const past = Math.floor(Date.now() / 1000) - 10; - // Signed by the same secret, so only the embedded expiry rejects it. - const crypto = await import("node:crypto"); - const signature = crypto.createHmac("sha256", process.env.SESSION_SECRET as string) - .update(String(past)).digest("hex"); - assert.equal(isValidSessionValue(`${past}.${signature}`), false); -}); - -test("the dashboard password is exact", () => { - assert.equal(checkDashboardPassword("correct-horse-battery"), true); - assert.equal(checkDashboardPassword("correct-horse-batter"), false); - assert.equal(checkDashboardPassword(""), false); -}); - -test("the ingest token is only accepted as a bearer header", () => { - const token = "a".repeat(64); - const bearer = (value: string) => new Request("https://x/v1", { headers: { authorization: value } }); - assert.equal(hasIngestToken(bearer(`Bearer ${token}`)), true); - assert.equal(hasIngestToken(bearer(token)), false); - assert.equal(hasIngestToken(bearer(`Bearer ${"b".repeat(64)}`)), false); - assert.equal(hasIngestToken(new Request("https://x/v1")), false); -}); diff --git a/web/test/telemetry.test.ts b/web/test/telemetry.test.ts deleted file mode 100644 index 7adea8a..0000000 --- a/web/test/telemetry.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; -import { pickForForward, resetAbrpState, toTlm } from "../src/lib/abrp.ts"; -import { ValidationError, validateTelemetryBatch } from "../src/lib/validate.ts"; - -const INSTALLATION = "6f1a2b3c-4d5e-4f60-8a9b-0c1d2e3f4a5b"; -const NOW_S = 1_790_000_000; - -test("accepts a sample in ABRP's field names and keeps only what was sent", () => { - const batch = validateTelemetryBatch({ - installationId: INSTALLATION, - samples: [{ utc: NOW_S, soc: 81, power: -12.5, speed: 0, lat: 52.1, lon: 7.6, is_charging: 1, is_dcfc: false }], - }); - assert.deepEqual(batch.samples[0], { - utc: NOW_S, soc: 81, power: -12.5, speed: 0, lat: 52.1, lon: 7.6, is_charging: true, is_dcfc: false, - }); - // An unread signal stays absent: a missing SOC must never become 0. - assert.equal("est_battery_range" in batch.samples[0], false); -}); - -test("drops unknown fields instead of rejecting the batch", () => { - const batch = validateTelemetryBatch({ installationId: INSTALLATION, samples: [{ utc: NOW_S, future_field: 3 }] }); - assert.deepEqual(batch.samples[0], { utc: NOW_S }); -}); - -test("rejects bad timestamps, out-of-range values, bad flags and half positions", () => { - const send = (sample: object) => validateTelemetryBatch({ installationId: INSTALLATION, samples: [sample] }); - assert.throws(() => send({ utc: NOW_S * 1000 }), ValidationError); // milliseconds, not seconds - assert.throws(() => send({ utc: 1.5 }), ValidationError); - assert.throws(() => send({ utc: NOW_S, soc: 101 }), ValidationError); - assert.throws(() => send({ utc: NOW_S, speed: "fast" }), ValidationError); - assert.throws(() => send({ utc: NOW_S, is_parked: 2 }), ValidationError); - assert.throws(() => send({ utc: NOW_S, lat: 52.1 }), ValidationError); -}); - -test("rejects empty and oversized batches and bad installation ids", () => { - assert.throws(() => validateTelemetryBatch({ installationId: INSTALLATION, samples: [] }), ValidationError); - const many = Array.from({ length: 501 }, (_, i) => ({ utc: NOW_S + i })); - assert.throws(() => validateTelemetryBatch({ installationId: INSTALLATION, samples: many }), ValidationError); - assert.throws(() => validateTelemetryBatch({ installationId: "nope", samples: [{ utc: NOW_S }] }), ValidationError); -}); - -test("ABRP gets flags as 0/1", () => { - assert.deepEqual(toTlm({ utc: NOW_S, soc: 50, is_charging: true, is_parked: false }), { - utc: NOW_S, soc: 50, is_charging: 1, is_parked: 0, - }); -}); - -test("forwards only the newest fresh sample, at most once per interval", () => { - resetAbrpState(); - const nowMs = NOW_S * 1000; - const picked = pickForForward([{ utc: NOW_S - 10 }, { utc: NOW_S - 2 }, { utc: NOW_S - 5 }], nowMs); - assert.equal(picked?.utc, NOW_S - 2); - // A sample from ten minutes ago describes a car that has moved on. - assert.equal(pickForForward([{ utc: NOW_S - 600 }], nowMs), null); -}); diff --git a/web/test/validate.test.ts b/web/test/validate.test.ts deleted file mode 100644 index eeef907..0000000 --- a/web/test/validate.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; -import { ValidationError, validateChargingSession, validateConsumption } from "../src/lib/validate.ts"; - -const INSTALLATION = "6f1a2b3c-4d5e-4f60-8a9b-0c1d2e3f4a5b"; -const SESSION = "11111111-2222-4333-8444-555555555555"; - -const day = { km: 42.5, kwh: 7.25, hours: 0.9, socDrop: 11.5 }; - -test("accepts a well-formed daily record", () => { - const record = validateConsumption({ - installationId: INSTALLATION, - date: "2026-10-07", - recordedAt: 1_760_000_000_000, - day, - lifetime: { km: 12000, kwh: 2100, hours: 240, socDrop: 3400 }, - sohPercent: 97.5, - }); - assert.equal(record.date, "2026-10-07"); - assert.equal(record.day.kwh, 7.25); - assert.ok(record.recordedAt instanceof Date); -}); - -test("a missing SOH is omitted rather than defaulted", () => { - // The car omits a field it could not read. Writing 0 here would be a battery at - // nothing, which is the same class of lie the telemetry side refuses to send. - const record = validateConsumption({ - installationId: INSTALLATION, - date: "2026-10-07", - recordedAt: 1, - day, - lifetime: day, - }); - assert.equal("sohPercent" in record, false); -}); - -test("rejects malformed identifiers, dates and out-of-range numbers", () => { - const base = { installationId: INSTALLATION, date: "2026-10-07", recordedAt: 1, day, lifetime: day }; - assert.throws(() => validateConsumption({ ...base, installationId: "nope" }), ValidationError); - assert.throws(() => validateConsumption({ ...base, date: "07.10.2026" }), ValidationError); - assert.throws(() => validateConsumption({ ...base, sohPercent: 140 }), ValidationError); - assert.throws(() => validateConsumption({ ...base, day: { ...day, kwh: "7" } }), ValidationError); - assert.throws(() => validateConsumption({ ...base, recordedAt: -1 }), ValidationError); - assert.throws(() => validateConsumption(null), ValidationError); -}); - -test("a charging session must match the id in its path", () => { - const body = { - installationId: INSTALLATION, - sessionId: SESSION, - startedAt: 1_760_000_000_000, - endedAt: 1_760_002_000_000, - durationSeconds: 1800, - energyKwh: 31.4, - chargingLossPercent: 10, - gridEnergyKwh: 34.54, - pricePerKwh: 7.5, - totalCost: 235.5, - isDemo: true, - startSocPercent: 18, - endSocPercent: 72, - curve: [{ timestampMs: 1_760_000_060_000, powerKw: 84.2, socPercent: 20 }], - }; - const record = validateChargingSession(body, SESSION); - assert.equal(record.curve.length, 1); - assert.equal(record.curve[0].powerKw, 84.2); - assert.equal(record.chargingLossPercent, 10); - assert.equal(record.gridEnergyKwh, 34.54); - assert.equal(record.totalCost, 259.05); - assert.equal(record.isDemo, true); - assert.throws(() => validateChargingSession(body, "11111111-2222-4333-8444-555555555556"), ValidationError); -}); - -test("refuses a curve longer than the car can produce", () => { - const curve = Array.from({ length: 721 }, (_unused, index) => ({ - timestampMs: 1_760_000_000_000 + index * 60_000, - powerKw: 50, - })); - assert.throws( - () => - validateChargingSession( - { - installationId: INSTALLATION, - sessionId: SESSION, - startedAt: 1, - endedAt: 2, - durationSeconds: 1, - energyKwh: 1, - chargingLossPercent: 10, - gridEnergyKwh: 1.1, - pricePerKwh: 1, - totalCost: 1, - curve, - }, - SESSION, - ), - ValidationError, - ); -}); diff --git a/web/tsconfig.json b/web/tsconfig.json deleted file mode 100644 index deb57ab..0000000 --- a/web/tsconfig.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "lib": ["dom", "dom.iterable", "ES2022"], - "allowJs": false, - "skipLibCheck": true, - "strict": true, - "noEmit": true, - "allowImportingTsExtensions": true, - "esModuleInterop": true, - "module": "esnext", - "moduleResolution": "bundler", - "resolveJsonModule": true, - "isolatedModules": true, - "jsx": "preserve", - "incremental": true, - "plugins": [{ "name": "next" }], - "paths": { "@/*": ["./src/*"] } - }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] -} From 5473ab1c2b6f74aa539f22672923a9ffca8d0aa8 Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Sat, 17 Oct 2026 15:18:09 +0200 Subject: [PATCH 19/79] Dashboards in Grafana; clean up after the Next.js app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - k8s/grafana.yaml: Grafana 13.2.3 with a 1Gi PVC, sign-up and anonymous access off. The InfluxDB datasource (URL and token from the environment) and three dashboards — Live, Consumption, Charging — are provisioned from k8s/grafana/ via kustomize-generated ConfigMaps. - server.yaml for the Go server (port 8080, UID 65532, smaller limits), image tag 2.0.0. The NetworkPolicy now admits Grafana to InfluxDB. - ingress.yaml: IngressRoutes for car.stinnesbeck.com and Grafana (placeholder host car-grafana.stinnesbeck.com) with tls: {}, using Traefik's default certificate; the cert-manager Certificate is gone. - secrets.example.yaml: drops the dashboard secrets, adds grafana-admin. - AGENTS.md loses its Next.js block; SPECS.md describes the new setup. The removal of web/ (the Next.js app) and of the stale .next/ cache upstream had committed already landed in 51e6f6f, which was staged together with the Go server by mistake. Checked end to end in podman (Go server + InfluxDB 2.7.12 + Grafana 13.2.3): all 25 dashboard queries return the expected data, and the dashboards were rendered with headless Chromium. Manifests validated offline with kustomize and kubeconform. Nothing applied to a cluster. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki --- AGENTS.md | 10 - SPECS.md | 89 ++-- k8s/README.md | 45 +- k8s/grafana.yaml | 137 ++++++ k8s/grafana/dashboards.yaml | 11 + k8s/grafana/dashboards/charging.json | 517 ++++++++++++++++++++ k8s/grafana/dashboards/consumption.json | 415 ++++++++++++++++ k8s/grafana/dashboards/live.json | 619 ++++++++++++++++++++++++ k8s/grafana/datasources.yaml | 17 + k8s/influxdb.yaml | 8 +- k8s/ingress.yaml | 43 +- k8s/kustomization.yaml | 21 +- k8s/secrets.example.yaml | 18 +- k8s/server.yaml | 31 +- 14 files changed, 1842 insertions(+), 139 deletions(-) create mode 100644 k8s/grafana.yaml create mode 100644 k8s/grafana/dashboards.yaml create mode 100644 k8s/grafana/dashboards/charging.json create mode 100644 k8s/grafana/dashboards/consumption.json create mode 100644 k8s/grafana/dashboards/live.json create mode 100644 k8s/grafana/datasources.yaml diff --git a/AGENTS.md b/AGENTS.md index 1f9ce97..a67abd4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -120,13 +120,3 @@ a stale Gradle daemon started by VS Code's Java extension is being reused — Most of this cannot be verified off a vehicle. When reporting work, state plainly what was tested and what was not. "Unit tests pass, not tried on the car" is the expected and acceptable answer. Do not imply road-testing that did not happen. - - - -## This is NOT the Next.js you know - -This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. - -This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. - - diff --git a/SPECS.md b/SPECS.md index dff6558..348eef3 100644 --- a/SPECS.md +++ b/SPECS.md @@ -7,76 +7,40 @@ what has been changed, and what is still to do. - Upstream: https://github.com/fatihdonmezdev/FatihsMG4 (itself a fork of malys / EVABRPUploader and Leon Kernan / ABRP_Uploader). Credited in the app's About dialog. -## Plan: car server at car.stinnesbeck.com +## Architecture: car server at car.stinnesbeck.com -The car sends everything to our own server. The server stores it and forwards live telemetry to -ABRP, so the ABRP token lives only on the server, never in the APK. +The car sends everything to our own server. The server stores it in InfluxDB 2 and forwards live +telemetry to ABRP, so the ABRP token lives only on the server, never in the APK. Grafana reads +InfluxDB directly for the dashboards. ``` -car ──live telemetry───▶ car.stinnesbeck.com ──▶ ABRP api.iternio.com/1/tlm/send - ──daily totals─────▶ │ +car ──live telemetry───▶ car.stinnesbeck.com (Go) ──▶ ABRP api.iternio.com/1/tlm/send + ──daily chunks─────▶ │ ──charging sessions▶ ▼ - InfluxDB 2 + InfluxDB 2 ◀── Grafana (car-grafana.stinnesbeck.com) ``` -### Server — extend `web/` (Next.js) - -- **Storage: MongoDB → InfluxDB 2.** Rewrite `web/src/lib/mongo.ts` / `data.ts` against one bucket - (tag `installationId` on every point): - - | Measurement | Timestamp | Tags | Fields | - |---|---|---|---| - | `telemetry` | sample time | — | soc, power, speed, lat, lon, heading, elevation, is_charging, is_dcfc, is_parked, capacity, soe, odometer, est_battery_range, batt_temp, cabin_temp, ext_temp, hvac_setpoint, voltage, current, tire_pressure_fl/fr/rl/rr | - | `consumption_chunk` | recordedAt | date | km, kwh, hours, socDrop, sohPercent (days summed on read) | - | `charging_session` | startedAt | sessionId | endedAt, durationSeconds, energyKwh, gridEnergyKwh, pricePerKwh, totalCost, start/endSocPercent | - | `charging_curve` | point time | sessionId | powerKw, socPercent | - - Same measurement + tags + timestamp overwrites, so the car's retries stay idempotent - (MongoDB used unique indexes for that). -- **New endpoint `POST /v1/telemetry`:** a batch of live samples, ingest-token auth, validated - and bounded like the existing endpoints. -- **ABRP forwarder:** after storing, forward the newest sample to `tlm/send` with `ABRP_API_KEY` and - `ABRP_USER_TOKEN` from the environment. Rate-limited; a failed forward is logged but not - retried, since stale live data is useless to ABRP. Off when the token is unset. -- **Existing endpoints stay:** `PUT /v1/consumption/daily`, `PUT /v1/charging-sessions/:id`, - `GET /v1/consumption/history/:id`, the dashboard and `/v1/health`. -- **Self-hosting:** `output: "standalone"`, a `Dockerfile`, and InfluxDB's own overwrite semantics - instead of `ensureIndexes()`. - -### Car app - -- Restore the live telemetry payload and upload cadence from before `ea8032a` (`TelemetryPayload`, - `UploadCadence`, location access), sending to the server instead of ABRP. -- Server URL, ingest token and installation id are entered in a new **Server** tab or imported from - a config file; nothing about the server is compiled into the APK. Each installation generates - its own id. - -### Image and deployment - -- Image: `git.stinnesbeck.com/nils/mg4-server:`, pushed to the forge's registry - (needs a registry login on the build machine). -- `k8s/` with plain manifests: Namespace, InfluxDB StatefulSet + PVC + Service (cluster-internal), - server Deployment + Service + Ingress for `car.stinnesbeck.com` (TLS), and Secret *templates* - with placeholder values only. -- **Nothing is ever applied to the cluster by us.** Nils applies the manifests. - -### Decisions (2026-10-17) - -- InfluxDB 2 runs in the cluster from our manifests. -- TLS: cert-manager `ClusterIssuer` `letsencrypt-prod`; Traefik `IngressRoute` on `websecure`. -- Car config is entered in the app (Server tab / config file), not baked into the APK. -- The ABRP API key and user token are set by Nils in the `mg4-server` Secret. +- **Server:** `server/`, Go 1.26, API only — see `server/README.md` for endpoints, configuration + and the storage layout. Replaced the Next.js app from upstream (`web/`, removed 2026-10-17). +- **Dashboards:** Grafana, provisioned from `k8s/grafana/`: Live (stats, SoC/range, power/speed, + route map, temperatures, tyres), Consumption (period totals, lifetime, SoH, per-day energy, + distance and efficiency) and Charging (totals, session table, curves). Prices in euro. +- **Car app:** Server tab (URL, ingest token, installation id, config import); live telemetry + every 15 s while driving or charging; consumption chunks and charging sessions as before. +- **Deployment:** `k8s/`, plain manifests. **Nothing is ever applied to the cluster by us** — + Nils applies them (`k8s/README.md`). TLS comes from Traefik's default certificate. ### Status -- [x] Server on InfluxDB 2 with `/v1/telemetry` and the ABRP forwarder (`web/`), tested locally - against InfluxDB 2.7.12. Image builds. -- [x] Manifests in `k8s/`, validated offline. **Not applied** — that is Nils' step (`k8s/README.md`). -- [x] Car app 2.3.0: Server tab with config import, live telemetry upload, cadence controls. - Unit tests and lint pass; **not yet tried on the car**. -- [ ] Push the image (`mg4-server:1.1.2`) to git.stinnesbeck.com (needs a registry login on the build machine). -- [ ] Check the Traefik ingress class in `k8s/ingress.yaml` (placeholder `traefik-external`). -- [ ] First drive with 2.3.0: telemetry arrives in InfluxDB and at ABRP. +- [x] Go server 2.0.0: tests and govulncheck clean; image builds (about 11 MB). +- [x] End-to-end in podman (server + InfluxDB 2.7.12 + Grafana 13.2.3): every endpoint, retry + idempotency, all 25 dashboard queries return data, dashboards rendered and checked. +- [x] Manifests validated offline (including Traefik CRDs). **Not applied.** +- [x] Car app 2.3.0: unit tests and lint pass; **not yet tried on the car**. +- [ ] Push the image (`mg4-server:2.0.0`) to git.stinnesbeck.com (needs a registry login here). +- [ ] Check in `k8s/ingress.yaml`: the Traefik ingress class (placeholder `traefik-external`) and + Grafana's host (placeholder `car-grafana.stinnesbeck.com`). +- [ ] First drive with 2.3.0: telemetry arrives in InfluxDB, Grafana and ABRP. ## To do @@ -116,6 +80,9 @@ car ──live telemetry───▶ car.stinnesbeck.com ──▶ ABRP api.ite - 2026-10-17 — Accent colour: MG red instead of muted gold. Web app renamed to Nils' MG4. - 2026-10-17 — Dashboard translated to English (en-GB formatting); euro instead of lira in the dashboard and the car app's charging tab. +- 2026-10-17 — Server rewritten in Go (API only); dashboards moved to Grafana with provisioned + InfluxDB datasource and three dashboards; the Next.js app (`web/`) removed. Per-host + cert-manager certificates dropped in favour of Traefik's default certificate. - 2026-10-17 — Temporary on-screen diagnostics (b2a5b32) to debug blank values without adb; removed again (1002962) once values showed on the car. Revert 1002962 to bring them back. diff --git a/k8s/README.md b/k8s/README.md index ecbb682..db45c48 100644 --- a/k8s/README.md +++ b/k8s/README.md @@ -1,28 +1,30 @@ # Kubernetes manifests — car.stinnesbeck.com -Plain manifests for the car server and its InfluxDB 2. They are only files: nothing here is -applied automatically. Apply them yourself. +Plain manifests for the car server, InfluxDB 2 and Grafana. They are only files: nothing here +is applied automatically. Apply them yourself. | File | What | |---|---| | `namespace.yaml` | Namespace `mg4` | -| `influxdb.yaml` | InfluxDB 2.7.12 StatefulSet (10Gi PVC, default storage class), cluster-internal Service, NetworkPolicy allowing only the server | -| `server.yaml` | Server Deployment (1 replica, non-root, read-only root filesystem) and Service | -| `ingress.yaml` | cert-manager `Certificate` from `letsencrypt-prod`, Traefik `IngressRoute` on `websecure` | -| `secrets.example.yaml` | **Template** for the two Secrets; not in `kustomization.yaml` | -| `kustomization.yaml` | Everything except the Secrets; pins the server image tag | +| `influxdb.yaml` | InfluxDB 2.7.12 StatefulSet (10Gi PVC, default storage class), cluster-internal Service, NetworkPolicy admitting only the server and Grafana | +| `server.yaml` | Go server Deployment (1 replica, non-root, read-only root filesystem) and Service | +| `grafana.yaml` | Grafana 13.2.3 Deployment (1Gi PVC), Service; datasource and dashboards provisioned | +| `grafana/` | The provisioned InfluxDB datasource and the MG4 dashboards (Live, Consumption, Charging) | +| `ingress.yaml` | Traefik `IngressRoute`s for car.stinnesbeck.com and Grafana, TLS from Traefik's default certificate | +| `secrets.example.yaml` | **Template** for the Secrets; not in `kustomization.yaml` | +| `kustomization.yaml` | Everything except the Secrets; pins the server image, generates the Grafana ConfigMaps | ## Before the first apply -1. **Check the ingress class** in `ingress.yaml`. It is set to `traefik-external` as a - placeholder; use the class of your public-facing Traefik. +1. **Check `ingress.yaml`:** the ingress class (placeholder `traefik-external` for the public + Traefik) and Grafana's host (placeholder `car-grafana.stinnesbeck.com`). Both hosts must be + covered by Traefik's default certificate. 2. **Create the Secrets.** Copy `secrets.example.yaml` outside the repository, replace every - `CHANGE-ME` (`openssl rand -hex 32` for tokens), then apply the copy: - `influxdb-auth` (InfluxDB admin password and token) and `mg4-server` (ingest token, - session secret, dashboard password, your ABRP API key and user token). -3. **Registry access.** If the `mg4-server` package on git.stinnesbeck.com is private, create - the `git-stinnesbeck-registry` pull secret (command in `secrets.example.yaml`); if it is - public, delete `imagePullSecrets` from `server.yaml`. + `CHANGE-ME` (`openssl rand -hex 32`), then apply the copy: `influxdb-auth`, `mg4-server` + (ingest token, your ABRP API key and user token) and `grafana-admin`. +3. **Registry access.** If the `mg4-server` package on git.stinnesbeck.com is private, create the + `git-stinnesbeck-registry` pull secret (command in `secrets.example.yaml`); if it is public, + delete `imagePullSecrets` from `server.yaml`. ## Apply @@ -32,13 +34,12 @@ kubectl apply -f kubectl apply -k k8s/ ``` -The Let's Encrypt HTTP-01 challenge needs car.stinnesbeck.com to resolve to the public -Traefik before the certificate can be issued; check with `kubectl -n mg4 get certificate`. - ## Notes -- InfluxDB's setup mode runs only on an empty volume. Changing `admin-token` in the Secret - later does not change the token inside InfluxDB. -- The server uses the InfluxDB admin token. A token scoped to the `mg4` bucket can replace it - later (`influx auth create --read-bucket … --write-bucket …`). +- InfluxDB's setup mode runs only on an empty volume. Changing `admin-token` in the Secret later + does not change the token inside InfluxDB. +- The server and Grafana use the InfluxDB admin token. A token scoped to the `mg4` bucket (read + for Grafana, read/write for the server) can replace it later. +- The dashboards are read-only in Grafana's UI. Change the JSON in `grafana/dashboards/`; the + ConfigMap name changes with its content, which rolls Grafana. - To upgrade the server, push a new image tag and change `newTag` in `kustomization.yaml`. diff --git a/k8s/grafana.yaml b/k8s/grafana.yaml new file mode 100644 index 0000000..3b8724d --- /dev/null +++ b/k8s/grafana.yaml @@ -0,0 +1,137 @@ +# Grafana for the dashboards. The InfluxDB datasource and the three MG4 dashboards are +# provisioned from k8s/grafana/ (ConfigMaps generated by kustomization.yaml); the dashboards +# are read-only in the UI — change the JSON in git instead. +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: grafana-data + namespace: mg4 + labels: + app.kubernetes.io/name: grafana + app.kubernetes.io/part-of: mg4 +spec: + accessModes: ["ReadWriteOnce"] + # No storageClassName: the cluster's default class is used. + resources: + requests: + storage: 1Gi +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: grafana + namespace: mg4 + labels: + app.kubernetes.io/name: grafana + app.kubernetes.io/part-of: mg4 +spec: + replicas: 1 + strategy: + type: Recreate # the SQLite database on the volume allows one writer + selector: + matchLabels: + app.kubernetes.io/name: grafana + template: + metadata: + labels: + app.kubernetes.io/name: grafana + app.kubernetes.io/part-of: mg4 + spec: + securityContext: + runAsNonRoot: true + runAsUser: 472 + runAsGroup: 472 + fsGroup: 472 + seccompProfile: + type: RuntimeDefault + containers: + - name: grafana + image: docker.io/grafana/grafana:13.2.3 + ports: + - name: http + containerPort: 3000 + env: + - name: GF_SERVER_ROOT_URL + value: https://car-grafana.stinnesbeck.com + - name: GF_SECURITY_ADMIN_USER + value: admin + - name: GF_SECURITY_ADMIN_PASSWORD + valueFrom: + secretKeyRef: + name: grafana-admin + key: admin-password + - name: GF_USERS_ALLOW_SIGN_UP + value: "false" + - name: GF_AUTH_ANONYMOUS_ENABLED + value: "false" + - name: GF_ANALYTICS_REPORTING_ENABLED + value: "false" + - name: GF_ANALYTICS_CHECK_FOR_UPDATES + value: "false" + # Read by datasources.yaml. + - name: INFLUX_URL + value: http://influxdb.mg4.svc:8086 + - name: INFLUX_TOKEN + valueFrom: + secretKeyRef: + name: influxdb-auth + key: admin-token + volumeMounts: + - name: data + mountPath: /var/lib/grafana + - name: datasources + mountPath: /etc/grafana/provisioning/datasources + - name: dashboard-provider + mountPath: /etc/grafana/provisioning/dashboards + - name: dashboards + mountPath: /var/lib/grafana/dashboards/mg4 + readinessProbe: + httpGet: + path: /api/health + port: http + periodSeconds: 10 + livenessProbe: + httpGet: + path: /api/health + port: http + initialDelaySeconds: 30 + periodSeconds: 30 + resources: + requests: + cpu: 50m + memory: 128Mi + limits: + memory: 512Mi + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + volumes: + - name: data + persistentVolumeClaim: + claimName: grafana-data + - name: datasources + configMap: + name: grafana-datasources + - name: dashboard-provider + configMap: + name: grafana-dashboard-provider + - name: dashboards + configMap: + name: grafana-dashboards +--- +apiVersion: v1 +kind: Service +metadata: + name: grafana + namespace: mg4 + labels: + app.kubernetes.io/name: grafana + app.kubernetes.io/part-of: mg4 +spec: + selector: + app.kubernetes.io/name: grafana + ports: + - name: http + port: 80 + targetPort: http diff --git a/k8s/grafana/dashboards.yaml b/k8s/grafana/dashboards.yaml new file mode 100644 index 0000000..de37763 --- /dev/null +++ b/k8s/grafana/dashboards.yaml @@ -0,0 +1,11 @@ +# Loads the dashboard JSON files in the mounted folder into a "MG4" folder. They are +# managed in git: edits in the UI are not saved back, change the JSON instead. +apiVersion: 1 +providers: + - name: mg4 + folder: MG4 + type: file + disableDeletion: true + allowUiUpdates: false + options: + path: /var/lib/grafana/dashboards/mg4 diff --git a/k8s/grafana/dashboards/charging.json b/k8s/grafana/dashboards/charging.json new file mode 100644 index 0000000..28471c1 --- /dev/null +++ b/k8s/grafana/dashboards/charging.json @@ -0,0 +1,517 @@ +{ + "uid": "mg4-charging", + "title": "MG4 — Charging", + "description": "DC charging sessions metered by the car. Prices in euro, as entered in the car's Charging tab.", + "tags": [ + "mg4" + ], + "timezone": "browser", + "editable": false, + "graphTooltip": 1, + "schemaVersion": 39, + "version": 1, + "time": { + "from": "now-90d", + "to": "now" + }, + "refresh": "", + "templating": { + "list": [ + { + "name": "installationId", + "label": "Car", + "type": "query", + "datasource": { + "type": "influxdb", + "uid": "mg4-influxdb" + }, + "query": "import \"influxdata/influxdb/schema\"\nschema.tagValues(bucket: \"mg4\", tag: \"installationId\", start: -400d)", + "definition": "installationId tag values", + "refresh": 1, + "includeAll": false, + "multi": false, + "current": {}, + "hide": 0, + "sort": 1 + } + ] + }, + "annotations": { + "list": [] + }, + "panels": [ + { + "id": 1, + "type": "stat", + "title": "DC sessions", + "datasource": { + "type": "influxdb", + "uid": "mg4-influxdb" + }, + "gridPos": { + "x": 0, + "y": 0, + "w": 6, + "h": 4 + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "influxdb", + "uid": "mg4-influxdb" + }, + "query": "from(bucket: \"mg4\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"charging_session\" and r.installationId == \"${installationId}\" and r._field == \"energyKwh\")\n |> group()\n |> count()" + } + ], + "fieldConfig": { + "defaults": { + "unit": "none", + "color": { + "mode": "fixed", + "fixedColor": "#E0424C" + }, + "decimals": 0 + }, + "overrides": [] + }, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "colorMode": "value", + "graphMode": "none", + "textMode": "value" + } + }, + { + "id": 2, + "type": "stat", + "title": "Drawn from grid", + "datasource": { + "type": "influxdb", + "uid": "mg4-influxdb" + }, + "gridPos": { + "x": 6, + "y": 0, + "w": 6, + "h": 4 + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "influxdb", + "uid": "mg4-influxdb" + }, + "query": "from(bucket: \"mg4\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"charging_session\" and r.installationId == \"${installationId}\" and r._field == \"gridEnergyKwh\")\n |> group()\n |> sum()" + } + ], + "fieldConfig": { + "defaults": { + "unit": "kwatth", + "color": { + "mode": "fixed", + "fixedColor": "#E0424C" + }, + "decimals": 1 + }, + "overrides": [] + }, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "colorMode": "value", + "graphMode": "none", + "textMode": "value" + }, + "description": "Net energy into the battery plus 10 % charging loss." + }, + { + "id": 3, + "type": "stat", + "title": "Paid", + "datasource": { + "type": "influxdb", + "uid": "mg4-influxdb" + }, + "gridPos": { + "x": 12, + "y": 0, + "w": 6, + "h": 4 + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "influxdb", + "uid": "mg4-influxdb" + }, + "query": "from(bucket: \"mg4\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"charging_session\" and r.installationId == \"${installationId}\" and r._field == \"totalCost\")\n |> group()\n |> sum()" + } + ], + "fieldConfig": { + "defaults": { + "unit": "currencyEUR", + "color": { + "mode": "fixed", + "fixedColor": "#E0424C" + }, + "decimals": 2 + }, + "overrides": [] + }, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "colorMode": "value", + "graphMode": "none", + "textMode": "value" + } + }, + { + "id": 4, + "type": "stat", + "title": "Peak power", + "datasource": { + "type": "influxdb", + "uid": "mg4-influxdb" + }, + "gridPos": { + "x": 18, + "y": 0, + "w": 6, + "h": 4 + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "influxdb", + "uid": "mg4-influxdb" + }, + "query": "from(bucket: \"mg4\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"charging_curve\" and r.installationId == \"${installationId}\" and r._field == \"powerKw\")\n |> group()\n |> max()\n |> keep(columns: [\"_value\"])" + } + ], + "fieldConfig": { + "defaults": { + "unit": "kwatt", + "color": { + "mode": "fixed", + "fixedColor": "#E0424C" + }, + "decimals": 0 + }, + "overrides": [] + }, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "colorMode": "value", + "graphMode": "none", + "textMode": "value" + } + }, + { + "id": 5, + "type": "table", + "title": "Sessions", + "datasource": { + "type": "influxdb", + "uid": "mg4-influxdb" + }, + "gridPos": { + "x": 0, + "y": 4, + "w": 24, + "h": 9 + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "influxdb", + "uid": "mg4-influxdb" + }, + "query": "from(bucket: \"mg4\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"charging_session\" and r.installationId == \"${installationId}\")\n |> pivot(rowKey: [\"_time\"], columnKey: [\"_field\"], valueColumn: \"_value\")\n |> group()\n |> map(fn: (r) => ({r with gained: if exists r.endSocPercent and exists r.startSocPercent then r.endSocPercent - r.startSocPercent else 0.0}))\n |> keep(columns: [\"_time\", \"startSocPercent\", \"endSocPercent\", \"gained\", \"energyKwh\", \"gridEnergyKwh\", \"durationSeconds\", \"pricePerKwh\", \"totalCost\"])\n |> sort(columns: [\"_time\"], desc: true)" + } + ], + "fieldConfig": { + "defaults": {}, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "_time" + }, + "properties": [ + { + "id": "displayName", + "value": "Start" + }, + { + "id": "unit", + "value": "dateTimeAsIso" + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "startSocPercent|endSocPercent|gained" + }, + "properties": [ + { + "id": "unit", + "value": "percent" + }, + { + "id": "decimals", + "value": 0 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "startSocPercent" + }, + "properties": [ + { + "id": "displayName", + "value": "SoC start" + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "endSocPercent" + }, + "properties": [ + { + "id": "displayName", + "value": "SoC end" + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "gained" + }, + "properties": [ + { + "id": "displayName", + "value": "Charge added" + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "energyKwh" + }, + "properties": [ + { + "id": "displayName", + "value": "Net into battery" + }, + { + "id": "unit", + "value": "kwatth" + }, + { + "id": "decimals", + "value": 2 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "gridEnergyKwh" + }, + "properties": [ + { + "id": "displayName", + "value": "From grid (+10%)" + }, + { + "id": "unit", + "value": "kwatth" + }, + { + "id": "decimals", + "value": 2 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "durationSeconds" + }, + "properties": [ + { + "id": "displayName", + "value": "Duration" + }, + { + "id": "unit", + "value": "dtdurations" + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "pricePerKwh" + }, + "properties": [ + { + "id": "displayName", + "value": "Price" + }, + { + "id": "unit", + "value": "currencyEUR" + }, + { + "id": "decimals", + "value": 2 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "totalCost" + }, + "properties": [ + { + "id": "displayName", + "value": "Cost" + }, + { + "id": "unit", + "value": "currencyEUR" + }, + { + "id": "decimals", + "value": 2 + } + ] + } + ] + }, + "options": { + "showHeader": true, + "cellHeight": "sm" + }, + "transformations": [ + { + "id": "organize", + "options": { + "indexByName": { + "_time": 0, + "durationSeconds": 1, + "startSocPercent": 2, + "endSocPercent": 3, + "gained": 4, + "energyKwh": 5, + "gridEnergyKwh": 6, + "pricePerKwh": 7, + "totalCost": 8 + } + } + } + ] + }, + { + "id": 6, + "type": "timeseries", + "title": "Charging curves", + "datasource": { + "type": "influxdb", + "uid": "mg4-influxdb" + }, + "gridPos": { + "x": 0, + "y": 13, + "w": 24, + "h": 9 + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "influxdb", + "uid": "mg4-influxdb" + }, + "query": "from(bucket: \"mg4\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"charging_curve\" and r.installationId == \"${installationId}\" and (r._field == \"powerKw\" or r._field == \"socPercent\"))\n |> group(columns: [\"_field\"])\n |> keep(columns: [\"_time\", \"_field\", \"_value\"])\n |> sort(columns: [\"_time\"])" + } + ], + "fieldConfig": { + "defaults": { + "unit": "kwatt", + "custom": { + "lineWidth": 2, + "fillOpacity": 8, + "spanNulls": 600000, + "showPoints": "never" + } + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": ".*socPercent.*" + }, + "properties": [ + { + "id": "unit", + "value": "percent" + }, + { + "id": "custom.axisPlacement", + "value": "right" + } + ] + } + ] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi" + } + }, + "description": "Only DC charges above 10 kW are metered. Pick a time range around a session to see its curve." + } + ] +} diff --git a/k8s/grafana/dashboards/consumption.json b/k8s/grafana/dashboards/consumption.json new file mode 100644 index 0000000..99b2078 --- /dev/null +++ b/k8s/grafana/dashboards/consumption.json @@ -0,0 +1,415 @@ +{ + "uid": "mg4-consumption", + "title": "MG4 — Consumption", + "description": "Daily consumption, summed from the chunks the car sends while driving.", + "tags": [ + "mg4" + ], + "timezone": "browser", + "editable": false, + "graphTooltip": 1, + "schemaVersion": 39, + "version": 1, + "time": { + "from": "now-30d", + "to": "now" + }, + "refresh": "", + "templating": { + "list": [ + { + "name": "installationId", + "label": "Car", + "type": "query", + "datasource": { + "type": "influxdb", + "uid": "mg4-influxdb" + }, + "query": "import \"influxdata/influxdb/schema\"\nschema.tagValues(bucket: \"mg4\", tag: \"installationId\", start: -400d)", + "definition": "installationId tag values", + "refresh": 1, + "includeAll": false, + "multi": false, + "current": {}, + "hide": 0, + "sort": 1 + } + ] + }, + "annotations": { + "list": [] + }, + "panels": [ + { + "id": 1, + "type": "stat", + "title": "Distance", + "datasource": { + "type": "influxdb", + "uid": "mg4-influxdb" + }, + "gridPos": { + "x": 0, + "y": 0, + "w": 5, + "h": 4 + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "influxdb", + "uid": "mg4-influxdb" + }, + "query": "from(bucket: \"mg4\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"consumption_chunk\" and r.installationId == \"${installationId}\" and r._field == \"km\")\n |> group()\n |> sum()" + } + ], + "fieldConfig": { + "defaults": { + "unit": "suffix: km", + "color": { + "mode": "fixed", + "fixedColor": "#E0424C" + }, + "decimals": 1 + }, + "overrides": [] + }, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "colorMode": "value", + "graphMode": "none", + "textMode": "value" + } + }, + { + "id": 2, + "type": "stat", + "title": "Energy used", + "datasource": { + "type": "influxdb", + "uid": "mg4-influxdb" + }, + "gridPos": { + "x": 5, + "y": 0, + "w": 5, + "h": 4 + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "influxdb", + "uid": "mg4-influxdb" + }, + "query": "from(bucket: \"mg4\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"consumption_chunk\" and r.installationId == \"${installationId}\" and r._field == \"kwh\")\n |> group()\n |> sum()" + } + ], + "fieldConfig": { + "defaults": { + "unit": "kwatth", + "color": { + "mode": "fixed", + "fixedColor": "#E0424C" + }, + "decimals": 2 + }, + "overrides": [] + }, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "colorMode": "value", + "graphMode": "none", + "textMode": "value" + } + }, + { + "id": 3, + "type": "stat", + "title": "Average", + "datasource": { + "type": "influxdb", + "uid": "mg4-influxdb" + }, + "gridPos": { + "x": 10, + "y": 0, + "w": 5, + "h": 4 + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "influxdb", + "uid": "mg4-influxdb" + }, + "query": "from(bucket: \"mg4\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"consumption_chunk\" and r.installationId == \"${installationId}\" and (r._field == \"km\" or r._field == \"kwh\"))\n |> group(columns: [\"_field\"])\n |> sum()\n |> group()\n |> pivot(rowKey: [], columnKey: [\"_field\"], valueColumn: \"_value\")\n |> map(fn: (r) => ({efficiency: if r.km >= 1.0 then r.kwh * 100.0 / r.km else 0.0}))" + } + ], + "fieldConfig": { + "defaults": { + "unit": "suffix: kWh/100 km", + "color": { + "mode": "fixed", + "fixedColor": "#E0424C" + }, + "decimals": 1 + }, + "overrides": [] + }, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "colorMode": "value", + "graphMode": "none", + "textMode": "value" + }, + "description": "kWh/100 km over the selected range." + }, + { + "id": 4, + "type": "stat", + "title": "Lifetime distance", + "datasource": { + "type": "influxdb", + "uid": "mg4-influxdb" + }, + "gridPos": { + "x": 15, + "y": 0, + "w": 5, + "h": 4 + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "influxdb", + "uid": "mg4-influxdb" + }, + "query": "from(bucket: \"mg4\")\n |> range(start: 0)\n |> filter(fn: (r) => r._measurement == \"consumption_chunk\" and r.installationId == \"${installationId}\" and r._field == \"km\")\n |> group()\n |> sum()" + } + ], + "fieldConfig": { + "defaults": { + "unit": "suffix: km", + "color": { + "mode": "fixed", + "fixedColor": "#E0424C" + }, + "decimals": 0 + }, + "overrides": [] + }, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "colorMode": "value", + "graphMode": "none", + "textMode": "value" + } + }, + { + "id": 5, + "type": "stat", + "title": "Battery health (SoH)", + "datasource": { + "type": "influxdb", + "uid": "mg4-influxdb" + }, + "gridPos": { + "x": 20, + "y": 0, + "w": 4, + "h": 4 + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "influxdb", + "uid": "mg4-influxdb" + }, + "query": "from(bucket: \"mg4\")\n |> range(start: 0)\n |> filter(fn: (r) => r._measurement == \"consumption_chunk\" and r.installationId == \"${installationId}\" and r._field == \"sohPercent\")\n |> group()\n |> last()\n |> keep(columns: [\"_value\"])" + } + ], + "fieldConfig": { + "defaults": { + "unit": "percent", + "color": { + "mode": "fixed", + "fixedColor": "#E0424C" + }, + "decimals": 1 + }, + "overrides": [] + }, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "colorMode": "value", + "graphMode": "none", + "textMode": "value" + }, + "description": "The manual SoH entered in the car's Consumption tab." + }, + { + "id": 6, + "type": "barchart", + "title": "Energy per day", + "datasource": { + "type": "influxdb", + "uid": "mg4-influxdb" + }, + "gridPos": { + "x": 0, + "y": 4, + "w": 12, + "h": 9 + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "influxdb", + "uid": "mg4-influxdb" + }, + "query": "from(bucket: \"mg4\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"consumption_chunk\" and r.installationId == \"${installationId}\" and r._field == \"kwh\")\n |> group(columns: [\"date\"])\n |> sum()\n |> group()\n |> map(fn: (r) => ({date: r.date, kwh: r._value}))\n |> sort(columns: [\"date\"])" + } + ], + "fieldConfig": { + "defaults": { + "unit": "kwatth", + "color": { + "mode": "fixed", + "fixedColor": "#E0424C" + } + }, + "overrides": [] + }, + "options": { + "xField": "date", + "showValue": "never", + "legend": { + "showLegend": false + }, + "xTickLabelRotation": -45 + } + }, + { + "id": 7, + "type": "barchart", + "title": "Distance per day", + "datasource": { + "type": "influxdb", + "uid": "mg4-influxdb" + }, + "gridPos": { + "x": 12, + "y": 4, + "w": 12, + "h": 9 + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "influxdb", + "uid": "mg4-influxdb" + }, + "query": "from(bucket: \"mg4\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"consumption_chunk\" and r.installationId == \"${installationId}\" and r._field == \"km\")\n |> group(columns: [\"date\"])\n |> sum()\n |> group()\n |> map(fn: (r) => ({date: r.date, km: r._value}))\n |> sort(columns: [\"date\"])" + } + ], + "fieldConfig": { + "defaults": { + "unit": "suffix: km", + "color": { + "mode": "fixed", + "fixedColor": "#E0424C" + } + }, + "overrides": [] + }, + "options": { + "xField": "date", + "showValue": "never", + "legend": { + "showLegend": false + }, + "xTickLabelRotation": -45 + } + }, + { + "id": 8, + "type": "barchart", + "title": "Efficiency per day (kWh/100 km)", + "datasource": { + "type": "influxdb", + "uid": "mg4-influxdb" + }, + "gridPos": { + "x": 0, + "y": 13, + "w": 24, + "h": 8 + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "influxdb", + "uid": "mg4-influxdb" + }, + "query": "from(bucket: \"mg4\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"consumption_chunk\" and r.installationId == \"${installationId}\" and (r._field == \"km\" or r._field == \"kwh\"))\n |> group(columns: [\"date\", \"_field\"])\n |> sum()\n |> group()\n |> pivot(rowKey: [\"date\"], columnKey: [\"_field\"], valueColumn: \"_value\")\n |> filter(fn: (r) => r.km >= 1.0)\n |> map(fn: (r) => ({date: r.date, efficiency: r.kwh * 100.0 / r.km}))\n |> sort(columns: [\"date\"])" + } + ], + "fieldConfig": { + "defaults": { + "unit": "suffix: kWh/100 km", + "color": { + "mode": "fixed", + "fixedColor": "#E0424C" + } + }, + "overrides": [] + }, + "options": { + "xField": "date", + "showValue": "never", + "legend": { + "showLegend": false + }, + "xTickLabelRotation": -45 + } + } + ] +} diff --git a/k8s/grafana/dashboards/live.json b/k8s/grafana/dashboards/live.json new file mode 100644 index 0000000..77dda27 --- /dev/null +++ b/k8s/grafana/dashboards/live.json @@ -0,0 +1,619 @@ +{ + "uid": "mg4-live", + "title": "MG4 — Live", + "description": "Live telemetry from the car, as stored before it is forwarded to ABRP.", + "tags": [ + "mg4" + ], + "timezone": "browser", + "editable": false, + "graphTooltip": 1, + "schemaVersion": 39, + "version": 1, + "time": { + "from": "now-24h", + "to": "now" + }, + "refresh": "30s", + "templating": { + "list": [ + { + "name": "installationId", + "label": "Car", + "type": "query", + "datasource": { + "type": "influxdb", + "uid": "mg4-influxdb" + }, + "query": "import \"influxdata/influxdb/schema\"\nschema.tagValues(bucket: \"mg4\", tag: \"installationId\", start: -400d)", + "definition": "installationId tag values", + "refresh": 1, + "includeAll": false, + "multi": false, + "current": {}, + "hide": 0, + "sort": 1 + } + ] + }, + "annotations": { + "list": [] + }, + "panels": [ + { + "id": 1, + "type": "stat", + "title": "State of charge", + "datasource": { + "type": "influxdb", + "uid": "mg4-influxdb" + }, + "gridPos": { + "x": 0, + "y": 0, + "w": 4, + "h": 4 + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "influxdb", + "uid": "mg4-influxdb" + }, + "query": "from(bucket: \"mg4\")\n |> range(start: -30d)\n |> filter(fn: (r) => r._measurement == \"telemetry\" and r.installationId == \"${installationId}\" and r._field == \"soc\")\n |> last()" + } + ], + "fieldConfig": { + "defaults": { + "unit": "percent", + "color": { + "mode": "fixed", + "fixedColor": "#E0424C" + }, + "decimals": 0 + }, + "overrides": [] + }, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "colorMode": "value", + "graphMode": "none", + "textMode": "value" + } + }, + { + "id": 2, + "type": "stat", + "title": "Range", + "datasource": { + "type": "influxdb", + "uid": "mg4-influxdb" + }, + "gridPos": { + "x": 4, + "y": 0, + "w": 4, + "h": 4 + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "influxdb", + "uid": "mg4-influxdb" + }, + "query": "from(bucket: \"mg4\")\n |> range(start: -30d)\n |> filter(fn: (r) => r._measurement == \"telemetry\" and r.installationId == \"${installationId}\" and r._field == \"est_battery_range\")\n |> last()" + } + ], + "fieldConfig": { + "defaults": { + "unit": "suffix: km", + "color": { + "mode": "fixed", + "fixedColor": "#E0424C" + }, + "decimals": 0 + }, + "overrides": [] + }, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "colorMode": "value", + "graphMode": "none", + "textMode": "value" + } + }, + { + "id": 3, + "type": "stat", + "title": "Odometer", + "datasource": { + "type": "influxdb", + "uid": "mg4-influxdb" + }, + "gridPos": { + "x": 8, + "y": 0, + "w": 4, + "h": 4 + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "influxdb", + "uid": "mg4-influxdb" + }, + "query": "from(bucket: \"mg4\")\n |> range(start: -30d)\n |> filter(fn: (r) => r._measurement == \"telemetry\" and r.installationId == \"${installationId}\" and r._field == \"odometer\")\n |> last()" + } + ], + "fieldConfig": { + "defaults": { + "unit": "suffix: km", + "color": { + "mode": "fixed", + "fixedColor": "#E0424C" + }, + "decimals": 0 + }, + "overrides": [] + }, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "colorMode": "value", + "graphMode": "none", + "textMode": "value" + } + }, + { + "id": 4, + "type": "stat", + "title": "Battery temperature", + "datasource": { + "type": "influxdb", + "uid": "mg4-influxdb" + }, + "gridPos": { + "x": 12, + "y": 0, + "w": 4, + "h": 4 + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "influxdb", + "uid": "mg4-influxdb" + }, + "query": "from(bucket: \"mg4\")\n |> range(start: -30d)\n |> filter(fn: (r) => r._measurement == \"telemetry\" and r.installationId == \"${installationId}\" and r._field == \"batt_temp\")\n |> last()" + } + ], + "fieldConfig": { + "defaults": { + "unit": "celsius", + "color": { + "mode": "fixed", + "fixedColor": "#E0424C" + }, + "decimals": 0 + }, + "overrides": [] + }, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "colorMode": "value", + "graphMode": "none", + "textMode": "value" + } + }, + { + "id": 5, + "type": "stat", + "title": "Outside temperature", + "datasource": { + "type": "influxdb", + "uid": "mg4-influxdb" + }, + "gridPos": { + "x": 16, + "y": 0, + "w": 4, + "h": 4 + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "influxdb", + "uid": "mg4-influxdb" + }, + "query": "from(bucket: \"mg4\")\n |> range(start: -30d)\n |> filter(fn: (r) => r._measurement == \"telemetry\" and r.installationId == \"${installationId}\" and r._field == \"ext_temp\")\n |> last()" + } + ], + "fieldConfig": { + "defaults": { + "unit": "celsius", + "color": { + "mode": "fixed", + "fixedColor": "#E0424C" + }, + "decimals": 0 + }, + "overrides": [] + }, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "colorMode": "value", + "graphMode": "none", + "textMode": "value" + } + }, + { + "id": 6, + "type": "stat", + "title": "Energy in pack", + "datasource": { + "type": "influxdb", + "uid": "mg4-influxdb" + }, + "gridPos": { + "x": 20, + "y": 0, + "w": 4, + "h": 4 + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "influxdb", + "uid": "mg4-influxdb" + }, + "query": "from(bucket: \"mg4\")\n |> range(start: -30d)\n |> filter(fn: (r) => r._measurement == \"telemetry\" and r.installationId == \"${installationId}\" and r._field == \"soe\")\n |> last()" + } + ], + "fieldConfig": { + "defaults": { + "unit": "kwatth", + "color": { + "mode": "fixed", + "fixedColor": "#E0424C" + }, + "decimals": 1 + }, + "overrides": [] + }, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "colorMode": "value", + "graphMode": "none", + "textMode": "value" + } + }, + { + "id": 7, + "type": "timeseries", + "title": "State of charge and range", + "datasource": { + "type": "influxdb", + "uid": "mg4-influxdb" + }, + "gridPos": { + "x": 0, + "y": 4, + "w": 12, + "h": 8 + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "influxdb", + "uid": "mg4-influxdb" + }, + "query": "from(bucket: \"mg4\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"telemetry\" and r.installationId == \"${installationId}\" and (r._field == \"soc\" or r._field == \"est_battery_range\"))\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n |> keep(columns: [\"_time\", \"_field\", \"_value\"])" + } + ], + "fieldConfig": { + "defaults": { + "unit": "percent", + "custom": { + "lineWidth": 2, + "fillOpacity": 8, + "spanNulls": 600000, + "showPoints": "never" + } + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": ".*est_battery_range.*" + }, + "properties": [ + { + "id": "unit", + "value": "suffix: km" + }, + { + "id": "custom.axisPlacement", + "value": "right" + } + ] + } + ] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi" + } + } + }, + { + "id": 8, + "type": "timeseries", + "title": "Power and speed", + "datasource": { + "type": "influxdb", + "uid": "mg4-influxdb" + }, + "gridPos": { + "x": 12, + "y": 4, + "w": 12, + "h": 8 + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "influxdb", + "uid": "mg4-influxdb" + }, + "query": "from(bucket: \"mg4\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"telemetry\" and r.installationId == \"${installationId}\" and (r._field == \"power\" or r._field == \"speed\"))\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n |> keep(columns: [\"_time\", \"_field\", \"_value\"])" + } + ], + "fieldConfig": { + "defaults": { + "unit": "kwatt", + "custom": { + "lineWidth": 2, + "fillOpacity": 8, + "spanNulls": 600000, + "showPoints": "never" + } + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": ".*speed.*" + }, + "properties": [ + { + "id": "unit", + "value": "velocitykmh" + }, + { + "id": "custom.axisPlacement", + "value": "right" + } + ] + } + ] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi" + } + }, + "description": "Power is positive leaving the battery, negative while charging or regenerating." + }, + { + "id": 9, + "type": "geomap", + "title": "Where the car was", + "datasource": { + "type": "influxdb", + "uid": "mg4-influxdb" + }, + "gridPos": { + "x": 0, + "y": 12, + "w": 12, + "h": 12 + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "influxdb", + "uid": "mg4-influxdb" + }, + "query": "from(bucket: \"mg4\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"telemetry\" and r.installationId == \"${installationId}\" and (r._field == \"lat\" or r._field == \"lon\"))\n |> pivot(rowKey: [\"_time\"], columnKey: [\"_field\"], valueColumn: \"_value\")\n |> keep(columns: [\"_time\", \"lat\", \"lon\"])\n |> sort(columns: [\"_time\"])" + } + ], + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "options": { + "view": { + "id": "fit", + "lat": 0, + "lon": 0, + "zoom": 6, + "allLayers": true + }, + "controls": { + "showZoom": true, + "mouseWheelZoom": true + }, + "basemap": { + "type": "default", + "name": "Basemap" + }, + "layers": [ + { + "type": "route", + "name": "Drive", + "config": { + "style": { + "color": { + "fixed": "#E0424C" + }, + "size": { + "fixed": 3 + }, + "opacity": 0.9 + } + }, + "location": { + "mode": "coords", + "latitude": "lat", + "longitude": "lon" + } + } + ] + } + }, + { + "id": 10, + "type": "timeseries", + "title": "Temperatures", + "datasource": { + "type": "influxdb", + "uid": "mg4-influxdb" + }, + "gridPos": { + "x": 12, + "y": 12, + "w": 12, + "h": 6 + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "influxdb", + "uid": "mg4-influxdb" + }, + "query": "from(bucket: \"mg4\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"telemetry\" and r.installationId == \"${installationId}\" and (r._field == \"batt_temp\" or r._field == \"cabin_temp\" or r._field == \"ext_temp\" or r._field == \"hvac_setpoint\"))\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n |> keep(columns: [\"_time\", \"_field\", \"_value\"])" + } + ], + "fieldConfig": { + "defaults": { + "unit": "celsius", + "custom": { + "lineWidth": 2, + "fillOpacity": 8, + "spanNulls": 600000, + "showPoints": "never" + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi" + } + } + }, + { + "id": 11, + "type": "timeseries", + "title": "Tire pressures", + "datasource": { + "type": "influxdb", + "uid": "mg4-influxdb" + }, + "gridPos": { + "x": 12, + "y": 18, + "w": 12, + "h": 6 + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "influxdb", + "uid": "mg4-influxdb" + }, + "query": "from(bucket: \"mg4\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"telemetry\" and r.installationId == \"${installationId}\" and (r._field == \"tire_pressure_fl\" or r._field == \"tire_pressure_fr\" or r._field == \"tire_pressure_rl\" or r._field == \"tire_pressure_rr\"))\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n |> keep(columns: [\"_time\", \"_field\", \"_value\"])" + } + ], + "fieldConfig": { + "defaults": { + "unit": "pressurekpa", + "custom": { + "lineWidth": 2, + "fillOpacity": 8, + "spanNulls": 600000, + "showPoints": "never" + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi" + } + } + } + ] +} diff --git a/k8s/grafana/datasources.yaml b/k8s/grafana/datasources.yaml new file mode 100644 index 0000000..5d83a9a --- /dev/null +++ b/k8s/grafana/datasources.yaml @@ -0,0 +1,17 @@ +# Provisioned InfluxDB datasource. URL and token come from the Grafana container's +# environment (INFLUX_URL, INFLUX_TOKEN), so no credential is stored in this file. +apiVersion: 1 +datasources: + - name: InfluxDB (mg4) + uid: mg4-influxdb + type: influxdb + access: proxy + url: $INFLUX_URL + isDefault: true + editable: false + jsonData: + version: Flux + organization: mg4 + defaultBucket: mg4 + secureJsonData: + token: $INFLUX_TOKEN diff --git a/k8s/influxdb.yaml b/k8s/influxdb.yaml index 72c2555..03247a5 100644 --- a/k8s/influxdb.yaml +++ b/k8s/influxdb.yaml @@ -110,11 +110,12 @@ spec: port: 8086 targetPort: http --- -# Only the server may reach InfluxDB. Has no effect on a CNI without NetworkPolicy support. +# Only the server and Grafana may reach InfluxDB. Has no effect on a CNI without +# NetworkPolicy support. apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: - name: influxdb-from-server-only + name: influxdb-from-server-and-grafana namespace: mg4 spec: podSelector: @@ -126,5 +127,8 @@ spec: - podSelector: matchLabels: app.kubernetes.io/name: mg4-server + - podSelector: + matchLabels: + app.kubernetes.io/name: grafana ports: - port: 8086 diff --git a/k8s/ingress.yaml b/k8s/ingress.yaml index ff60931..1e2fbf5 100644 --- a/k8s/ingress.yaml +++ b/k8s/ingress.yaml @@ -1,18 +1,5 @@ -# TLS certificate from cert-manager. cert-manager does not watch Traefik IngressRoutes, so the -# certificate is requested explicitly and the IngressRoute references its Secret. -apiVersion: cert-manager.io/v1 -kind: Certificate -metadata: - name: car-stinnesbeck-com - namespace: mg4 -spec: - secretName: car-stinnesbeck-com-tls - dnsNames: - - car.stinnesbeck.com - issuerRef: - kind: ClusterIssuer - name: letsencrypt-prod ---- +# Public routes. TLS uses Traefik's default certificate, so there is no per-host +# Certificate here. apiVersion: traefik.io/v1alpha1 kind: IngressRoute metadata: @@ -20,7 +7,7 @@ metadata: namespace: mg4 annotations: # CHECK: the class of your public-facing Traefik. Your internal example uses - # "traefik-internal"; this host must be served by the external one. + # "traefik-internal"; these hosts must be served by the external one. kubernetes.io/ingress.class: traefik-external spec: entryPoints: @@ -33,5 +20,25 @@ spec: name: mg4-server namespace: mg4 port: 80 - tls: - secretName: car-stinnesbeck-com-tls + tls: {} +--- +apiVersion: traefik.io/v1alpha1 +kind: IngressRoute +metadata: + name: grafana + namespace: mg4 + annotations: + kubernetes.io/ingress.class: traefik-external # CHECK, as above +spec: + entryPoints: + - websecure + routes: + - kind: Rule + # CHECK: placeholder host. It must be covered by Traefik's default certificate. + match: Host(`car-grafana.stinnesbeck.com`) + services: + - kind: Service + name: grafana + namespace: mg4 + port: 80 + tls: {} diff --git a/k8s/kustomization.yaml b/k8s/kustomization.yaml index 1acf22d..0a643ee 100644 --- a/k8s/kustomization.yaml +++ b/k8s/kustomization.yaml @@ -6,7 +6,26 @@ resources: - namespace.yaml - influxdb.yaml - server.yaml + - grafana.yaml - ingress.yaml images: - name: git.stinnesbeck.com/nils/mg4-server - newTag: "1.1.2" + newTag: "2.0.0" +generatorOptions: + labels: + app.kubernetes.io/part-of: mg4 +configMapGenerator: + - name: grafana-datasources + namespace: mg4 + files: + - mg4.yaml=grafana/datasources.yaml + - name: grafana-dashboard-provider + namespace: mg4 + files: + - mg4.yaml=grafana/dashboards.yaml + - name: grafana-dashboards + namespace: mg4 + files: + - grafana/dashboards/live.json + - grafana/dashboards/consumption.json + - grafana/dashboards/charging.json diff --git a/k8s/secrets.example.yaml b/k8s/secrets.example.yaml index 9b7dd47..cc316cd 100644 --- a/k8s/secrets.example.yaml +++ b/k8s/secrets.example.yaml @@ -1,7 +1,7 @@ # TEMPLATE ONLY — not part of kustomization.yaml. Copy it outside the repository, fill in # real values, and apply that copy yourself. Never commit the filled-in file. # -# openssl rand -hex 32 for every token and secret below +# openssl rand -hex 32 for every token and password below apiVersion: v1 kind: Secret metadata: @@ -19,15 +19,25 @@ metadata: namespace: mg4 type: Opaque stringData: - # The car's write token, entered in the app. At least 24 characters. + # The car's write token, entered in the app. At least 24 characters, or the server + # refuses to start. INGEST_TOKEN: CHANGE-ME-openssl-rand-hex-32 - SESSION_SECRET: CHANGE-ME-openssl-rand-hex-32 - DASHBOARD_PASSWORD: CHANGE-ME-at-least-8-characters # Your ABRP credentials. Leave either empty to switch forwarding off. ABRP_API_KEY: CHANGE-ME ABRP_USER_TOKEN: CHANGE-ME ABRP_MIN_INTERVAL_SECONDS: "5" --- +apiVersion: v1 +kind: Secret +metadata: + name: grafana-admin + namespace: mg4 +type: Opaque +stringData: + # Grafana's admin login (user "admin"). Only read on first start: change it in Grafana + # afterwards, or reset it with grafana cli. + admin-password: CHANGE-ME +--- # Pull secret for a private image on git.stinnesbeck.com. Easier to create with: # kubectl -n mg4 create secret docker-registry git-stinnesbeck-registry \ # --docker-server=git.stinnesbeck.com --docker-username= --docker-password= diff --git a/k8s/server.yaml b/k8s/server.yaml index 01c7039..e004ef8 100644 --- a/k8s/server.yaml +++ b/k8s/server.yaml @@ -1,5 +1,5 @@ -# The car server. One replica on purpose: the ABRP rate limit is kept in memory, and two -# replicas would each forward to ABRP at the full rate. +# The car server (Go). One replica on purpose: the ABRP rate limit is kept in memory, and +# two replicas would each forward to ABRP at the full rate. apiVersion: apps/v1 kind: Deployment metadata: @@ -26,8 +26,8 @@ spec: - name: git-stinnesbeck-registry securityContext: runAsNonRoot: true - runAsUser: 10001 - runAsGroup: 10001 + runAsUser: 65532 + runAsGroup: 65532 seccompProfile: type: RuntimeDefault containers: @@ -35,7 +35,7 @@ spec: image: git.stinnesbeck.com/nils/mg4-server:latest # pinned by kustomization.yaml ports: - name: http - containerPort: 3000 + containerPort: 8080 env: - name: INFLUX_URL value: http://influxdb.mg4.svc:8086 @@ -48,7 +48,7 @@ spec: secretKeyRef: name: influxdb-auth key: admin-token - # INGEST_TOKEN, SESSION_SECRET, DASHBOARD_PASSWORD, ABRP_API_KEY, ABRP_USER_TOKEN. + # INGEST_TOKEN, ABRP_API_KEY, ABRP_USER_TOKEN, ABRP_MIN_INTERVAL_SECONDS. envFrom: - secretRef: name: mg4-server @@ -61,30 +61,19 @@ spec: httpGet: path: /v1/health port: http - initialDelaySeconds: 10 + initialDelaySeconds: 5 periodSeconds: 30 resources: requests: - cpu: 50m - memory: 128Mi + cpu: 10m + memory: 32Mi limits: - memory: 512Mi + memory: 128Mi securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: true capabilities: drop: ["ALL"] - volumeMounts: - # Next.js writes its fetch/image cache here; the rest of the filesystem is read-only. - - name: next-cache - mountPath: /app/.next/cache - - name: tmp - mountPath: /tmp - volumes: - - name: next-cache - emptyDir: {} - - name: tmp - emptyDir: {} --- apiVersion: v1 kind: Service From e874578e5466e7b3123be658ecd3b43d68becf0f Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Sat, 17 Oct 2026 15:45:18 +0200 Subject: [PATCH 20/79] Replace the turquoise with MG red tones ev_cyan becomes a light red (#FF9A9E, for values and secondary highlights, readable on black) and ev_cyan_deep a deep red (#3A1215, version badge). Turquoise hardcoded in the battery card, switch track, input panel, car outline, background glow and charging-graph grid is replaced with matching red tones, as are two gold leftovers (tab outline, second background glow). The mint "OK" status colour and the amber charging curve are unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki --- AGENTS.md | 2 +- app/src/main/java/com/evsuite/abrp/ChargingGraphView.java | 2 +- .../main/java/com/evsuite/abrp/PremiumBackdropView.java | 4 ++-- app/src/main/res/color/nav_tab_stroke.xml | 2 +- app/src/main/res/color/switch_track.xml | 2 +- app/src/main/res/drawable/bg_battery_card.xml | 2 +- app/src/main/res/drawable/bg_input_panel.xml | 2 +- app/src/main/res/drawable/ic_vehicle_outline.xml | 4 ++-- app/src/main/res/values/colors.xml | 7 ++++--- 9 files changed, 14 insertions(+), 13 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a67abd4..e6ec9a0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ workspace `DESIGN.md` apply; this file contains only telemetry-specific addition ## App-specific visual direction (2.2.6) The user explicitly requested a permanent OLED-black theme, charcoal glass-like cards, -MG red (#E0424C, replacing the earlier muted gold) and cyan accents. These app resources intentionally override the shared +MG red accents (#E0424C, with a light #FF9A9E and a deep #3A1215 variant; they replaced the earlier muted gold and cyan). These app resources intentionally override the shared DESIGN.md palette, DayNight preference and flat-surface rules. Do not overwrite them with the shared token generator. Use static gradients rather than continuous effects; keep large touch targets, readable text and null telemetry shown as a dash. diff --git a/app/src/main/java/com/evsuite/abrp/ChargingGraphView.java b/app/src/main/java/com/evsuite/abrp/ChargingGraphView.java index acd99da..be73a5c 100644 --- a/app/src/main/java/com/evsuite/abrp/ChargingGraphView.java +++ b/app/src/main/java/com/evsuite/abrp/ChargingGraphView.java @@ -14,7 +14,7 @@ public final class ChargingGraphView extends View { private final Paint grid = new Paint(1), line = new Paint(1), text = new Paint(1); private final List points = new ArrayList<>(); private final float[] socPower = new float[101]; - public ChargingGraphView(Context c, AttributeSet a) { super(c,a); java.util.Arrays.fill(socPower, Float.NaN); grid.setColor(0x5548C7D9); line.setColor(0xFFFFC857); line.setStrokeWidth(4); text.setColor(0xFFBDC4C6); text.setTextSize(20); } + public ChargingGraphView(Context c, AttributeSet a) { super(c,a); java.util.Arrays.fill(socPower, Float.NaN); grid.setColor(0x55E0858A); line.setColor(0xFFFFC857); line.setStrokeWidth(4); text.setColor(0xFFBDC4C6); text.setTextSize(20); } synchronized void add(Float soc, Float power) { if (soc == null || power == null || power >= 0) return; float p = Math.abs(power); diff --git a/app/src/main/java/com/evsuite/abrp/PremiumBackdropView.java b/app/src/main/java/com/evsuite/abrp/PremiumBackdropView.java index 7261c20..fa843b1 100644 --- a/app/src/main/java/com/evsuite/abrp/PremiumBackdropView.java +++ b/app/src/main/java/com/evsuite/abrp/PremiumBackdropView.java @@ -21,9 +21,9 @@ public final class PremiumBackdropView extends View { @Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { float radius = Math.max(1, Math.max(w, h) * 0.65f); cyan.setShader(new RadialGradient(w * 0.70f, 0, radius, - new int[]{0x2419565B, 0x00000000}, null, Shader.TileMode.CLAMP)); + new int[]{0x245B191D, 0x00000000}, null, Shader.TileMode.CLAMP)); gold.setShader(new RadialGradient(w, h, radius * 0.65f, - new int[]{0x1C8B693A, 0x00000000}, null, Shader.TileMode.CLAMP)); + new int[]{0x1C8B3A3F, 0x00000000}, null, Shader.TileMode.CLAMP)); } @Override protected void onDraw(Canvas canvas) { diff --git a/app/src/main/res/color/nav_tab_stroke.xml b/app/src/main/res/color/nav_tab_stroke.xml index 62d6c16..9a29683 100644 --- a/app/src/main/res/color/nav_tab_stroke.xml +++ b/app/src/main/res/color/nav_tab_stroke.xml @@ -1 +1 @@ - + diff --git a/app/src/main/res/color/switch_track.xml b/app/src/main/res/color/switch_track.xml index 0154b5b..145e36c 100644 --- a/app/src/main/res/color/switch_track.xml +++ b/app/src/main/res/color/switch_track.xml @@ -1,6 +1,6 @@ - + diff --git a/app/src/main/res/drawable/bg_battery_card.xml b/app/src/main/res/drawable/bg_battery_card.xml index bf08638..2201dc5 100644 --- a/app/src/main/res/drawable/bg_battery_card.xml +++ b/app/src/main/res/drawable/bg_battery_card.xml @@ -1 +1 @@ - + diff --git a/app/src/main/res/drawable/bg_input_panel.xml b/app/src/main/res/drawable/bg_input_panel.xml index 1b78256..e2b1f97 100644 --- a/app/src/main/res/drawable/bg_input_panel.xml +++ b/app/src/main/res/drawable/bg_input_panel.xml @@ -1 +1 @@ - + diff --git a/app/src/main/res/drawable/ic_vehicle_outline.xml b/app/src/main/res/drawable/ic_vehicle_outline.xml index e6178ec..365da1d 100644 --- a/app/src/main/res/drawable/ic_vehicle_outline.xml +++ b/app/src/main/res/drawable/ic_vehicle_outline.xml @@ -1,5 +1,5 @@ - - + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index db6aef8..c5f6be3 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,7 +1,8 @@ + name but is now the dark red tint that goes with it. ev_cyan / ev_cyan_deep likewise keep + their names but are the light and the deep red of the same palette (no turquoise left). --> #000000 #121212 @@ -11,8 +12,8 @@ #BDC4C6 #E0424C #1A0B0C - #A0E2E0 - #103436 + #FF9A9E + #3A1215 #2A1416 #A4DCC1 #E9CA96 From 4fe29189c0871187278046956fe0903c88206ed9 Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Sat, 17 Oct 2026 16:00:29 +0200 Subject: [PATCH 21/79] Pin the pushed server image; drop the pull secret MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mg4-server:2.0.0 is on git.stinnesbeck.com and pulls anonymously, so the Deployment no longer references a registry pull secret. The image is pinned by digest (sha256:6feea777…) so a re-pushed tag cannot change what the cluster runs. README and secrets template updated. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki --- SPECS.md | 3 ++- k8s/README.md | 8 ++++---- k8s/kustomization.yaml | 3 ++- k8s/secrets.example.yaml | 5 ----- k8s/server.yaml | 3 --- 5 files changed, 8 insertions(+), 14 deletions(-) diff --git a/SPECS.md b/SPECS.md index 348eef3..1f77b4e 100644 --- a/SPECS.md +++ b/SPECS.md @@ -37,7 +37,8 @@ car ──live telemetry───▶ car.stinnesbeck.com (Go) ──▶ ABRP ap idempotency, all 25 dashboard queries return data, dashboards rendered and checked. - [x] Manifests validated offline (including Traefik CRDs). **Not applied.** - [x] Car app 2.3.0: unit tests and lint pass; **not yet tried on the car**. -- [ ] Push the image (`mg4-server:2.0.0`) to git.stinnesbeck.com (needs a registry login here). +- [x] Image `mg4-server:2.0.0` pushed to git.stinnesbeck.com (public; pinned by digest + `sha256:6feea777…` in `k8s/kustomization.yaml`). - [ ] Check in `k8s/ingress.yaml`: the Traefik ingress class (placeholder `traefik-external`) and Grafana's host (placeholder `car-grafana.stinnesbeck.com`). - [ ] First drive with 2.3.0: telemetry arrives in InfluxDB, Grafana and ABRP. diff --git a/k8s/README.md b/k8s/README.md index db45c48..83bd3a7 100644 --- a/k8s/README.md +++ b/k8s/README.md @@ -22,9 +22,8 @@ is applied automatically. Apply them yourself. 2. **Create the Secrets.** Copy `secrets.example.yaml` outside the repository, replace every `CHANGE-ME` (`openssl rand -hex 32`), then apply the copy: `influxdb-auth`, `mg4-server` (ingest token, your ABRP API key and user token) and `grafana-admin`. -3. **Registry access.** If the `mg4-server` package on git.stinnesbeck.com is private, create the - `git-stinnesbeck-registry` pull secret (command in `secrets.example.yaml`); if it is public, - delete `imagePullSecrets` from `server.yaml`. +3. **Registry access.** The `mg4-server` package on git.stinnesbeck.com is public, so no pull + secret is needed. If you make it private, add an `imagePullSecrets` entry to `server.yaml`. ## Apply @@ -42,4 +41,5 @@ kubectl apply -k k8s/ for Grafana, read/write for the server) can replace it later. - The dashboards are read-only in Grafana's UI. Change the JSON in `grafana/dashboards/`; the ConfigMap name changes with its content, which rolls Grafana. -- To upgrade the server, push a new image tag and change `newTag` in `kustomization.yaml`. +- The server image is pinned by digest in `kustomization.yaml`. To upgrade, push a new tag and + put its digest there (`skopeo inspect docker://git.stinnesbeck.com/nils/mg4-server:`). diff --git a/k8s/kustomization.yaml b/k8s/kustomization.yaml index 0a643ee..61be4c7 100644 --- a/k8s/kustomization.yaml +++ b/k8s/kustomization.yaml @@ -10,7 +10,8 @@ resources: - ingress.yaml images: - name: git.stinnesbeck.com/nils/mg4-server - newTag: "2.0.0" + # 2.0.0, pinned by digest so a re-pushed tag cannot change what runs. + digest: sha256:6feea777c514b9488ffb0b49fad7c15423ee06f0e0e8d609cf31eb85755da815 generatorOptions: labels: app.kubernetes.io/part-of: mg4 diff --git a/k8s/secrets.example.yaml b/k8s/secrets.example.yaml index cc316cd..a60b124 100644 --- a/k8s/secrets.example.yaml +++ b/k8s/secrets.example.yaml @@ -37,8 +37,3 @@ stringData: # Grafana's admin login (user "admin"). Only read on first start: change it in Grafana # afterwards, or reset it with grafana cli. admin-password: CHANGE-ME ---- -# Pull secret for a private image on git.stinnesbeck.com. Easier to create with: -# kubectl -n mg4 create secret docker-registry git-stinnesbeck-registry \ -# --docker-server=git.stinnesbeck.com --docker-username= --docker-password= -# If the package is public, remove imagePullSecrets from server.yaml instead. diff --git a/k8s/server.yaml b/k8s/server.yaml index e004ef8..1f20507 100644 --- a/k8s/server.yaml +++ b/k8s/server.yaml @@ -21,9 +21,6 @@ spec: app.kubernetes.io/name: mg4-server app.kubernetes.io/part-of: mg4 spec: - # Needed only if the image is private on git.stinnesbeck.com; see secrets.example.yaml. - imagePullSecrets: - - name: git-stinnesbeck-registry securityContext: runAsNonRoot: true runAsUser: 65532 From d5f68e1bd9c98ce95a6c93015abbd3c752cccbd3 Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Sat, 17 Oct 2026 16:16:19 +0200 Subject: [PATCH 22/79] Server 2.1.0: pair cars by QR code instead of typing the token Modelled on the OAuth device authorization grant (RFC 8628) and on only when APPROVAL_PASSWORD is set (at least 12 characters): - POST /v1/pair/start opens a request for an installation and returns a short user code and the /pair URL for the car's QR code; POST /v1/pair/poll returns pending, the car's own token (once), denied or expired. Requests live 10 minutes, in memory. - GET/POST /pair is a script-free approval page for the phone (strict CSP, no framing). Five wrong passwords lock approvals for a minute. - Each car gets its own 43-character token; only its SHA-256 is stored (measurement "device"), and the device is persisted before the car can collect the token. Tokens are loaded at startup, so a restart keeps paired cars working; the server refuses to start if they cannot be loaded. - GET /v1/devices and POST /v1/devices/{id}/revoke, authenticated with the approval password, list and revoke cars. - The shared INGEST_TOKEN keeps working. 31 tests (race detector clean), govulncheck clean. Tested end to end against InfluxDB 2.7.12: pairing, wrong password, single collection, persistence across restarts, revocation (also across a restart), and that InfluxDB holds only the hash. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki --- server/README.md | 27 +++- server/api.go | 21 ++- server/api_test.go | 25 +++- server/main.go | 30 +++- server/pairing.go | 311 +++++++++++++++++++++++++++++++++++++++++ server/pairing_http.go | 294 ++++++++++++++++++++++++++++++++++++++ server/pairing_test.go | 234 +++++++++++++++++++++++++++++++ server/store.go | 38 +++++ 8 files changed, 970 insertions(+), 10 deletions(-) create mode 100644 server/pairing.go create mode 100644 server/pairing_http.go create mode 100644 server/pairing_test.go diff --git a/server/README.md b/server/README.md index 8dfba01..647d2cd 100644 --- a/server/README.md +++ b/server/README.md @@ -22,13 +22,16 @@ Environment only; the server refuses to start when a required value is missing. | `INGEST_TOKEN` | required, at least 24 characters: `openssl rand -hex 32` | | `ABRP_API_KEY`, `ABRP_USER_TOKEN` | optional; both needed to forward | | `ABRP_MIN_INTERVAL_SECONDS` | default 5 | +| `APPROVAL_PASSWORD` | optional, at least 12 characters; switches pairing on (see below) | +| `PUBLIC_URL` | the address a phone uses, for the pairing QR code; derived from the request if empty | | `LISTEN_ADDR` | default `:8080` | The ABRP credentials never reach the car. ## Endpoints -All but `/v1/health` need `Authorization: Bearer `. +The ingest endpoints accept `Authorization: Bearer ` with either the shared `INGEST_TOKEN` +or a paired car's own token. - `POST /v1/telemetry` — `{ installationId, samples: [...] }`, up to 500 samples in ABRP's `tlm` field names and units (`utc` in epoch seconds; flags as 0/1 or booleans). Unknown fields are @@ -41,6 +44,27 @@ All but `/v1/health` need `Authorization: Bearer `. A bad request is a 400 naming the field; a database failure is a 500 without detail. +## Pairing a car by QR code + +So nobody types a token in the car. Modelled on the OAuth device authorization grant (RFC 8628); +on only when `APPROVAL_PASSWORD` is set. + +1. The car calls `POST /v1/pair/start` `{installationId}` and shows the returned + `verificationUriComplete` as a QR code, plus the short `userCode` (e.g. `K7F-3QX`). +2. The phone opens `/pair?code=…`, the owner checks the code and approves with the approval + password. Five wrong passwords lock approvals for a minute. +3. The car polls `POST /v1/pair/poll` `{deviceCode}`: 202 pending, 200 with its own token + (handed out once), 403 denied, 410 expired (after 10 minutes) or unknown. + +Each car gets its own 43-character token; only its SHA-256 is stored (measurement `device` in +the same bucket), and the device is persisted before the car can collect the token, so a +restart never loses a paired car. Manage cars with the approval password: + +```sh +curl -H "Authorization: Bearer $APPROVAL_PASSWORD" https://car.stinnesbeck.com/v1/devices +curl -X POST -H "Authorization: Bearer $APPROVAL_PASSWORD" https://car.stinnesbeck.com/v1/devices//revoke +``` + ## Storage One bucket, `installationId` tag on every point: @@ -51,6 +75,7 @@ One bucket, `installationId` tag on every point: | `consumption_chunk` | `recordedAt` | `date` | km, kwh, hours, socDrop, sohPercent | | `charging_session` | `startedAt` | `sessionId` | endedAtMs, duration, energy, cost, start/end SOC | | `charging_curve` | point time | `sessionId` | powerKw, socPercent | +| `device` | paired at | `deviceId` | tokenHash, installationId, revoked | A point with the same measurement, tags and timestamp replaces the earlier one, so the car's retries are idempotent: a cached chunk is re-sent with its original `recordedAt`. Days and the diff --git a/server/api.go b/server/api.go index 89b8923..ce1390e 100644 --- a/server/api.go +++ b/server/api.go @@ -17,10 +17,17 @@ import ( // PUT /v1/charging-sessions/{sessionId} one finished DC charging session // GET /v1/consumption/history/{installationId} the car reads its own totals back // GET /v1/health unauthenticated liveness probe +// +// A car authenticates with the shared INGEST_TOKEN or with its own token from pairing +// (pairing_http.go). type API struct { Store Store IngestToken string Forwarder *Forwarder + Pairing *Pairing + // PublicURL is the server's address as a phone reaches it, for the pairing QR code; + // derived from the request when empty. + PublicURL string } const maxBodyBytes = 512 * 1024 @@ -34,18 +41,24 @@ func (a *API) Routes() http.Handler { mux.HandleFunc("PUT /v1/consumption/daily", a.ingest(a.consumption)) mux.HandleFunc("PUT /v1/charging-sessions/{sessionId}", a.ingest(a.chargingSession)) mux.HandleFunc("GET /v1/consumption/history/{installationId}", a.ingest(a.history)) + a.pairingRoutes(mux) return mux } -// hasIngestToken compares in constant time. main refuses to start with a token under 24 -// characters, so an empty or short configured token can never match here. +// hasIngestToken accepts the shared token (compared in constant time; main refuses to start +// with one under 24 characters, so an empty or short one never matches) or a paired car's +// own, unrevoked token. func (a *API) hasIngestToken(r *http.Request) bool { header := r.Header.Get("Authorization") supplied, ok := strings.CutPrefix(header, "Bearer ") - if !ok || len(a.IngestToken) < minIngestTokenLength { + if !ok || supplied == "" { return false } - return subtle.ConstantTimeCompare([]byte(supplied), []byte(a.IngestToken)) == 1 + if len(a.IngestToken) >= minIngestTokenLength && + subtle.ConstantTimeCompare([]byte(supplied), []byte(a.IngestToken)) == 1 { + return true + } + return a.Pairing.Authorized(supplied) } type handler func(w http.ResponseWriter, r *http.Request, body []byte) error diff --git a/server/api_test.go b/server/api_test.go index c466fde..c3832ae 100644 --- a/server/api_test.go +++ b/server/api_test.go @@ -16,6 +16,7 @@ type fakeStore struct { telemetry []TelemetrySample chunks []ConsumptionChunk sessions []ChargingSession + devices []Device fail error } @@ -46,17 +47,37 @@ func (f *fakeStore) WriteChargingSession(_ context.Context, s ChargingSession) e return f.fail } +func (f *fakeStore) SaveDevice(_ context.Context, d Device) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.fail != nil { + return f.fail + } + f.devices = append(f.devices, d) + return nil +} + +func (f *fakeStore) LoadDevices(context.Context) ([]Device, error) { + f.mu.Lock() + defer f.mu.Unlock() + return f.devices, f.fail +} + func (f *fakeStore) ReadConsumptionChunks(context.Context, string) ([]ConsumptionChunk, error) { f.mu.Lock() defer f.mu.Unlock() return f.chunks, f.fail } -const testToken = "ingest-test-token-0123456789abcdef" +const ( + testToken = "ingest-test-token-0123456789abcdef" + testApprovalPassword = "approval-password-123" +) func newTestAPI() (*API, *fakeStore) { store := &fakeStore{} - return &API{Store: store, IngestToken: testToken, Forwarder: &Forwarder{}}, store + return &API{Store: store, IngestToken: testToken, Forwarder: &Forwarder{}, + Pairing: NewPairing(testApprovalPassword, nil), PublicURL: "https://car.example.com"}, store } func do(t *testing.T, h http.Handler, method, path, token, body string) *httptest.ResponseRecorder { diff --git a/server/main.go b/server/main.go index 18ca0b6..6bc6eb7 100644 --- a/server/main.go +++ b/server/main.go @@ -16,7 +16,10 @@ import ( "time" ) -const minIngestTokenLength = 24 +const ( + minIngestTokenLength = 24 + minApprovalPasswordLength = 12 +) type config struct { listenAddr string @@ -28,6 +31,8 @@ type config struct { abrpAPIKey string abrpUserToken string abrpMinInterval time.Duration + approvalPass string + publicURL string } // loadConfig reads the environment and fails closed: a missing InfluxDB setting or a @@ -42,6 +47,8 @@ func loadConfig(getenv func(string) string) (config, error) { ingestToken: getenv("INGEST_TOKEN"), abrpAPIKey: getenv("ABRP_API_KEY"), abrpUserToken: getenv("ABRP_USER_TOKEN"), + approvalPass: getenv("APPROVAL_PASSWORD"), + publicURL: getenv("PUBLIC_URL"), } if c.listenAddr == "" { c.listenAddr = ":8080" @@ -57,6 +64,11 @@ func loadConfig(getenv func(string) string) (config, error) { if len(c.ingestToken) < minIngestTokenLength { return config{}, fmt.Errorf("INGEST_TOKEN must be at least %d characters", minIngestTokenLength) } + // Pairing is optional; when it is on, its password guards token issuance and must not + // be guessable. + if c.approvalPass != "" && len(c.approvalPass) < minApprovalPasswordLength { + return config{}, fmt.Errorf("APPROVAL_PASSWORD must be at least %d characters", minApprovalPasswordLength) + } c.abrpMinInterval = 5 * time.Second if raw := getenv("ABRP_MIN_INTERVAL_SECONDS"); raw != "" { seconds, err := strconv.ParseFloat(raw, 64) @@ -84,7 +96,18 @@ func main() { MinInterval: cfg.abrpMinInterval, Client: &http.Client{Timeout: 10 * time.Second}, } - api := &API{Store: store, IngestToken: cfg.ingestToken, Forwarder: forwarder} + // Paired cars' tokens must be known before the first request; starting without them + // would answer every paired car with 401, so a failed load stops the start instead. + loadCtx, cancelLoad := context.WithTimeout(context.Background(), 30*time.Second) + devices, err := store.LoadDevices(loadCtx) + cancelLoad() + if err != nil { + slog.Error("paired devices could not be loaded", "error", err) + os.Exit(1) + } + pairing := NewPairing(cfg.approvalPass, devices) + api := &API{Store: store, IngestToken: cfg.ingestToken, Forwarder: forwarder, + Pairing: pairing, PublicURL: cfg.publicURL} srv := &http.Server{ Addr: cfg.listenAddr, @@ -98,7 +121,8 @@ func main() { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() go func() { - slog.Info("listening", "addr", cfg.listenAddr, "bucket", cfg.influxBucket, "abrp", forwarder.configured()) + slog.Info("listening", "addr", cfg.listenAddr, "bucket", cfg.influxBucket, + "abrp", forwarder.configured(), "pairing", pairing.Enabled(), "pairedDevices", len(devices)) if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { slog.Error("server failed", "error", err) os.Exit(1) diff --git a/server/pairing.go b/server/pairing.go new file mode 100644 index 0000000..f67438c --- /dev/null +++ b/server/pairing.go @@ -0,0 +1,311 @@ +package main + +import ( + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "errors" + "strings" + "sync" + "time" +) + +// Pairing lets a car get its own ingest token without anyone typing it: the car asks for a +// pairing request and shows its code as a QR code, a phone opens the approval page, the +// owner signs in with the approval password and approves, and the car — polling meanwhile — +// receives a token once. Modelled on the OAuth device authorization grant (RFC 8628). +// +// Pending requests live in memory only: they last ten minutes, and the server runs as one +// replica. Approved devices are persisted (see Device) so their tokens survive a restart. + +const ( + pairingTTL = 10 * time.Minute + pairingInterval = 5 * time.Second + maxPendingPairing = 50 + // After this many wrong approval passwords in a window, approvals pause for the window. + maxFailedLogins = 5 + failedLoginsWindow = time.Minute + // Unambiguous characters only: no 0/O, 1/I/L, so a code reads right off a car screen. + userCodeAlphabet = "ABCDEFGHJKMNPQRSTUVWXYZ23456789" +) + +var ( + errUnknownCode = errors.New("unknown or expired code") + errNotPending = errors.New("request already decided") + errTooManyFailed = errors.New("too many wrong passwords, try again in a minute") + errWrongPassword = errors.New("wrong password") + errTooMany = errors.New("too many pending pairing requests") +) + +// Device is a paired car. Only the SHA-256 of its token is kept. +type Device struct { + ID string + InstallationID string + TokenHash string + CreatedAt time.Time + Revoked bool +} + +type pairingState int + +const ( + pairingPending pairingState = iota + pairingApproved + pairingDenied +) + +type pairingRequest struct { + deviceCode string + userCode string + installationID string + expires time.Time + state pairingState + token string // set on approval, handed out once + deviceID string +} + +// Pairing holds pending requests and the known devices. The clock is injectable for tests. +type Pairing struct { + ApprovalPassword string + Now func() time.Time + + mu sync.Mutex + byDevice map[string]*pairingRequest + byUser map[string]*pairingRequest + devices map[string]Device // by token hash + failures []time.Time +} + +func NewPairing(password string, devices []Device) *Pairing { + p := &Pairing{ + ApprovalPassword: password, + Now: time.Now, + byDevice: map[string]*pairingRequest{}, + byUser: map[string]*pairingRequest{}, + devices: map[string]Device{}, + } + for _, d := range devices { + p.devices[d.TokenHash] = d + } + return p +} + +func (p *Pairing) Enabled() bool { return p != nil && p.ApprovalPassword != "" } + +func randomString(n int) string { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + panic(err) // crypto/rand does not fail on supported platforms + } + return base64.RawURLEncoding.EncodeToString(b) +} + +func newUserCode() string { + b := make([]byte, 6) + if _, err := rand.Read(b); err != nil { + panic(err) + } + code := make([]byte, 0, 7) + for i, v := range b { + if i == 3 { + code = append(code, '-') + } + // 256 % 31 leaves a slight bias; irrelevant for a 10-minute code that also needs + // the approval password. + code = append(code, userCodeAlphabet[int(v)%len(userCodeAlphabet)]) + } + return string(code) +} + +// normalizeUserCode accepts what a person types: any case, with or without the dash. +func normalizeUserCode(code string) string { + code = strings.ToUpper(strings.TrimSpace(code)) + code = strings.ReplaceAll(code, "-", "") + code = strings.ReplaceAll(code, " ", "") + if len(code) != 6 { + return "" + } + return code[:3] + "-" + code[3:] +} + +func hashToken(token string) string { + sum := sha256.Sum256([]byte(token)) + return hex.EncodeToString(sum[:]) +} + +func (p *Pairing) expireLocked(now time.Time) { + for code, r := range p.byDevice { + if now.After(r.expires) { + delete(p.byDevice, code) + delete(p.byUser, r.userCode) + } + } +} + +// Start opens a pairing request for a car. +func (p *Pairing) Start(installationID string) (*pairingRequest, error) { + p.mu.Lock() + defer p.mu.Unlock() + now := p.Now() + p.expireLocked(now) + if len(p.byDevice) >= maxPendingPairing { + return nil, errTooMany + } + r := &pairingRequest{ + deviceCode: randomString(32), + installationID: installationID, + expires: now.Add(pairingTTL), + } + for { + r.userCode = newUserCode() + if _, taken := p.byUser[r.userCode]; !taken { + break + } + } + p.byDevice[r.deviceCode] = r + p.byUser[r.userCode] = r + out := *r + return &out, nil +} + +// Lookup returns the pending request for a user code, for the approval page. +func (p *Pairing) Lookup(userCode string) (pairingRequest, error) { + p.mu.Lock() + defer p.mu.Unlock() + p.expireLocked(p.Now()) + r, ok := p.byUser[normalizeUserCode(userCode)] + if !ok { + return pairingRequest{}, errUnknownCode + } + return *r, nil +} + +// checkPassword counts failures and refuses everything while too many are recent. +func (p *Pairing) checkPasswordLocked(password string, now time.Time) error { + recent := p.failures[:0] + for _, t := range p.failures { + if now.Sub(t) < failedLoginsWindow { + recent = append(recent, t) + } + } + p.failures = recent + if len(p.failures) >= maxFailedLogins { + return errTooManyFailed + } + if subtle.ConstantTimeCompare([]byte(password), []byte(p.ApprovalPassword)) != 1 { + p.failures = append(p.failures, now) + return errWrongPassword + } + return nil +} + +// Decide approves or denies a pending request. On approval it returns the new device, which +// the caller must persist before the car can collect the token. +func (p *Pairing) Decide(userCode, password string, approve bool) (Device, error) { + p.mu.Lock() + defer p.mu.Unlock() + now := p.Now() + if err := p.checkPasswordLocked(password, now); err != nil { + return Device{}, err + } + p.expireLocked(now) + r, ok := p.byUser[normalizeUserCode(userCode)] + if !ok { + return Device{}, errUnknownCode + } + if r.state != pairingPending { + return Device{}, errNotPending + } + if !approve { + r.state = pairingDenied + return Device{}, nil + } + token := randomString(32) // 43 characters + d := Device{ + ID: randomString(9), + InstallationID: r.installationID, + TokenHash: hashToken(token), + CreatedAt: now, + } + r.state = pairingApproved + r.token = token + r.deviceID = d.ID + p.devices[d.TokenHash] = d + return d, nil +} + +// Forget undoes an approval whose device could not be persisted, so no token is handed out +// that would stop working at the next restart. +func (p *Pairing) Forget(userCode string, d Device) { + p.mu.Lock() + defer p.mu.Unlock() + delete(p.devices, d.TokenHash) + if r, ok := p.byUser[normalizeUserCode(userCode)]; ok { + r.state = pairingPending + r.token = "" + r.deviceID = "" + } +} + +// Poll is what the car calls: pending, the token (exactly once), denied, or unknown. +func (p *Pairing) Poll(deviceCode string) (state pairingState, token, deviceID string, err error) { + p.mu.Lock() + defer p.mu.Unlock() + p.expireLocked(p.Now()) + r, ok := p.byDevice[deviceCode] + if !ok { + return 0, "", "", errUnknownCode + } + switch r.state { + case pairingApproved, pairingDenied: + // Decided: hand the outcome out once, then forget the request. + delete(p.byDevice, r.deviceCode) + delete(p.byUser, r.userCode) + } + return r.state, r.token, r.deviceID, nil +} + +// Authorized reports whether a token belongs to a paired, unrevoked device. +func (p *Pairing) Authorized(token string) bool { + if p == nil || token == "" { + return false + } + p.mu.Lock() + defer p.mu.Unlock() + d, ok := p.devices[hashToken(token)] + return ok && !d.Revoked +} + +func (p *Pairing) Devices() []Device { + p.mu.Lock() + defer p.mu.Unlock() + out := make([]Device, 0, len(p.devices)) + for _, d := range p.devices { + out = append(out, d) + } + return out +} + +// Revoke marks a device revoked and returns it for persisting. +func (p *Pairing) Revoke(id string) (Device, bool) { + p.mu.Lock() + defer p.mu.Unlock() + for hash, d := range p.devices { + if d.ID == id { + d.Revoked = true + p.devices[hash] = d + return d, true + } + } + return Device{}, false +} + +// CheckAdmin verifies the approval password for the device-management API, with the same +// failure counting as the approval page. +func (p *Pairing) CheckAdmin(password string) error { + p.mu.Lock() + defer p.mu.Unlock() + return p.checkPasswordLocked(password, p.Now()) +} diff --git a/server/pairing_http.go b/server/pairing_http.go new file mode 100644 index 0000000..f8341f1 --- /dev/null +++ b/server/pairing_http.go @@ -0,0 +1,294 @@ +package main + +import ( + "errors" + "html/template" + "io" + "log/slog" + "net/http" + "net/url" + "strings" + "time" +) + +// HTTP side of pairing. +// +// POST /v1/pair/start car: open a request {installationId} → codes and URL +// POST /v1/pair/poll car: collect the outcome {deviceCode} → 202 / 200 token / 403 / 410 +// GET /pair[?code=XXX-XXX] phone: approval page +// POST /pair phone: approve or deny (needs the approval password) +// GET /v1/devices list paired cars Bearer +// POST /v1/devices/{id}/revoke revoke a car's token Bearer +// +// Start and poll need no token — the car does not have one yet. What they hand out is +// worthless until a person who knows the approval password approves it. + +func (a *API) pairingRoutes(mux *http.ServeMux) { + mux.HandleFunc("POST /v1/pair/start", a.pairStart) + mux.HandleFunc("POST /v1/pair/poll", a.pairPoll) + mux.HandleFunc("GET /pair", a.pairPage) + mux.HandleFunc("POST /pair", a.pairDecide) + mux.HandleFunc("GET /v1/devices", a.listDevices) + mux.HandleFunc("POST /v1/devices/{id}/revoke", a.revokeDevice) +} + +func (a *API) pairingDisabled(w http.ResponseWriter) bool { + if a.Pairing.Enabled() { + return false + } + writeJSON(w, http.StatusNotFound, map[string]string{"error": "pairing is not enabled"}) + return true +} + +func readSmallJSON(r *http.Request) (object, error) { + body, err := io.ReadAll(io.LimitReader(r.Body, 4097)) + if err != nil || len(body) > 4096 { + return nil, invalid("body is too large") + } + return decodeObject(body) +} + +// publicURL is where the phone should go: PUBLIC_URL when set, otherwise what the request +// came in on (behind Traefik, the forwarded scheme and host). +func (a *API) publicURL(r *http.Request) string { + if a.PublicURL != "" { + return strings.TrimRight(a.PublicURL, "/") + } + scheme := "http" + if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" { + scheme = "https" + } + host := r.Header.Get("X-Forwarded-Host") + if host == "" { + host = r.Host + } + return scheme + "://" + host +} + +func (a *API) pairStart(w http.ResponseWriter, r *http.Request) { + if a.pairingDisabled(w) { + return + } + o, err := readSmallJSON(r) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + id, err := validUUID(o["installationId"], "installationId") + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + req, err := a.Pairing.Start(id) + if err != nil { + writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": err.Error()}) + return + } + base := a.publicURL(r) + "/pair" + writeJSON(w, http.StatusCreated, map[string]any{ + "deviceCode": req.deviceCode, + "userCode": req.userCode, + "verificationUri": base, + "verificationUriComplete": base + "?code=" + url.QueryEscape(req.userCode), + "expiresIn": int(pairingTTL / time.Second), + "interval": int(pairingInterval / time.Second), + }) +} + +func (a *API) pairPoll(w http.ResponseWriter, r *http.Request) { + if a.pairingDisabled(w) { + return + } + o, err := readSmallJSON(r) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + code, _ := o["deviceCode"].(string) + state, token, deviceID, err := a.Pairing.Poll(code) + switch { + case err != nil: + writeJSON(w, http.StatusGone, map[string]string{"error": "expired_token"}) + case state == pairingPending: + writeJSON(w, http.StatusAccepted, map[string]string{"status": "authorization_pending"}) + case state == pairingDenied: + writeJSON(w, http.StatusForbidden, map[string]string{"error": "access_denied"}) + default: + writeJSON(w, http.StatusOK, map[string]string{"token": token, "deviceId": deviceID}) + } +} + +// ---------- Approval page ---------- + +type pageData struct { + Code string + Message string + Error string + Done bool +} + +var pairTemplate = template.Must(template.New("pair").Parse(` + +Pair a car — Nils' MG4 +
    +

    Pair a car

    +{{if .Done}} +

    {{.Message}}

    +{{else}} +

    Check that this code matches the one on the car's screen, then approve with the approval password.

    +
    +{{if .Code}}
    {{.Code}}
    +{{else}} +{{end}} + + + +{{if .Error}}

    {{.Error}}

    {{end}} +
    +
    +
    +{{end}} +
    `)) + +func renderPage(w http.ResponseWriter, status int, data pageData) { + h := w.Header() + h.Set("Content-Type", "text/html; charset=utf-8") + // No scripts at all, no framing, forms only back to this page. + h.Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'") + h.Set("X-Content-Type-Options", "nosniff") + h.Set("Referrer-Policy", "no-referrer") + h.Set("Cache-Control", "no-store") + w.WriteHeader(status) + if err := pairTemplate.Execute(w, data); err != nil { + slog.Warn("pair page not rendered", "error", err) + } +} + +func (a *API) pairPage(w http.ResponseWriter, r *http.Request) { + if !a.Pairing.Enabled() { + http.NotFound(w, r) + return + } + data := pageData{} + if raw := r.URL.Query().Get("code"); raw != "" { + if req, err := a.Pairing.Lookup(raw); err == nil && req.state == pairingPending { + data.Code = req.userCode + } else { + data.Error = "This code is unknown or has expired. Start pairing again on the car." + } + } + renderPage(w, http.StatusOK, data) +} + +func (a *API) pairDecide(w http.ResponseWriter, r *http.Request) { + if !a.Pairing.Enabled() { + http.NotFound(w, r) + return + } + r.Body = http.MaxBytesReader(w, r.Body, 4096) + if err := r.ParseForm(); err != nil { + renderPage(w, http.StatusBadRequest, pageData{Error: "The form could not be read."}) + return + } + code := normalizeUserCode(r.PostForm.Get("code")) + approve := r.PostForm.Get("action") != "deny" + device, err := a.Pairing.Decide(code, r.PostForm.Get("password"), approve) + switch { + case errors.Is(err, errWrongPassword): + renderPage(w, http.StatusUnauthorized, pageData{Code: code, Error: "Wrong password."}) + return + case errors.Is(err, errTooManyFailed): + renderPage(w, http.StatusTooManyRequests, pageData{Code: code, Error: err.Error() + "."}) + return + case errors.Is(err, errUnknownCode), errors.Is(err, errNotPending): + renderPage(w, http.StatusNotFound, pageData{Error: "This code is unknown, expired or already used. Start pairing again on the car."}) + return + case err != nil: + renderPage(w, http.StatusInternalServerError, pageData{Code: code, Error: "Something went wrong."}) + return + } + if !approve { + renderPage(w, http.StatusOK, pageData{Done: true, Message: "Denied. The car was not paired."}) + return + } + // Persist before the car can collect the token: a token that vanished at the next + // restart would look like a pairing that worked. + if err := a.Store.SaveDevice(r.Context(), device); err != nil { + a.Pairing.Forget(code, device) + slog.Error("device not saved", "error", err) + renderPage(w, http.StatusInternalServerError, pageData{Code: code, Error: "The car could not be saved. Try again."}) + return + } + slog.Info("car paired", "device", device.ID, "installation", device.InstallationID) + renderPage(w, http.StatusOK, pageData{Done: true, Message: "Approved. The car finishes pairing on its own within a few seconds — you can close this page."}) +} + +// ---------- Device management ---------- + +func (a *API) adminAuthorized(w http.ResponseWriter, r *http.Request) bool { + if a.pairingDisabled(w) { + return false + } + supplied, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ") + if !ok { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"}) + return false + } + switch err := a.Pairing.CheckAdmin(supplied); { + case errors.Is(err, errTooManyFailed): + writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": err.Error()}) + return false + case err != nil: + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"}) + return false + } + return true +} + +type deviceJSON struct { + ID string `json:"id"` + InstallationID string `json:"installationId"` + CreatedAt time.Time `json:"createdAt"` + Revoked bool `json:"revoked"` +} + +func (a *API) listDevices(w http.ResponseWriter, r *http.Request) { + if !a.adminAuthorized(w, r) { + return + } + out := []deviceJSON{} + for _, d := range a.Pairing.Devices() { + out = append(out, deviceJSON{d.ID, d.InstallationID, d.CreatedAt, d.Revoked}) + } + writeJSON(w, http.StatusOK, map[string]any{"devices": out}) +} + +func (a *API) revokeDevice(w http.ResponseWriter, r *http.Request) { + if !a.adminAuthorized(w, r) { + return + } + d, ok := a.Pairing.Revoke(r.PathValue("id")) + if !ok { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such device"}) + return + } + if err := a.Store.SaveDevice(r.Context(), d); err != nil { + slog.Error("revocation not saved", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "revoked until restart only; saving failed"}) + return + } + slog.Info("car revoked", "device", d.ID) + w.WriteHeader(http.StatusNoContent) +} diff --git a/server/pairing_test.go b/server/pairing_test.go new file mode 100644 index 0000000..fce9b53 --- /dev/null +++ b/server/pairing_test.go @@ -0,0 +1,234 @@ +package main + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "regexp" + "strings" + "testing" + "time" +) + +func startPairing(t *testing.T, h http.Handler) (deviceCode, userCode, uriComplete string) { + t.Helper() + rec := do(t, h, "POST", "/v1/pair/start", "", `{"installationId":"`+testInstallation+`"}`) + if rec.Code != 201 { + t.Fatalf("start: %d %s", rec.Code, rec.Body) + } + var got map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + return got["deviceCode"].(string), got["userCode"].(string), got["verificationUriComplete"].(string) +} + +func poll(t *testing.T, h http.Handler, deviceCode string) (int, map[string]string) { + t.Helper() + rec := do(t, h, "POST", "/v1/pair/poll", "", `{"deviceCode":"`+deviceCode+`"}`) + var got map[string]string + _ = json.Unmarshal(rec.Body.Bytes(), &got) + return rec.Code, got +} + +func submit(t *testing.T, h http.Handler, code, password, action string) *httptest.ResponseRecorder { + t.Helper() + form := url.Values{"code": {code}, "password": {password}, "action": {action}} + req := httptest.NewRequest("POST", "/pair", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec +} + +func TestPairingHappyPath(t *testing.T) { + api, store := newTestAPI() + h := api.Routes() + deviceCode, userCode, uri := startPairing(t, h) + if !regexpUserCode.MatchString(userCode) { + t.Fatalf("user code %q", userCode) + } + if uri != "https://car.example.com/pair?code="+userCode { + t.Fatalf("verification URI %q", uri) + } + if status, _ := poll(t, h, deviceCode); status != 202 { + t.Fatalf("before approval: want 202, got %d", status) + } + + // The phone opens the link: the page shows the code and asks for the password. + page := do(t, h, "GET", "/pair?code="+strings.ToLower(strings.ReplaceAll(userCode, "-", "")), "", "") + if page.Code != 200 || !strings.Contains(page.Body.String(), userCode) { + t.Fatalf("page: %d", page.Code) + } + if csp := page.Header().Get("Content-Security-Policy"); !strings.Contains(csp, "frame-ancestors 'none'") { + t.Fatalf("CSP %q", csp) + } + + if rec := submit(t, h, userCode, testApprovalPassword, "approve"); rec.Code != 200 || !strings.Contains(rec.Body.String(), "Approved") { + t.Fatalf("approve: %d %s", rec.Code, rec.Body) + } + if len(store.devices) != 1 || store.devices[0].InstallationID != testInstallation { + t.Fatalf("device not persisted: %+v", store.devices) + } + status, got := poll(t, h, deviceCode) + if status != 200 || len(got["token"]) < minIngestTokenLength || got["deviceId"] == "" { + t.Fatalf("after approval: %d %v", status, got) + } + // Only the hash is stored, never the token. + if store.devices[0].TokenHash == got["token"] || store.devices[0].TokenHash != hashToken(got["token"]) { + t.Fatal("stored token hash is wrong") + } + // The token is handed out once. + if status, _ := poll(t, h, deviceCode); status != 410 { + t.Fatalf("second collection: want 410, got %d", status) + } + // And it works on the ingest endpoints. + rec := do(t, h, "POST", "/v1/telemetry", got["token"], string(telemetryBody(`{"utc":1790000000,"soc":80}`))) + if rec.Code != 204 { + t.Fatalf("paired token on telemetry: %d %s", rec.Code, rec.Body) + } +} + +func TestPairingWrongPasswordAndLockout(t *testing.T) { + api, store := newTestAPI() + h := api.Routes() + deviceCode, userCode, _ := startPairing(t, h) + for i := 0; i < maxFailedLogins; i++ { + if rec := submit(t, h, userCode, "wrong", "approve"); rec.Code != 401 { + t.Fatalf("attempt %d: want 401, got %d", i, rec.Code) + } + } + // Locked now — even the right password waits. + if rec := submit(t, h, userCode, testApprovalPassword, "approve"); rec.Code != 429 { + t.Fatalf("locked: want 429, got %d", rec.Code) + } + if status, _ := poll(t, h, deviceCode); status != 202 || len(store.devices) != 0 { + t.Fatal("a locked-out attempt changed the request") + } + // After the window the right password works again. + api.Pairing.Now = func() time.Time { return time.Now().Add(failedLoginsWindow + time.Second) } + if rec := submit(t, h, userCode, testApprovalPassword, "approve"); rec.Code != 200 { + t.Fatalf("after the window: want 200, got %d %s", rec.Code, rec.Body) + } +} + +func TestPairingDenyAndExpiry(t *testing.T) { + api, _ := newTestAPI() + h := api.Routes() + deviceCode, userCode, _ := startPairing(t, h) + if rec := submit(t, h, userCode, testApprovalPassword, "deny"); rec.Code != 200 || !strings.Contains(rec.Body.String(), "Denied") { + t.Fatalf("deny: %d", rec.Code) + } + if status, _ := poll(t, h, deviceCode); status != 403 { + t.Fatalf("denied: want 403, got %d", status) + } + if rec := submit(t, h, userCode, testApprovalPassword, "approve"); rec.Code != 404 { + t.Fatalf("a decided code was approvable: %d", rec.Code) + } + + deviceCode, userCode, _ = startPairing(t, h) + api.Pairing.Now = func() time.Time { return time.Now().Add(pairingTTL + time.Second) } + if status, _ := poll(t, h, deviceCode); status != 410 { + t.Fatalf("expired: want 410, got %d", status) + } + if rec := submit(t, h, userCode, testApprovalPassword, "approve"); rec.Code != 404 { + t.Fatalf("an expired code was approvable: %d", rec.Code) + } +} + +func TestPairingNotPersistedMeansNoToken(t *testing.T) { + api, store := newTestAPI() + h := api.Routes() + deviceCode, userCode, _ := startPairing(t, h) + store.fail = errors.New("influx down") + if rec := submit(t, h, userCode, testApprovalPassword, "approve"); rec.Code != 500 { + t.Fatalf("want 500, got %d", rec.Code) + } + if status, _ := poll(t, h, deviceCode); status != 202 { + t.Fatalf("a token was handed out that was never saved: %d", status) + } + store.fail = nil + if rec := submit(t, h, userCode, testApprovalPassword, "approve"); rec.Code != 200 { + t.Fatalf("retry: %d", rec.Code) + } + if status, _ := poll(t, h, deviceCode); status != 200 { + t.Fatalf("after retry: %d", status) + } +} + +func TestRevokedTokenIsRejected(t *testing.T) { + api, store := newTestAPI() + h := api.Routes() + deviceCode, userCode, _ := startPairing(t, h) + submit(t, h, userCode, testApprovalPassword, "approve") + _, got := poll(t, h, deviceCode) + token := got["token"] + + if rec := do(t, h, "GET", "/v1/devices", "wrong-password", ""); rec.Code != 401 { + t.Fatalf("devices without the password: %d", rec.Code) + } + if rec := do(t, h, "GET", "/v1/devices", token, ""); rec.Code != 401 { + t.Fatal("a car token opened the device list") + } + rec := do(t, h, "GET", "/v1/devices", testApprovalPassword, "") + if rec.Code != 200 || !strings.Contains(rec.Body.String(), got["deviceId"]) || strings.Contains(rec.Body.String(), store.devices[0].TokenHash) { + t.Fatalf("list: %d %s", rec.Code, rec.Body) + } + if rec := do(t, h, "POST", "/v1/devices/"+got["deviceId"]+"/revoke", testApprovalPassword, ""); rec.Code != 204 { + t.Fatalf("revoke: %d", rec.Code) + } + if last := store.devices[len(store.devices)-1]; !last.Revoked { + t.Fatal("revocation not persisted") + } + if rec := do(t, h, "POST", "/v1/telemetry", token, string(telemetryBody(`{"utc":1790000000}`))); rec.Code != 401 { + t.Fatalf("revoked token accepted: %d", rec.Code) + } + // The shared ingest token is unaffected. + if rec := do(t, h, "POST", "/v1/telemetry", testToken, string(telemetryBody(`{"utc":1790000000,"soc":1}`))); rec.Code != 204 { + t.Fatalf("shared token: %d", rec.Code) + } +} + +func TestPairedDevicesSurviveARestart(t *testing.T) { + devices := []Device{{ID: "d1", TokenHash: hashToken("restored-token-0123456789abcdef"), CreatedAt: time.Now()}, + {ID: "d2", TokenHash: hashToken("revoked-token-0123456789abcdef"), CreatedAt: time.Now(), Revoked: true}} + p := NewPairing(testApprovalPassword, devices) + if !p.Authorized("restored-token-0123456789abcdef") || p.Authorized("revoked-token-0123456789abcdef") { + t.Fatal("restored devices not honoured") + } +} + +func TestPairingOffWithoutAPassword(t *testing.T) { + api, _ := newTestAPI() + api.Pairing = NewPairing("", nil) + h := api.Routes() + if rec := do(t, h, "POST", "/v1/pair/start", "", `{"installationId":"`+testInstallation+`"}`); rec.Code != 404 { + t.Fatalf("start: %d", rec.Code) + } + if rec := do(t, h, "GET", "/pair", "", ""); rec.Code != 404 { + t.Fatalf("page: %d", rec.Code) + } + if rec := do(t, h, "GET", "/v1/devices", "", ""); rec.Code != 404 { + t.Fatalf("devices: %d", rec.Code) + } +} + +func TestUserCodesNormalise(t *testing.T) { + for in, want := range map[string]string{"k7f-3qx": "K7F-3QX", " K7F3QX ": "K7F-3QX", "k7f 3qx": "K7F-3QX", "K7F-3Q": "", "": ""} { + if got := normalizeUserCode(in); got != want { + t.Errorf("%q: got %q, want %q", in, got, want) + } + } +} + +func TestApprovalPasswordMustBeLongEnough(t *testing.T) { + env := map[string]string{"INFLUX_URL": "u", "INFLUX_TOKEN": "t", "INFLUX_ORG": "o", + "INGEST_TOKEN": "0123456789abcdef01234567", "APPROVAL_PASSWORD": "short"} + if _, err := loadConfig(func(k string) string { return env[k] }); err == nil { + t.Fatal("short approval password accepted") + } +} + +var regexpUserCode = regexp.MustCompile(`^[` + userCodeAlphabet + `]{3}-[` + userCodeAlphabet + `]{3}$`) diff --git a/server/store.go b/server/store.go index 9f49601..0dab1a8 100644 --- a/server/store.go +++ b/server/store.go @@ -17,6 +17,8 @@ type Store interface { WriteConsumptionChunk(ctx context.Context, chunk ConsumptionChunk) error WriteChargingSession(ctx context.Context, session ChargingSession) error ReadConsumptionChunks(ctx context.Context, installationID string) ([]ConsumptionChunk, error) + SaveDevice(ctx context.Context, d Device) error + LoadDevices(ctx context.Context) ([]Device, error) } // InfluxStore keeps everything in one InfluxDB 2 bucket; every point carries an @@ -26,6 +28,7 @@ type Store interface { // consumption_chunk time = recordedAt tags: date km, kwh, hours, socDrop, sohPercent // charging_session time = startedAt tags: sessionId summary fields // charging_curve time = point time tags: sessionId powerKw, socPercent +// device time = createdAt tags: deviceId tokenHash, installationId, revoked // // Idempotency comes from InfluxDB itself: a point with the same measurement, tags and // timestamp replaces the earlier one. The car re-sends a cached chunk with its original @@ -160,3 +163,38 @@ func floatValue(v any) float64 { f, _ := v.(float64) return f } + +// SaveDevice writes a paired car. Re-saving it (a revocation) overwrites the same point, +// because measurement, tag and timestamp stay the same. +func (s *InfluxStore) SaveDevice(ctx context.Context, d Device) error { + return s.write.WritePoint(ctx, influxdb2.NewPoint("device", + map[string]string{"deviceId": d.ID}, + map[string]any{"tokenHash": d.TokenHash, "installationId": d.InstallationID, "revoked": d.Revoked}, + d.CreatedAt)) +} + +func (s *InfluxStore) LoadDevices(ctx context.Context) ([]Device, error) { + query := fmt.Sprintf(`from(bucket: %s) + |> range(start: 0) + |> filter(fn: (r) => r._measurement == "device") + |> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")`, fluxString(s.bucket)) + result, err := s.query.Query(ctx, query) + if err != nil { + return nil, err + } + defer result.Close() + var devices []Device + for result.Next() { + r := result.Record() + id, _ := r.ValueByKey("deviceId").(string) + hash, _ := r.ValueByKey("tokenHash").(string) + installation, _ := r.ValueByKey("installationId").(string) + revoked, _ := r.ValueByKey("revoked").(bool) + if id == "" || hash == "" { + continue + } + devices = append(devices, Device{ID: id, InstallationID: installation, TokenHash: hash, + CreatedAt: r.Time(), Revoked: revoked}) + } + return devices, result.Err() +} From c578a882bddd371313ee0052a2cca62f7466344b Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Sat, 17 Oct 2026 16:16:52 +0200 Subject: [PATCH 23/79] App 2.4.0: pair with the server by QR code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Server tab gets "Pair with phone" right under the server URL, which is now pre-filled with https://car.stinnesbeck.com. It opens a dialog with a QR code and the short code, polls the server, and on approval saves this car's own token like Save would — nothing to type in the car. Denied, expired, and pairing switched off on the server are each reported. PairingClient is tested against a stand-in for the server. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki --- app/build.gradle | 4 +- .../java/com/evsuite/abrp/MainActivity.java | 109 +++++++++++++++ .../java/com/evsuite/abrp/PairingClient.java | 122 ++++++++++++++++ .../java/com/evsuite/abrp/ServerConfig.java | 5 +- app/src/main/res/layout/pane_server.xml | 2 + app/src/main/res/values/strings.xml | 13 ++ .../com/evsuite/abrp/PairingClientTest.java | 132 ++++++++++++++++++ 7 files changed, 384 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/com/evsuite/abrp/PairingClient.java create mode 100644 app/src/test/java/com/evsuite/abrp/PairingClientTest.java diff --git a/app/build.gradle b/app/build.gradle index 0c0fc65..da13714 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -34,8 +34,8 @@ android { applicationId "com.stinnesbeck.nilsmg4" minSdk 27 targetSdk 36 - versionCode 57 - versionName "2.3.0" + versionCode 58 + versionName "2.4.0" // Nothing about the server ships in the APK: its URL and ingest token are entered in // the app's Server tab (ServerConfig), and the database is only reachable by the server. diff --git a/app/src/main/java/com/evsuite/abrp/MainActivity.java b/app/src/main/java/com/evsuite/abrp/MainActivity.java index 7df5cbb..cc225e7 100644 --- a/app/src/main/java/com/evsuite/abrp/MainActivity.java +++ b/app/src/main/java/com/evsuite/abrp/MainActivity.java @@ -391,6 +391,7 @@ public class MainActivity extends AppCompatActivity { } }); serverPane.findViewById(R.id.server_test_button).setOnClickListener(v -> testConnection()); + serverPane.findViewById(R.id.server_pair_button).setOnClickListener(v -> startPairing()); serverPane.findViewById(R.id.import_button).setOnClickListener(v -> startConfigImport()); } @@ -463,6 +464,114 @@ public class MainActivity extends AppCompatActivity { }, "server-test").start(); } + /** + * Pairs this car with the server through a phone: the server opens a pairing request, the + * dialog shows its code as a QR code, and a background thread polls until the request is + * approved, denied or expired. On approval the car's own token is saved with the URL from + * the field and the installation id, exactly as Save would. + */ + private void startPairing() { + String url = ServerConfig.normalizeUrl(textOf(serverUrlInput)); + if (url == null) { + serverUrlLayout.setError(getString(R.string.server_err_url)); + return; + } + serverUrlLayout.setError(null); + String typedId = textOf(installationIdInput).toLowerCase(java.util.Locale.US); + String installationId = ServerConfig.isValidInstallationId(typedId) + ? typedId : ServerConfig.get(this).installationId; + + float density = getResources().getDisplayMetrics().density; + int pad = (int) (24 * density); + android.widget.LinearLayout panel = new android.widget.LinearLayout(this); + panel.setOrientation(android.widget.LinearLayout.VERTICAL); + panel.setGravity(android.view.Gravity.CENTER_HORIZONTAL); + panel.setPadding(pad, pad, pad, pad); + TextView instructions = new TextView(this); + instructions.setTextAppearance(R.style.Text_EV_Secondary); + ImageView qr = new ImageView(this); + qr.setContentDescription(getString(R.string.pair_qr_description)); + int qrSize = (int) (240 * density); + TextView code = new TextView(this); + code.setTextAppearance(R.style.Text_EV_Title); + code.setTypeface(android.graphics.Typeface.MONOSPACE, android.graphics.Typeface.BOLD); + code.setTextSize(36); + code.setLetterSpacing(0.15f); + code.setTextColor(getColor(R.color.ev_cyan)); + TextView status = new TextView(this); + status.setTextAppearance(R.style.Text_EV_Body); + status.setText(R.string.pair_starting); + panel.addView(instructions); + android.widget.LinearLayout.LayoutParams qrParams = new android.widget.LinearLayout.LayoutParams(qrSize, qrSize); + qrParams.topMargin = pad / 2; + panel.addView(qr, qrParams); + panel.addView(code); + panel.addView(status); + + final boolean[] cancelled = {false}; + androidx.appcompat.app.AlertDialog dialog = new MaterialAlertDialogBuilder(this) + .setTitle(R.string.pair_title) + .setView(panel) + .setNegativeButton(R.string.pair_cancel, null) + .create(); + dialog.setOnDismissListener(d -> cancelled[0] = true); + dialog.show(); + + new Thread(() -> { + String outcome; + int color = COLOR_ERROR; + try { + PairingClient.Request request = PairingClient.start(url, installationId); + android.graphics.Bitmap bitmap = QrCode.generate(request.verificationUriComplete, 416); + runOnUiThread(() -> { + instructions.setText(getString(R.string.pair_scan, request.verificationUri)); + if (bitmap != null) qr.setImageBitmap(bitmap); + code.setText(request.userCode); + status.setText(R.string.pair_waiting); + }); + PairingClient.PollResult result = null; + while (!cancelled[0] && System.currentTimeMillis() < request.expiresAtMs) { + Thread.sleep(request.intervalMs); + if (cancelled[0]) break; + try { + result = PairingClient.poll(url, request.deviceCode); + } catch (java.io.IOException e) { + continue; // a dropped connection mid-wait is not a failed pairing + } + if (result.outcome != PairingClient.Outcome.PENDING) break; + } + if (cancelled[0]) return; + if (result != null && result.outcome == PairingClient.Outcome.APPROVED) { + ServerConfig.save(this, url, result.token, installationId); + outcome = getString(R.string.pair_ok); + color = COLOR_OK; + } else if (result != null && result.outcome == PairingClient.Outcome.DENIED) { + outcome = getString(R.string.pair_denied); + } else { + outcome = getString(R.string.pair_expired); + } + } catch (PairingClient.NotEnabledException e) { + outcome = getString(R.string.pair_not_enabled); + } catch (InterruptedException e) { + return; + } catch (Exception e) { + outcome = getString(R.string.pair_error, e.getClass().getSimpleName()); + } + final String message = outcome; + final int c = color; + runOnUiThread(() -> { + if (!cancelled[0]) dialog.dismiss(); + if (c == COLOR_OK) { + if (consumptionCloudClient != null) consumptionCloudClient.invalidateHistory(); + showServerConfig(); + serverTokenLayout.setError(null); + refreshStatus(); + } + setConnectionStatus(c, message); + }); + }, "server-pairing").start(); + } + private static int httpGet(String url, String token) throws java.io.IOException { java.net.HttpURLConnection connection = (java.net.HttpURLConnection) new java.net.URL(url).openConnection(); try { diff --git a/app/src/main/java/com/evsuite/abrp/PairingClient.java b/app/src/main/java/com/evsuite/abrp/PairingClient.java new file mode 100644 index 0000000..29c0fd8 --- /dev/null +++ b/app/src/main/java/com/evsuite/abrp/PairingClient.java @@ -0,0 +1,122 @@ +package com.evsuite.abrp; + +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; + +/** + * The car's side of pairing with the server (an OAuth device-authorization flow): ask for a + * pairing request, show its code as a QR code, then poll until a phone has approved it and + * collect this car's own token. Blocking calls — run them off the main thread. + */ +final class PairingClient { + + private static final int TIMEOUT_MS = 8_000; + + /** A pairing request as the server opened it. */ + static final class Request { + final String deviceCode; + final String userCode; + final String verificationUri; + final String verificationUriComplete; + final long expiresAtMs; + final long intervalMs; + + Request(String deviceCode, String userCode, String verificationUri, String verificationUriComplete, + long expiresAtMs, long intervalMs) { + this.deviceCode = deviceCode; + this.userCode = userCode; + this.verificationUri = verificationUri; + this.verificationUriComplete = verificationUriComplete; + this.expiresAtMs = expiresAtMs; + this.intervalMs = intervalMs; + } + } + + enum Outcome { PENDING, APPROVED, DENIED, EXPIRED } + + static final class PollResult { + final Outcome outcome; + final String token; + + PollResult(Outcome outcome, String token) { + this.outcome = outcome; + this.token = token; + } + } + + /** Thrown when pairing is switched off on the server (no APPROVAL_PASSWORD). */ + static final class NotEnabledException extends Exception { } + + private PairingClient() { } + + static Request start(String serverUrl, String installationId) throws Exception { + Response r = post(serverUrl + "/v1/pair/start", + new JSONObject().put("installationId", installationId)); + if (r.status == 404) throw new NotEnabledException(); + if (r.status != 201) throw new java.io.IOException("HTTP " + r.status); + JSONObject json = new JSONObject(r.body); + long now = System.currentTimeMillis(); + return new Request( + json.getString("deviceCode"), + json.getString("userCode"), + json.getString("verificationUri"), + json.getString("verificationUriComplete"), + now + json.optLong("expiresIn", 600) * 1000L, + Math.max(2, json.optLong("interval", 5)) * 1000L); + } + + static PollResult poll(String serverUrl, String deviceCode) throws Exception { + Response r = post(serverUrl + "/v1/pair/poll", new JSONObject().put("deviceCode", deviceCode)); + switch (r.status) { + case 200: return new PollResult(Outcome.APPROVED, new JSONObject(r.body).getString("token")); + case 202: return new PollResult(Outcome.PENDING, null); + case 403: return new PollResult(Outcome.DENIED, null); + case 410: return new PollResult(Outcome.EXPIRED, null); + default: throw new java.io.IOException("HTTP " + r.status); + } + } + + private static final class Response { + final int status; + final String body; + + Response(int status, String body) { + this.status = status; + this.body = body; + } + } + + private static Response post(String url, JSONObject json) throws Exception { + byte[] body = json.toString().getBytes(StandardCharsets.UTF_8); + HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection(); + try { + connection.setRequestMethod("POST"); + connection.setConnectTimeout(TIMEOUT_MS); + connection.setReadTimeout(TIMEOUT_MS); + connection.setDoOutput(true); + connection.setFixedLengthStreamingMode(body.length); + connection.setRequestProperty("Content-Type", "application/json"); + try (OutputStream out = connection.getOutputStream()) { out.write(body); } + int status = connection.getResponseCode(); + InputStream in = status < 400 ? connection.getInputStream() : connection.getErrorStream(); + String text = ""; + if (in != null) { + try (InputStream stream = in) { + java.io.ByteArrayOutputStream buf = new java.io.ByteArrayOutputStream(); + byte[] chunk = new byte[2048]; + int n; + while ((n = stream.read(chunk)) != -1 && buf.size() < 16 * 1024) buf.write(chunk, 0, n); + text = buf.toString(StandardCharsets.UTF_8.name()); + } + } + return new Response(status, text); + } finally { + connection.disconnect(); + } + } +} diff --git a/app/src/main/java/com/evsuite/abrp/ServerConfig.java b/app/src/main/java/com/evsuite/abrp/ServerConfig.java index af36f73..555685a 100644 --- a/app/src/main/java/com/evsuite/abrp/ServerConfig.java +++ b/app/src/main/java/com/evsuite/abrp/ServerConfig.java @@ -27,6 +27,9 @@ final class ServerConfig { "^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", Pattern.CASE_INSENSITIVE); + /** Pre-filled so pairing needs no typing in the car; can still be changed. */ + static final String DEFAULT_URL = "https://car.stinnesbeck.com"; + /** The server refuses shorter ingest tokens outright. */ static final int MIN_TOKEN_LENGTH = 24; @@ -58,7 +61,7 @@ final class ServerConfig { id = UUID.randomUUID().toString(); prefs.edit().putString(KEY_INSTALLATION_ID, id).apply(); } - current = new ServerConfig(prefs.getString(KEY_URL, ""), prefs.getString(KEY_TOKEN, ""), id); + current = new ServerConfig(prefs.getString(KEY_URL, DEFAULT_URL), prefs.getString(KEY_TOKEN, ""), id); return current; } } diff --git a/app/src/main/res/layout/pane_server.xml b/app/src/main/res/layout/pane_server.xml index 455efa2..7eb14d7 100644 --- a/app/src/main/res/layout/pane_server.xml +++ b/app/src/main/res/layout/pane_server.xml @@ -6,6 +6,8 @@ + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ef89248..67b4320 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -55,6 +55,19 @@ Installation ID Your data is stored under this ID. Keep it when reinstalling. Enter an https:// URL + Pair with phone + Shows a QR code: scan it with your phone and approve with the approval password. The token below is then filled in for you. + Pair with phone + Scan with your phone, or open %1$s and enter this code: + Waiting for approval… + Contacting the server… + Paired — this car now has its own token + Pairing was denied on the phone + The code expired — press Pair with phone again + Pairing is not enabled on the server (no APPROVAL_PASSWORD) + Pairing failed: %1$s + Cancel + QR code to approve this car on your phone The token must be at least 24 characters, without spaces Not a valid ID (UUID) Server settings saved diff --git a/app/src/test/java/com/evsuite/abrp/PairingClientTest.java b/app/src/test/java/com/evsuite/abrp/PairingClientTest.java new file mode 100644 index 0000000..0bc8006 --- /dev/null +++ b/app/src/test/java/com/evsuite/abrp/PairingClientTest.java @@ -0,0 +1,132 @@ +package com.evsuite.abrp; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.util.ArrayDeque; +import java.util.Deque; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +/** The car's pairing client against a stand-in that answers like the Go server. */ +public class PairingClientTest { + + private ServerSocket socket; + private Thread thread; + private String url; + private final Deque pollStatuses = new ArrayDeque<>(); + private volatile int startStatus = 201; + private volatile String lastStartBody; + + @Before + public void setUp() throws Exception { + socket = new ServerSocket(0, 10, InetAddress.getLoopbackAddress()); + url = "http://127.0.0.1:" + socket.getLocalPort(); + thread = new Thread(() -> { + while (!socket.isClosed()) { + try (Socket client = socket.accept()) { + handle(client); + } catch (Exception ignored) { + // socket closed in tearDown + } + } + }); + thread.start(); + } + + @After + public void tearDown() throws Exception { + socket.close(); + thread.join(2000); + } + + /** Minimal HTTP/1.1: one request per connection, body by Content-Length. */ + private void handle(Socket client) throws Exception { + BufferedReader in = new BufferedReader(new InputStreamReader(client.getInputStream(), StandardCharsets.UTF_8)); + String requestLine = in.readLine(); + int length = 0; + for (String line = in.readLine(); line != null && !line.isEmpty(); line = in.readLine()) { + if (line.toLowerCase(java.util.Locale.US).startsWith("content-length:")) { + length = Integer.parseInt(line.substring(15).trim()); + } + } + char[] body = new char[length]; + int read = 0; + while (read < length) read += in.read(body, read, length - read); + String path = requestLine.split(" ")[1]; + int status; + String response; + if (path.equals("/v1/pair/start")) { + lastStartBody = new String(body); + status = startStatus; + response = status == 201 + ? "{\"deviceCode\":\"dev-code\",\"userCode\":\"K7F-3QX\",\"verificationUri\":\"https://car.example.com/pair\"," + + "\"verificationUriComplete\":\"https://car.example.com/pair?code=K7F-3QX\",\"expiresIn\":600,\"interval\":5}" + : "{\"error\":\"pairing is not enabled\"}"; + } else { + Integer next; + synchronized (pollStatuses) { next = pollStatuses.poll(); } + status = next == null ? 410 : next; + response = status == 200 ? "{\"token\":\"car-token-0123456789abcdefghij\",\"deviceId\":\"d1\"}" + : status == 202 ? "{\"status\":\"authorization_pending\"}" : "{\"error\":\"x\"}"; + } + byte[] bytes = response.getBytes(StandardCharsets.UTF_8); + OutputStream out = client.getOutputStream(); + out.write(("HTTP/1.1 " + status + " X\r\nContent-Type: application/json\r\nContent-Length: " + bytes.length + + "\r\nConnection: close\r\n\r\n").getBytes(StandardCharsets.UTF_8)); + out.write(bytes); + out.flush(); + } + + @Test + public void startSendsTheInstallationAndReadsTheCodes() throws Exception { + long before = System.currentTimeMillis(); + PairingClient.Request request = PairingClient.start(url, "6f1a2b3c-4d5e-4f60-8a9b-0c1d2e3f4a5b"); + assertTrue(lastStartBody, lastStartBody.contains("6f1a2b3c-4d5e-4f60-8a9b-0c1d2e3f4a5b")); + assertEquals("dev-code", request.deviceCode); + assertEquals("K7F-3QX", request.userCode); + assertEquals("https://car.example.com/pair?code=K7F-3QX", request.verificationUriComplete); + assertEquals(5000L, request.intervalMs); + assertTrue(request.expiresAtMs >= before + 600_000L); + } + + @Test + public void pollMapsEveryServerAnswer() throws Exception { + synchronized (pollStatuses) { + pollStatuses.add(202); + pollStatuses.add(200); + pollStatuses.add(403); + pollStatuses.add(410); + } + PairingClient.PollResult pending = PairingClient.poll(url, "dev-code"); + assertEquals(PairingClient.Outcome.PENDING, pending.outcome); + assertNull(pending.token); + PairingClient.PollResult approved = PairingClient.poll(url, "dev-code"); + assertEquals(PairingClient.Outcome.APPROVED, approved.outcome); + assertEquals("car-token-0123456789abcdefghij", approved.token); + assertEquals(PairingClient.Outcome.DENIED, PairingClient.poll(url, "dev-code").outcome); + assertEquals(PairingClient.Outcome.EXPIRED, PairingClient.poll(url, "dev-code").outcome); + } + + @Test + public void pairingSwitchedOffOnTheServerIsReportedAsSuch() throws Exception { + startStatus = 404; + try { + PairingClient.start(url, "6f1a2b3c-4d5e-4f60-8a9b-0c1d2e3f4a5b"); + fail("expected NotEnabledException"); + } catch (PairingClient.NotEnabledException expected) { + // the dialog says pairing is off instead of a generic failure + } + } +} From 59ee2cae3b9ab80c76dfb91e658c4dc96ad4fd02 Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Sat, 17 Oct 2026 16:16:52 +0200 Subject: [PATCH 24/79] Deploy server 2.1.0; keep filled-in secrets out of git - kustomization pins mg4-server 2.1.0 by digest; server.yaml sets PUBLIC_URL for the pairing QR code; the secrets template gains APPROVAL_PASSWORD. - Filled-in Secrets now go into k8s/secrets.yaml, which is gitignored; the template stays on placeholders. This follows a leak where real values typed into the template were committed and pushed (amended and force-pushed since; both values regenerated). - SPECS.md records pairing and the incident. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki --- .gitignore | 3 +++ SPECS.md | 17 ++++++++++++----- k8s/README.md | 12 +++++++----- k8s/kustomization.yaml | 4 ++-- k8s/secrets.example.yaml | 7 +++++-- k8s/server.yaml | 6 +++++- 6 files changed, 34 insertions(+), 15 deletions(-) diff --git a/.gitignore b/.gitignore index aab93a6..c0a29a9 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,6 @@ mise.local.toml /EVHardware/build /EVHardware/.gradle /EVHardware/**/build + +# Filled-in Kubernetes Secrets (copy of k8s/secrets.example.yaml). Never commit. +/k8s/secrets.yaml diff --git a/SPECS.md b/SPECS.md index 1f77b4e..95c54bf 100644 --- a/SPECS.md +++ b/SPECS.md @@ -25,7 +25,9 @@ car ──live telemetry───▶ car.stinnesbeck.com (Go) ──▶ ABRP ap - **Dashboards:** Grafana, provisioned from `k8s/grafana/`: Live (stats, SoC/range, power/speed, route map, temperatures, tyres), Consumption (period totals, lifetime, SoH, per-day energy, distance and efficiency) and Charging (totals, session table, curves). Prices in euro. -- **Car app:** Server tab (URL, ingest token, installation id, config import); live telemetry +- **Pairing:** the car shows a QR code, a phone approves it with the approval password, and the car + gets its own token (RFC 8628-style; `server/README.md`). The shared `INGEST_TOKEN` still works. +- **Car app:** Server tab (URL, pairing by QR, ingest token, installation id, config import); live telemetry every 15 s while driving or charging; consumption chunks and charging sessions as before. - **Deployment:** `k8s/`, plain manifests. **Nothing is ever applied to the cluster by us** — Nils applies them (`k8s/README.md`). TLS comes from Traefik's default certificate. @@ -36,12 +38,12 @@ car ──live telemetry───▶ car.stinnesbeck.com (Go) ──▶ ABRP ap - [x] End-to-end in podman (server + InfluxDB 2.7.12 + Grafana 13.2.3): every endpoint, retry idempotency, all 25 dashboard queries return data, dashboards rendered and checked. - [x] Manifests validated offline (including Traefik CRDs). **Not applied.** -- [x] Car app 2.3.0: unit tests and lint pass; **not yet tried on the car**. -- [x] Image `mg4-server:2.0.0` pushed to git.stinnesbeck.com (public; pinned by digest - `sha256:6feea777…` in `k8s/kustomization.yaml`). +- [x] Car app 2.4.0 (pairing by QR): unit tests pass; **not yet tried on the car**. +- [x] Image `mg4-server:2.1.0` (with pairing) pushed to git.stinnesbeck.com (public; pinned by + digest `sha256:e5f6e2d2…` in `k8s/kustomization.yaml`). - [ ] Check in `k8s/ingress.yaml`: the Traefik ingress class (placeholder `traefik-external`) and Grafana's host (placeholder `car-grafana.stinnesbeck.com`). -- [ ] First drive with 2.3.0: telemetry arrives in InfluxDB, Grafana and ABRP. +- [ ] First pairing and drive with 2.4.0: telemetry arrives in InfluxDB, Grafana and ABRP. ## To do @@ -84,6 +86,11 @@ car ──live telemetry───▶ car.stinnesbeck.com (Go) ──▶ ABRP ap - 2026-10-17 — Server rewritten in Go (API only); dashboards moved to Grafana with provisioned InfluxDB datasource and three dashboards; the Next.js app (`web/`) removed. Per-host cert-manager certificates dropped in favour of Traefik's default certificate. +- 2026-10-17 — Pairing by QR code: server 2.1.0 issues per-car tokens after approval on a phone; + app 2.4.0 shows the QR code and collects its token. Tested end to end against InfluxDB. +- 2026-10-17 — Incident: a commit swept the filled-in `secrets.example.yaml` (InfluxDB admin token, + INGEST_TOKEN) into git and pushed it. Commit amended and force-pushed; both values regenerated in + the now gitignored `k8s/secrets.yaml`. Filled-in secrets live only there from now on. - 2026-10-17 — Temporary on-screen diagnostics (b2a5b32) to debug blank values without adb; removed again (1002962) once values showed on the car. Revert 1002962 to bring them back. diff --git a/k8s/README.md b/k8s/README.md index 83bd3a7..8539e58 100644 --- a/k8s/README.md +++ b/k8s/README.md @@ -11,7 +11,8 @@ is applied automatically. Apply them yourself. | `grafana.yaml` | Grafana 13.2.3 Deployment (1Gi PVC), Service; datasource and dashboards provisioned | | `grafana/` | The provisioned InfluxDB datasource and the MG4 dashboards (Live, Consumption, Charging) | | `ingress.yaml` | Traefik `IngressRoute`s for car.stinnesbeck.com and Grafana, TLS from Traefik's default certificate | -| `secrets.example.yaml` | **Template** for the Secrets; not in `kustomization.yaml` | +| `secrets.example.yaml` | **Template** for the Secrets; placeholders only, not in `kustomization.yaml` | +| `secrets.yaml` | Your filled-in copy — **gitignored**, never committed | | `kustomization.yaml` | Everything except the Secrets; pins the server image, generates the Grafana ConfigMaps | ## Before the first apply @@ -19,9 +20,10 @@ is applied automatically. Apply them yourself. 1. **Check `ingress.yaml`:** the ingress class (placeholder `traefik-external` for the public Traefik) and Grafana's host (placeholder `car-grafana.stinnesbeck.com`). Both hosts must be covered by Traefik's default certificate. -2. **Create the Secrets.** Copy `secrets.example.yaml` outside the repository, replace every - `CHANGE-ME` (`openssl rand -hex 32`), then apply the copy: `influxdb-auth`, `mg4-server` - (ingest token, your ABRP API key and user token) and `grafana-admin`. +2. **Create the Secrets.** `cp k8s/secrets.example.yaml k8s/secrets.yaml` (gitignored), replace + every `CHANGE-ME` there (`openssl rand -hex 32`): `influxdb-auth`, `mg4-server` (ingest token, + approval password for pairing, your ABRP API key and user token) and `grafana-admin`. Keep + `secrets.example.yaml` itself on placeholders only. 3. **Registry access.** The `mg4-server` package on git.stinnesbeck.com is public, so no pull secret is needed. If you make it private, add an `imagePullSecrets` entry to `server.yaml`. @@ -29,7 +31,7 @@ is applied automatically. Apply them yourself. ```sh kubectl apply -f k8s/namespace.yaml -kubectl apply -f +kubectl apply -f k8s/secrets.yaml kubectl apply -k k8s/ ``` diff --git a/k8s/kustomization.yaml b/k8s/kustomization.yaml index 61be4c7..bd41dd3 100644 --- a/k8s/kustomization.yaml +++ b/k8s/kustomization.yaml @@ -10,8 +10,8 @@ resources: - ingress.yaml images: - name: git.stinnesbeck.com/nils/mg4-server - # 2.0.0, pinned by digest so a re-pushed tag cannot change what runs. - digest: sha256:6feea777c514b9488ffb0b49fad7c15423ee06f0e0e8d609cf31eb85755da815 + # 2.1.0, pinned by digest so a re-pushed tag cannot change what runs. + digest: sha256:e5f6e2d2ef9dc2650055a0823a3f86d8903e46918a76e990540482edf819766d generatorOptions: labels: app.kubernetes.io/part-of: mg4 diff --git a/k8s/secrets.example.yaml b/k8s/secrets.example.yaml index a60b124..6bea4d4 100644 --- a/k8s/secrets.example.yaml +++ b/k8s/secrets.example.yaml @@ -1,5 +1,5 @@ -# TEMPLATE ONLY — not part of kustomization.yaml. Copy it outside the repository, fill in -# real values, and apply that copy yourself. Never commit the filled-in file. +# TEMPLATE ONLY — not part of kustomization.yaml. Copy it to k8s/secrets.yaml (gitignored), +# fill in real values there, and apply that file yourself. Never put real values in here. # # openssl rand -hex 32 for every token and password below apiVersion: v1 @@ -22,6 +22,9 @@ stringData: # The car's write token, entered in the app. At least 24 characters, or the server # refuses to start. INGEST_TOKEN: CHANGE-ME-openssl-rand-hex-32 + # Approves a car on your phone when it pairs by QR code (at least 12 characters). Leave it + # empty to switch pairing off; then only the INGEST_TOKEN above works. + APPROVAL_PASSWORD: CHANGE-ME-at-least-12-characters # Your ABRP credentials. Leave either empty to switch forwarding off. ABRP_API_KEY: CHANGE-ME ABRP_USER_TOKEN: CHANGE-ME diff --git a/k8s/server.yaml b/k8s/server.yaml index 1f20507..80b7b5f 100644 --- a/k8s/server.yaml +++ b/k8s/server.yaml @@ -40,12 +40,16 @@ spec: value: mg4 - name: INFLUX_BUCKET value: mg4 + # Where a phone reaches the server; the pairing QR code points here. + - name: PUBLIC_URL + value: https://car.stinnesbeck.com - name: INFLUX_TOKEN valueFrom: secretKeyRef: name: influxdb-auth key: admin-token - # INGEST_TOKEN, ABRP_API_KEY, ABRP_USER_TOKEN, ABRP_MIN_INTERVAL_SECONDS. + # INGEST_TOKEN, APPROVAL_PASSWORD, ABRP_API_KEY, ABRP_USER_TOKEN, + # ABRP_MIN_INTERVAL_SECONDS. envFrom: - secretRef: name: mg4-server From 315586f94cbb1a9afbec5501a08b4b4d37d98d7c Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Sat, 17 Oct 2026 18:08:35 +0200 Subject: [PATCH 25/79] Move the server to car.k8s.stinnesbeck.com The server's host is car.k8s.stinnesbeck.com, not car.stinnesbeck.com: IngressRoute, PUBLIC_URL (so the pairing QR code points there), the app's pre-filled server URL and hint, and the docs. Grafana's placeholder host follows to car-grafana.k8s.stinnesbeck.com, so one *.k8s.stinnesbeck.com default certificate covers both. App 2.4.1 for the new default; the server image is unchanged (its URL comes from the manifest). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki --- SPECS.md | 8 ++++---- app/build.gradle | 4 ++-- app/src/main/java/com/evsuite/abrp/ConfigImport.java | 2 +- app/src/main/java/com/evsuite/abrp/ServerConfig.java | 2 +- app/src/main/res/values/strings.xml | 2 +- k8s/README.md | 6 +++--- k8s/grafana.yaml | 2 +- k8s/ingress.yaml | 4 ++-- k8s/server.yaml | 2 +- server/README.md | 6 +++--- 10 files changed, 19 insertions(+), 19 deletions(-) diff --git a/SPECS.md b/SPECS.md index 95c54bf..0a4df47 100644 --- a/SPECS.md +++ b/SPECS.md @@ -7,17 +7,17 @@ what has been changed, and what is still to do. - Upstream: https://github.com/fatihdonmezdev/FatihsMG4 (itself a fork of malys / EVABRPUploader and Leon Kernan / ABRP_Uploader). Credited in the app's About dialog. -## Architecture: car server at car.stinnesbeck.com +## Architecture: car server at car.k8s.stinnesbeck.com The car sends everything to our own server. The server stores it in InfluxDB 2 and forwards live telemetry to ABRP, so the ABRP token lives only on the server, never in the APK. Grafana reads InfluxDB directly for the dashboards. ``` -car ──live telemetry───▶ car.stinnesbeck.com (Go) ──▶ ABRP api.iternio.com/1/tlm/send +car ──live telemetry───▶ car.k8s.stinnesbeck.com (Go) ──▶ ABRP api.iternio.com/1/tlm/send ──daily chunks─────▶ │ ──charging sessions▶ ▼ - InfluxDB 2 ◀── Grafana (car-grafana.stinnesbeck.com) + InfluxDB 2 ◀── Grafana (car-grafana.k8s.stinnesbeck.com) ``` - **Server:** `server/`, Go 1.26, API only — see `server/README.md` for endpoints, configuration @@ -42,7 +42,7 @@ car ──live telemetry───▶ car.stinnesbeck.com (Go) ──▶ ABRP ap - [x] Image `mg4-server:2.1.0` (with pairing) pushed to git.stinnesbeck.com (public; pinned by digest `sha256:e5f6e2d2…` in `k8s/kustomization.yaml`). - [ ] Check in `k8s/ingress.yaml`: the Traefik ingress class (placeholder `traefik-external`) and - Grafana's host (placeholder `car-grafana.stinnesbeck.com`). + Grafana's host (placeholder `car-grafana.k8s.stinnesbeck.com`). - [ ] First pairing and drive with 2.4.0: telemetry arrives in InfluxDB, Grafana and ABRP. ## To do diff --git a/app/build.gradle b/app/build.gradle index da13714..1aa3515 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -34,8 +34,8 @@ android { applicationId "com.stinnesbeck.nilsmg4" minSdk 27 targetSdk 36 - versionCode 58 - versionName "2.4.0" + versionCode 59 + versionName "2.4.1" // Nothing about the server ships in the APK: its URL and ingest token are entered in // the app's Server tab (ServerConfig), and the database is only reachable by the server. diff --git a/app/src/main/java/com/evsuite/abrp/ConfigImport.java b/app/src/main/java/com/evsuite/abrp/ConfigImport.java index 0495edd..cbea2b1 100644 --- a/app/src/main/java/com/evsuite/abrp/ConfigImport.java +++ b/app/src/main/java/com/evsuite/abrp/ConfigImport.java @@ -7,7 +7,7 @@ package com.evsuite.abrp; * *
      *   # Nils' MG4 config
    - *   server_url      = https://car.stinnesbeck.com
    + *   server_url      = https://car.k8s.stinnesbeck.com
      *   token           = 0123abcd...            (the server's INGEST_TOKEN)
      *   installation_id = 6f1a2b3c-4d5e-4f60-8a9b-0c1d2e3f4a5b
      *   interval_sec    = 60
    diff --git a/app/src/main/java/com/evsuite/abrp/ServerConfig.java b/app/src/main/java/com/evsuite/abrp/ServerConfig.java
    index 555685a..0f9fa2e 100644
    --- a/app/src/main/java/com/evsuite/abrp/ServerConfig.java
    +++ b/app/src/main/java/com/evsuite/abrp/ServerConfig.java
    @@ -28,7 +28,7 @@ final class ServerConfig {
                 Pattern.CASE_INSENSITIVE);
     
         /** Pre-filled so pairing needs no typing in the car; can still be changed. */
    -    static final String DEFAULT_URL = "https://car.stinnesbeck.com";
    +    static final String DEFAULT_URL = "https://car.k8s.stinnesbeck.com";
     
         /** The server refuses shorter ingest tokens outright. */
         static final int MIN_TOKEN_LENGTH = 24;
    diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
    index 67b4320..cd1beb8 100644
    --- a/app/src/main/res/values/strings.xml
    +++ b/app/src/main/res/values/strings.xml
    @@ -49,7 +49,7 @@
         
         Car server
         Server URL
    -    For example https://car.stinnesbeck.com
    +    For example https://car.k8s.stinnesbeck.com
         Ingest token
         The server\'s INGEST_TOKEN, at least 24 characters
         Installation ID
    diff --git a/k8s/README.md b/k8s/README.md
    index 8539e58..d448687 100644
    --- a/k8s/README.md
    +++ b/k8s/README.md
    @@ -1,4 +1,4 @@
    -# Kubernetes manifests — car.stinnesbeck.com
    +# Kubernetes manifests — car.k8s.stinnesbeck.com
     
     Plain manifests for the car server, InfluxDB 2 and Grafana. They are only files: nothing here
     is applied automatically. Apply them yourself.
    @@ -10,7 +10,7 @@ is applied automatically. Apply them yourself.
     | `server.yaml` | Go server Deployment (1 replica, non-root, read-only root filesystem) and Service |
     | `grafana.yaml` | Grafana 13.2.3 Deployment (1Gi PVC), Service; datasource and dashboards provisioned |
     | `grafana/` | The provisioned InfluxDB datasource and the MG4 dashboards (Live, Consumption, Charging) |
    -| `ingress.yaml` | Traefik `IngressRoute`s for car.stinnesbeck.com and Grafana, TLS from Traefik's default certificate |
    +| `ingress.yaml` | Traefik `IngressRoute`s for car.k8s.stinnesbeck.com and Grafana, TLS from Traefik's default certificate |
     | `secrets.example.yaml` | **Template** for the Secrets; placeholders only, not in `kustomization.yaml` |
     | `secrets.yaml` | Your filled-in copy — **gitignored**, never committed |
     | `kustomization.yaml` | Everything except the Secrets; pins the server image, generates the Grafana ConfigMaps |
    @@ -18,7 +18,7 @@ is applied automatically. Apply them yourself.
     ## Before the first apply
     
     1. **Check `ingress.yaml`:** the ingress class (placeholder `traefik-external` for the public
    -   Traefik) and Grafana's host (placeholder `car-grafana.stinnesbeck.com`). Both hosts must be
    +   Traefik) and Grafana's host (placeholder `car-grafana.k8s.stinnesbeck.com`). Both hosts must be
        covered by Traefik's default certificate.
     2. **Create the Secrets.** `cp k8s/secrets.example.yaml k8s/secrets.yaml` (gitignored), replace
        every `CHANGE-ME` there (`openssl rand -hex 32`): `influxdb-auth`, `mg4-server` (ingest token,
    diff --git a/k8s/grafana.yaml b/k8s/grafana.yaml
    index 3b8724d..c3bb156 100644
    --- a/k8s/grafana.yaml
    +++ b/k8s/grafana.yaml
    @@ -52,7 +52,7 @@ spec:
                   containerPort: 3000
               env:
                 - name: GF_SERVER_ROOT_URL
    -              value: https://car-grafana.stinnesbeck.com
    +              value: https://car-grafana.k8s.stinnesbeck.com
                 - name: GF_SECURITY_ADMIN_USER
                   value: admin
                 - name: GF_SECURITY_ADMIN_PASSWORD
    diff --git a/k8s/ingress.yaml b/k8s/ingress.yaml
    index 1e2fbf5..9e86840 100644
    --- a/k8s/ingress.yaml
    +++ b/k8s/ingress.yaml
    @@ -14,7 +14,7 @@ spec:
         - websecure
       routes:
         - kind: Rule
    -      match: Host(`car.stinnesbeck.com`)
    +      match: Host(`car.k8s.stinnesbeck.com`)
           services:
             - kind: Service
               name: mg4-server
    @@ -35,7 +35,7 @@ spec:
       routes:
         - kind: Rule
           # CHECK: placeholder host. It must be covered by Traefik's default certificate.
    -      match: Host(`car-grafana.stinnesbeck.com`)
    +      match: Host(`car-grafana.k8s.stinnesbeck.com`)
           services:
             - kind: Service
               name: grafana
    diff --git a/k8s/server.yaml b/k8s/server.yaml
    index 80b7b5f..7fc4743 100644
    --- a/k8s/server.yaml
    +++ b/k8s/server.yaml
    @@ -42,7 +42,7 @@ spec:
                   value: mg4
                 # Where a phone reaches the server; the pairing QR code points here.
                 - name: PUBLIC_URL
    -              value: https://car.stinnesbeck.com
    +              value: https://car.k8s.stinnesbeck.com
                 - name: INFLUX_TOKEN
                   valueFrom:
                     secretKeyRef:
    diff --git a/server/README.md b/server/README.md
    index 647d2cd..53a2fd9 100644
    --- a/server/README.md
    +++ b/server/README.md
    @@ -1,6 +1,6 @@
     # Nils' MG4 — car server
     
    -A small Go service at car.stinnesbeck.com: the API the car writes to, and the ABRP
    +A small Go service at car.k8s.stinnesbeck.com: the API the car writes to, and the ABRP
     forwarder. Data lives in InfluxDB 2; Grafana reads it from there for the dashboards
     (`../k8s/grafana/`).
     
    @@ -61,8 +61,8 @@ the same bucket), and the device is persisted before the car can collect the tok
     restart never loses a paired car. Manage cars with the approval password:
     
     ```sh
    -curl -H "Authorization: Bearer $APPROVAL_PASSWORD" https://car.stinnesbeck.com/v1/devices
    -curl -X POST -H "Authorization: Bearer $APPROVAL_PASSWORD" https://car.stinnesbeck.com/v1/devices//revoke
    +curl -H "Authorization: Bearer $APPROVAL_PASSWORD" https://car.k8s.stinnesbeck.com/v1/devices
    +curl -X POST -H "Authorization: Bearer $APPROVAL_PASSWORD" https://car.k8s.stinnesbeck.com/v1/devices//revoke
     ```
     
     ## Storage
    
    From 6f57351866c193f90cc5ca34372cb3f9e0861e76 Mon Sep 17 00:00:00 2001
    From: Nils Stinnesbeck 
    Date: Sat, 17 Oct 2026 18:16:52 +0200
    Subject: [PATCH 26/79] Use the traefik-internal ingress class
    
    Both IngressRoutes (server and Grafana) now use traefik-internal, like
    the rest of *.k8s.stinnesbeck.com, instead of the traefik-external
    placeholder. README and SPECS updated.
    
    Co-Authored-By: Claude Opus 5.5 
    Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
    ---
     SPECS.md         |  3 +--
     k8s/README.md    |  4 ++--
     k8s/ingress.yaml | 10 ++++------
     3 files changed, 7 insertions(+), 10 deletions(-)
    
    diff --git a/SPECS.md b/SPECS.md
    index 0a4df47..7272a3c 100644
    --- a/SPECS.md
    +++ b/SPECS.md
    @@ -41,8 +41,7 @@ car ──live telemetry───▶ car.k8s.stinnesbeck.com (Go) ──▶ ABRP
     - [x] Car app 2.4.0 (pairing by QR): unit tests pass; **not yet tried on the car**.
     - [x] Image `mg4-server:2.1.0` (with pairing) pushed to git.stinnesbeck.com (public; pinned by
           digest `sha256:e5f6e2d2…` in `k8s/kustomization.yaml`).
    -- [ ] Check in `k8s/ingress.yaml`: the Traefik ingress class (placeholder `traefik-external`) and
    -      Grafana's host (placeholder `car-grafana.k8s.stinnesbeck.com`).
    +- [ ] Check Grafana's host in `k8s/ingress.yaml` (placeholder `car-grafana.k8s.stinnesbeck.com`).
     - [ ] First pairing and drive with 2.4.0: telemetry arrives in InfluxDB, Grafana and ABRP.
     
     ## To do
    diff --git a/k8s/README.md b/k8s/README.md
    index d448687..27b4bd2 100644
    --- a/k8s/README.md
    +++ b/k8s/README.md
    @@ -17,8 +17,8 @@ is applied automatically. Apply them yourself.
     
     ## Before the first apply
     
    -1. **Check `ingress.yaml`:** the ingress class (placeholder `traefik-external` for the public
    -   Traefik) and Grafana's host (placeholder `car-grafana.k8s.stinnesbeck.com`). Both hosts must be
    +1. **Check `ingress.yaml`:** Grafana's host (placeholder `car-grafana.k8s.stinnesbeck.com`).
    +   Both routes use the `traefik-internal` class, and both hosts must be
        covered by Traefik's default certificate.
     2. **Create the Secrets.** `cp k8s/secrets.example.yaml k8s/secrets.yaml` (gitignored), replace
        every `CHANGE-ME` there (`openssl rand -hex 32`): `influxdb-auth`, `mg4-server` (ingest token,
    diff --git a/k8s/ingress.yaml b/k8s/ingress.yaml
    index 9e86840..335dc03 100644
    --- a/k8s/ingress.yaml
    +++ b/k8s/ingress.yaml
    @@ -1,14 +1,12 @@
    -# Public routes. TLS uses Traefik's default certificate, so there is no per-host
    -# Certificate here.
    +# Routes on the traefik-internal class, like the rest of *.k8s.stinnesbeck.com. TLS uses
    +# Traefik's default certificate, so there is no per-host Certificate here.
     apiVersion: traefik.io/v1alpha1
     kind: IngressRoute
     metadata:
       name: mg4-server
       namespace: mg4
       annotations:
    -    # CHECK: the class of your public-facing Traefik. Your internal example uses
    -    # "traefik-internal"; these hosts must be served by the external one.
    -    kubernetes.io/ingress.class: traefik-external
    +    kubernetes.io/ingress.class: traefik-internal
     spec:
       entryPoints:
         - websecure
    @@ -28,7 +26,7 @@ metadata:
       name: grafana
       namespace: mg4
       annotations:
    -    kubernetes.io/ingress.class: traefik-external # CHECK, as above
    +    kubernetes.io/ingress.class: traefik-internal
     spec:
       entryPoints:
         - websecure
    
    From 8a5813fa16ba7ee366ed3a1c50885507ddde2852 Mon Sep 17 00:00:00 2001
    From: Nils Stinnesbeck 
    Date: Sat, 17 Oct 2026 20:56:02 +0200
    Subject: [PATCH 27/79] App 2.4.2: pairing dialog shows errors instead of
     closing
    
    - On a failure the pairing dialog stays open and shows the reason in
      red (Cancel becomes Close); before, it closed at once and the reason
      was only in the Server tab's status line.
    - "Pairing is not enabled" only when the server says so. Any other 404
      (a server older than 2.1.0, or a proxy's page) is reported as a
      server without pairing.
    - Pairing and Test connection name the problem: a server name that
      does not resolve (Test connection used to say "No internet
      connection"), a server that does not answer, or the HTTP status.
    
    Co-Authored-By: Claude Opus 5.5 
    Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
    ---
     app/build.gradle                              |  4 +-
     .../java/com/evsuite/abrp/MainActivity.java   | 49 +++++++++++++++++--
     .../java/com/evsuite/abrp/PairingClient.java  | 21 ++++++--
     app/src/main/res/values/strings.xml           |  5 ++
     .../com/evsuite/abrp/PairingClientTest.java   | 16 +++++-
     k8s/{ => secrets}/secrets.example.yaml        |  0
     6 files changed, 84 insertions(+), 11 deletions(-)
     rename k8s/{ => secrets}/secrets.example.yaml (100%)
    
    diff --git a/app/build.gradle b/app/build.gradle
    index 1aa3515..c2c0330 100644
    --- a/app/build.gradle
    +++ b/app/build.gradle
    @@ -34,8 +34,8 @@ android {
             applicationId "com.stinnesbeck.nilsmg4"
             minSdk 27
             targetSdk 36
    -        versionCode 59
    -        versionName "2.4.1"
    +        versionCode 60
    +        versionName "2.4.2"
     
             // Nothing about the server ships in the APK: its URL and ingest token are entered in
             // the app's Server tab (ServerConfig), and the database is only reachable by the server.
    diff --git a/app/src/main/java/com/evsuite/abrp/MainActivity.java b/app/src/main/java/com/evsuite/abrp/MainActivity.java
    index cc225e7..3fe59c7 100644
    --- a/app/src/main/java/com/evsuite/abrp/MainActivity.java
    +++ b/app/src/main/java/com/evsuite/abrp/MainActivity.java
    @@ -455,7 +455,7 @@ public class MainActivity extends AppCompatActivity {
                     message = getString(R.string.conn_err_timeout);
                     color = COLOR_ERROR;
                 } catch (Exception e) {
    -                message = getString(R.string.conn_err_no_internet);
    +                message = describeNetworkError(e, url);
                     color = COLOR_ERROR;
                 }
                 final String m = message;
    @@ -514,6 +514,9 @@ public class MainActivity extends AppCompatActivity {
                     .setView(panel)
                     .setNegativeButton(R.string.pair_cancel, null)
                     .create();
    +        // Shown only once the server has answered, so a failure leaves no empty QR frame.
    +        qr.setVisibility(View.GONE);
    +        code.setVisibility(View.GONE);
             dialog.setOnDismissListener(d -> cancelled[0] = true);
             dialog.show();
     
    @@ -525,8 +528,12 @@ public class MainActivity extends AppCompatActivity {
                     android.graphics.Bitmap bitmap = QrCode.generate(request.verificationUriComplete, 416);
                     runOnUiThread(() -> {
                         instructions.setText(getString(R.string.pair_scan, request.verificationUri));
    -                    if (bitmap != null) qr.setImageBitmap(bitmap);
    +                    if (bitmap != null) {
    +                        qr.setImageBitmap(bitmap);
    +                        qr.setVisibility(View.VISIBLE);
    +                    }
                         code.setText(request.userCode);
    +                    code.setVisibility(View.VISIBLE);
                         status.setText(R.string.pair_waiting);
                     });
                     PairingClient.PollResult result = null;
    @@ -552,26 +559,58 @@ public class MainActivity extends AppCompatActivity {
                     }
                 } catch (PairingClient.NotEnabledException e) {
                     outcome = getString(R.string.pair_not_enabled);
    +            } catch (PairingClient.HttpStatusException e) {
    +                outcome = e.status == 404 ? getString(R.string.pair_err_old_server)
    +                        : getString(R.string.err_http, e.status);
                 } catch (InterruptedException e) {
                     return;
                 } catch (Exception e) {
    -                outcome = getString(R.string.pair_error, e.getClass().getSimpleName());
    +                outcome = describeNetworkError(e, url);
                 }
                 final String message = outcome;
                 final int c = color;
                 runOnUiThread(() -> {
    -                if (!cancelled[0]) dialog.dismiss();
    +                setConnectionStatus(c, message);
                     if (c == COLOR_OK) {
                         if (consumptionCloudClient != null) consumptionCloudClient.invalidateHistory();
                         showServerConfig();
                         serverTokenLayout.setError(null);
                         refreshStatus();
    +                    if (!cancelled[0]) dialog.dismiss();
    +                    return;
                     }
    -                setConnectionStatus(c, message);
    +                if (cancelled[0]) return;
    +                // Keep the dialog up with the reason, instead of closing it on a failure the
    +                // driver would otherwise only see as a flicker.
    +                qr.setVisibility(View.GONE);
    +                code.setVisibility(View.GONE);
    +                instructions.setVisibility(View.GONE);
    +                status.setText(message);
    +                status.setTextColor(c);
    +                android.widget.Button close = dialog.getButton(android.content.DialogInterface.BUTTON_NEGATIVE);
    +                if (close != null) close.setText(R.string.pair_close);
                 });
             }, "server-pairing").start();
         }
     
    +    /**
    +     * A network failure in words the driver can act on: a name that does not resolve, a
    +     * server that does not answer, or a status. Shared by Test connection and pairing.
    +     */
    +    private String describeNetworkError(Exception e, String url) {
    +        String host = Uri.parse(url).getHost();
    +        if (host == null) host = url;
    +        if (e instanceof java.net.UnknownHostException) return getString(R.string.err_dns, host);
    +        if (e instanceof java.net.SocketTimeoutException || e instanceof java.net.ConnectException
    +                || e instanceof java.net.NoRouteToHostException) {
    +            return getString(R.string.err_unreachable, host);
    +        }
    +        if (e instanceof PairingClient.HttpStatusException) {
    +            return getString(R.string.err_http, ((PairingClient.HttpStatusException) e).status);
    +        }
    +        return getString(R.string.pair_error, e.getClass().getSimpleName());
    +    }
    +
         private static int httpGet(String url, String token) throws java.io.IOException {
             java.net.HttpURLConnection connection = (java.net.HttpURLConnection) new java.net.URL(url).openConnection();
             try {
    diff --git a/app/src/main/java/com/evsuite/abrp/PairingClient.java b/app/src/main/java/com/evsuite/abrp/PairingClient.java
    index 29c0fd8..e349dca 100644
    --- a/app/src/main/java/com/evsuite/abrp/PairingClient.java
    +++ b/app/src/main/java/com/evsuite/abrp/PairingClient.java
    @@ -52,13 +52,28 @@ final class PairingClient {
         /** Thrown when pairing is switched off on the server (no APPROVAL_PASSWORD). */
         static final class NotEnabledException extends Exception { }
     
    +    /** The server answered with a status the flow does not expect. */
    +    static final class HttpStatusException extends java.io.IOException {
    +        final int status;
    +
    +        HttpStatusException(int status) {
    +            super("HTTP " + status);
    +            this.status = status;
    +        }
    +    }
    +
    +    /** What the server says when APPROVAL_PASSWORD is unset; any other 404 is a missing route. */
    +    static final String NOT_ENABLED_MESSAGE = "pairing is not enabled";
    +
         private PairingClient() { }
     
         static Request start(String serverUrl, String installationId) throws Exception {
             Response r = post(serverUrl + "/v1/pair/start",
                     new JSONObject().put("installationId", installationId));
    -        if (r.status == 404) throw new NotEnabledException();
    -        if (r.status != 201) throw new java.io.IOException("HTTP " + r.status);
    +        // Only the server's own answer means "switched off". A 404 without it is a server (or
    +        // proxy) that has no pairing route at all, e.g. one older than 2.1.0.
    +        if (r.status == 404 && r.body.contains(NOT_ENABLED_MESSAGE)) throw new NotEnabledException();
    +        if (r.status != 201) throw new HttpStatusException(r.status);
             JSONObject json = new JSONObject(r.body);
             long now = System.currentTimeMillis();
             return new Request(
    @@ -77,7 +92,7 @@ final class PairingClient {
                 case 202: return new PollResult(Outcome.PENDING, null);
                 case 403: return new PollResult(Outcome.DENIED, null);
                 case 410: return new PollResult(Outcome.EXPIRED, null);
    -            default: throw new java.io.IOException("HTTP " + r.status);
    +            default: throw new HttpStatusException(r.status);
             }
         }
     
    diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
    index cd1beb8..f942d80 100644
    --- a/app/src/main/res/values/strings.xml
    +++ b/app/src/main/res/values/strings.xml
    @@ -66,6 +66,11 @@
         The code expired — press Pair with phone again
         Pairing is not enabled on the server (no APPROVAL_PASSWORD)
         Pairing failed: %1$s
    +    Close
    +    The server has no pairing (HTTP 404). It needs server version 2.1.0 or newer.
    +    Server name %1$s not found. Is it in public DNS?
    +    Server %1$s not reachable
    +    Server answered HTTP %1$d
         Cancel
         QR code to approve this car on your phone
         The token must be at least 24 characters, without spaces
    diff --git a/app/src/test/java/com/evsuite/abrp/PairingClientTest.java b/app/src/test/java/com/evsuite/abrp/PairingClientTest.java
    index 0bc8006..c9b6844 100644
    --- a/app/src/test/java/com/evsuite/abrp/PairingClientTest.java
    +++ b/app/src/test/java/com/evsuite/abrp/PairingClientTest.java
    @@ -27,6 +27,7 @@ public class PairingClientTest {
         private String url;
         private final Deque pollStatuses = new ArrayDeque<>();
         private volatile int startStatus = 201;
    +    private volatile String startErrorBody = "{\"error\":\"pairing is not enabled\"}";
         private volatile String lastStartBody;
     
         @Before
    @@ -73,7 +74,7 @@ public class PairingClientTest {
                 response = status == 201
                         ? "{\"deviceCode\":\"dev-code\",\"userCode\":\"K7F-3QX\",\"verificationUri\":\"https://car.example.com/pair\","
                           + "\"verificationUriComplete\":\"https://car.example.com/pair?code=K7F-3QX\",\"expiresIn\":600,\"interval\":5}"
    -                    : "{\"error\":\"pairing is not enabled\"}";
    +                    : startErrorBody;
             } else {
                 Integer next;
                 synchronized (pollStatuses) { next = pollStatuses.poll(); }
    @@ -119,6 +120,19 @@ public class PairingClientTest {
             assertEquals(PairingClient.Outcome.EXPIRED, PairingClient.poll(url, "dev-code").outcome);
         }
     
    +    @Test
    +    public void aServerWithoutPairingIsNotMistakenForPairingSwitchedOff() throws Exception {
    +        // e.g. server 2.0.0, or a proxy's own 404 page
    +        startStatus = 404;
    +        startErrorBody = "404 page not found";
    +        try {
    +            PairingClient.start(url, "6f1a2b3c-4d5e-4f60-8a9b-0c1d2e3f4a5b");
    +            fail("expected HttpStatusException");
    +        } catch (PairingClient.HttpStatusException expected) {
    +            assertEquals(404, expected.status);
    +        }
    +    }
    +
         @Test
         public void pairingSwitchedOffOnTheServerIsReportedAsSuch() throws Exception {
             startStatus = 404;
    diff --git a/k8s/secrets.example.yaml b/k8s/secrets/secrets.example.yaml
    similarity index 100%
    rename from k8s/secrets.example.yaml
    rename to k8s/secrets/secrets.example.yaml
    
    From 46dccee1dedeb8b6fb69ce796b7a8886dc534d5e Mon Sep 17 00:00:00 2001
    From: Nils Stinnesbeck 
    Date: Sat, 17 Oct 2026 21:12:21 +0200
    Subject: [PATCH 28/79] App 2.4.3: pairing code visible on the car screen
    
    The code below the QR code was not visible on the head unit: the dialog
    stacked the text, a 240 dp QR code, the code and the status, and its
    content could not scroll, so on the wide, low screen the lower part
    was cut off. On wide screens the QR code now sits left of the text,
    the content scrolls, and the code is also part of the instruction
    sentence, so it is readable wherever the text is.
    
    Co-Authored-By: Claude Opus 5.5 
    Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
    ---
     app/build.gradle                              |  4 +-
     .../java/com/evsuite/abrp/MainActivity.java   | 47 +++++++++++++++----
     app/src/main/res/values/strings.xml           |  2 +-
     3 files changed, 41 insertions(+), 12 deletions(-)
    
    diff --git a/app/build.gradle b/app/build.gradle
    index c2c0330..09ee60c 100644
    --- a/app/build.gradle
    +++ b/app/build.gradle
    @@ -34,8 +34,8 @@ android {
             applicationId "com.stinnesbeck.nilsmg4"
             minSdk 27
             targetSdk 36
    -        versionCode 60
    -        versionName "2.4.2"
    +        versionCode 61
    +        versionName "2.4.3"
     
             // Nothing about the server ships in the APK: its URL and ingest token are entered in
             // the app's Server tab (ServerConfig), and the database is only reachable by the server.
    diff --git a/app/src/main/java/com/evsuite/abrp/MainActivity.java b/app/src/main/java/com/evsuite/abrp/MainActivity.java
    index 3fe59c7..fb7d9d6 100644
    --- a/app/src/main/java/com/evsuite/abrp/MainActivity.java
    +++ b/app/src/main/java/com/evsuite/abrp/MainActivity.java
    @@ -483,10 +483,17 @@ public class MainActivity extends AppCompatActivity {
     
             float density = getResources().getDisplayMetrics().density;
             int pad = (int) (24 * density);
    +        // On the head unit's wide, low screen the QR code sits left of the text: stacked, the
    +        // code and status below a 240 dp QR code were cut off. Scrollable either way, so
    +        // nothing can be clipped on any screen.
    +        android.util.DisplayMetrics metrics = getResources().getDisplayMetrics();
    +        boolean wide = metrics.widthPixels > metrics.heightPixels;
             android.widget.LinearLayout panel = new android.widget.LinearLayout(this);
    -        panel.setOrientation(android.widget.LinearLayout.VERTICAL);
    -        panel.setGravity(android.view.Gravity.CENTER_HORIZONTAL);
    +        panel.setOrientation(wide ? android.widget.LinearLayout.HORIZONTAL : android.widget.LinearLayout.VERTICAL);
    +        panel.setGravity(wide ? android.view.Gravity.CENTER_VERTICAL : android.view.Gravity.CENTER_HORIZONTAL);
             panel.setPadding(pad, pad, pad, pad);
    +        android.widget.LinearLayout column = new android.widget.LinearLayout(this);
    +        column.setOrientation(android.widget.LinearLayout.VERTICAL);
             TextView instructions = new TextView(this);
             instructions.setTextAppearance(R.style.Text_EV_Secondary);
             ImageView qr = new ImageView(this);
    @@ -501,17 +508,37 @@ public class MainActivity extends AppCompatActivity {
             TextView status = new TextView(this);
             status.setTextAppearance(R.style.Text_EV_Body);
             status.setText(R.string.pair_starting);
    -        panel.addView(instructions);
             android.widget.LinearLayout.LayoutParams qrParams = new android.widget.LinearLayout.LayoutParams(qrSize, qrSize);
    -        qrParams.topMargin = pad / 2;
    -        panel.addView(qr, qrParams);
    -        panel.addView(code);
    -        panel.addView(status);
    +        android.widget.LinearLayout.LayoutParams columnParams;
    +        if (wide) {
    +            qrParams.rightMargin = pad;
    +            columnParams = new android.widget.LinearLayout.LayoutParams(
    +                    0, android.view.ViewGroup.LayoutParams.WRAP_CONTENT, 1f);
    +            panel.addView(qr, qrParams);
    +            panel.addView(column, columnParams);
    +            column.addView(instructions);
    +        } else {
    +            qrParams.topMargin = pad / 2;
    +            qrParams.bottomMargin = pad / 2;
    +            columnParams = new android.widget.LinearLayout.LayoutParams(
    +                    android.view.ViewGroup.LayoutParams.MATCH_PARENT, android.view.ViewGroup.LayoutParams.WRAP_CONTENT);
    +            panel.addView(instructions);
    +            panel.addView(qr, qrParams);
    +            panel.addView(column, columnParams);
    +        }
    +        android.widget.LinearLayout.LayoutParams codeParams = new android.widget.LinearLayout.LayoutParams(
    +                android.view.ViewGroup.LayoutParams.WRAP_CONTENT, android.view.ViewGroup.LayoutParams.WRAP_CONTENT);
    +        codeParams.topMargin = pad / 2;
    +        codeParams.bottomMargin = pad / 2;
    +        column.addView(code, codeParams);
    +        column.addView(status);
    +        android.widget.ScrollView scroll = new android.widget.ScrollView(this);
    +        scroll.addView(panel);
     
             final boolean[] cancelled = {false};
             androidx.appcompat.app.AlertDialog dialog = new MaterialAlertDialogBuilder(this)
                     .setTitle(R.string.pair_title)
    -                .setView(panel)
    +                .setView(scroll)
                     .setNegativeButton(R.string.pair_cancel, null)
                     .create();
             // Shown only once the server has answered, so a failure leaves no empty QR frame.
    @@ -527,7 +554,9 @@ public class MainActivity extends AppCompatActivity {
                     PairingClient.Request request = PairingClient.start(url, installationId);
                     android.graphics.Bitmap bitmap = QrCode.generate(request.verificationUriComplete, 416);
                     runOnUiThread(() -> {
    -                    instructions.setText(getString(R.string.pair_scan, request.verificationUri));
    +                    // The code is in the sentence as well as below it, so it is readable
    +                    // wherever the text is.
    +                    instructions.setText(getString(R.string.pair_scan, request.verificationUri, request.userCode));
                         if (bitmap != null) {
                             qr.setImageBitmap(bitmap);
                             qr.setVisibility(View.VISIBLE);
    diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
    index f942d80..b92d940 100644
    --- a/app/src/main/res/values/strings.xml
    +++ b/app/src/main/res/values/strings.xml
    @@ -58,7 +58,7 @@
         Pair with phone
         Shows a QR code: scan it with your phone and approve with the approval password. The token below is then filled in for you.
         Pair with phone
    -    Scan with your phone, or open %1$s and enter this code:
    +    Scan the QR code with your phone, or open %1$s and enter the code %2$s:
         Waiting for approval…
         Contacting the server…
         Paired — this car now has its own token
    
    From 84fb3aac2142804699dbc37330d43e31089165dd Mon Sep 17 00:00:00 2001
    From: Nils Stinnesbeck 
    Date: Sat, 17 Oct 2026 21:36:17 +0200
    Subject: [PATCH 29/79] k8s: work with `kubectl apply -f` as well as `-k`
    MIME-Version: 1.0
    Content-Type: text/plain; charset=UTF-8
    Content-Transfer-Encoding: 8bit
    
    - The secrets template moves to k8s/secrets/, and the filled-in copy to
      k8s/secrets/secrets.yaml (gitignored). `kubectl apply -f k8s/` does
      not recurse, so neither the placeholder template nor the real secrets
      can be applied by accident.
    - The server image digest is pinned in server.yaml instead of through
      kustomize's images override, which `-f` ignores (it would have pulled
      a nonexistent :latest).
    - README and kustomization.yaml say to use `-k`: plain `-f` never
      creates the generated Grafana ConfigMaps.
    - The IngressRoutes drop `tls: {}`, like the other routes on this
      Traefik: TLS is configured on the websecure entrypoint.
    
    Manifests validated both ways (14 resources rendered with kustomize,
    11 as plain files — the difference is the Grafana ConfigMaps).
    
    Co-Authored-By: Claude Opus 5.5 
    Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
    ---
     .gitignore                       |  3 ++-
     SPECS.md                         |  5 +++--
     k8s/README.md                    | 19 +++++++++++--------
     k8s/ingress.yaml                 |  7 +++----
     k8s/kustomization.yaml           |  8 ++------
     k8s/secrets/secrets.example.yaml |  5 +++--
     k8s/server.yaml                  |  3 ++-
     7 files changed, 26 insertions(+), 24 deletions(-)
    
    diff --git a/.gitignore b/.gitignore
    index c0a29a9..90dee08 100644
    --- a/.gitignore
    +++ b/.gitignore
    @@ -40,5 +40,6 @@ mise.local.toml
     /EVHardware/.gradle
     /EVHardware/**/build
     
    -# Filled-in Kubernetes Secrets (copy of k8s/secrets.example.yaml). Never commit.
    +# Filled-in Kubernetes Secrets (copy of k8s/secrets/secrets.example.yaml). Never commit.
     /k8s/secrets.yaml
    +/k8s/secrets/secrets.yaml
    diff --git a/SPECS.md b/SPECS.md
    index 7272a3c..a0e0914 100644
    --- a/SPECS.md
    +++ b/SPECS.md
    @@ -40,7 +40,7 @@ car ──live telemetry───▶ car.k8s.stinnesbeck.com (Go) ──▶ ABRP
     - [x] Manifests validated offline (including Traefik CRDs). **Not applied.**
     - [x] Car app 2.4.0 (pairing by QR): unit tests pass; **not yet tried on the car**.
     - [x] Image `mg4-server:2.1.0` (with pairing) pushed to git.stinnesbeck.com (public; pinned by
    -      digest `sha256:e5f6e2d2…` in `k8s/kustomization.yaml`).
    +      digest `sha256:e5f6e2d2…` in `k8s/server.yaml`).
     - [ ] Check Grafana's host in `k8s/ingress.yaml` (placeholder `car-grafana.k8s.stinnesbeck.com`).
     - [ ] First pairing and drive with 2.4.0: telemetry arrives in InfluxDB, Grafana and ABRP.
     
    @@ -89,7 +89,8 @@ car ──live telemetry───▶ car.k8s.stinnesbeck.com (Go) ──▶ ABRP
       app 2.4.0 shows the QR code and collects its token. Tested end to end against InfluxDB.
     - 2026-10-17 — Incident: a commit swept the filled-in `secrets.example.yaml` (InfluxDB admin token,
       INGEST_TOKEN) into git and pushed it. Commit amended and force-pushed; both values regenerated in
    -  the now gitignored `k8s/secrets.yaml`. Filled-in secrets live only there from now on.
    +  the now gitignored `k8s/secrets.yaml` (since moved to `k8s/secrets/secrets.yaml`, outside
    +  `kubectl apply -f k8s/`). Filled-in secrets live only there from now on.
     - 2026-10-17 — Temporary on-screen diagnostics (b2a5b32) to debug blank values without adb;
       removed again (1002962) once values showed on the car. Revert 1002962 to bring them back.
     
    diff --git a/k8s/README.md b/k8s/README.md
    index 27b4bd2..f2d2c0b 100644
    --- a/k8s/README.md
    +++ b/k8s/README.md
    @@ -11,19 +11,19 @@ is applied automatically. Apply them yourself.
     | `grafana.yaml` | Grafana 13.2.3 Deployment (1Gi PVC), Service; datasource and dashboards provisioned |
     | `grafana/` | The provisioned InfluxDB datasource and the MG4 dashboards (Live, Consumption, Charging) |
     | `ingress.yaml` | Traefik `IngressRoute`s for car.k8s.stinnesbeck.com and Grafana, TLS from Traefik's default certificate |
    -| `secrets.example.yaml` | **Template** for the Secrets; placeholders only, not in `kustomization.yaml` |
    -| `secrets.yaml` | Your filled-in copy — **gitignored**, never committed |
    -| `kustomization.yaml` | Everything except the Secrets; pins the server image, generates the Grafana ConfigMaps |
    +| `secrets/secrets.example.yaml` | **Template** for the Secrets; placeholders only, not in `kustomization.yaml` |
    +| `secrets/secrets.yaml` | Your filled-in copy — **gitignored**, never committed |
    +| `kustomization.yaml` | Everything except the Secrets; generates the Grafana ConfigMaps |
     
     ## Before the first apply
     
     1. **Check `ingress.yaml`:** Grafana's host (placeholder `car-grafana.k8s.stinnesbeck.com`).
        Both routes use the `traefik-internal` class, and both hosts must be
        covered by Traefik's default certificate.
    -2. **Create the Secrets.** `cp k8s/secrets.example.yaml k8s/secrets.yaml` (gitignored), replace
    +2. **Create the Secrets.** `cp k8s/secrets/secrets.example.yaml k8s/secrets/secrets.yaml` (gitignored), replace
        every `CHANGE-ME` there (`openssl rand -hex 32`): `influxdb-auth`, `mg4-server` (ingest token,
        approval password for pairing, your ABRP API key and user token) and `grafana-admin`. Keep
    -   `secrets.example.yaml` itself on placeholders only.
    +   the template itself on placeholders only.
     3. **Registry access.** The `mg4-server` package on git.stinnesbeck.com is public, so no pull
        secret is needed. If you make it private, add an `imagePullSecrets` entry to `server.yaml`.
     
    @@ -31,10 +31,13 @@ is applied automatically. Apply them yourself.
     
     ```sh
     kubectl apply -f k8s/namespace.yaml
    -kubectl apply -f k8s/secrets.yaml
    +kubectl apply -f k8s/secrets/secrets.yaml
     kubectl apply -k k8s/
     ```
     
    +Use **`-k`**, not `-f k8s/`: plain `-f` applies the files without kustomize, so the Grafana
    +ConfigMaps (datasource and dashboards) are never created and Grafana does not start.
    +
     ## Notes
     
     - InfluxDB's setup mode runs only on an empty volume. Changing `admin-token` in the Secret later
    @@ -43,5 +46,5 @@ kubectl apply -k k8s/
       for Grafana, read/write for the server) can replace it later.
     - The dashboards are read-only in Grafana's UI. Change the JSON in `grafana/dashboards/`; the
       ConfigMap name changes with its content, which rolls Grafana.
    -- The server image is pinned by digest in `kustomization.yaml`. To upgrade, push a new tag and
    -  put its digest there (`skopeo inspect docker://git.stinnesbeck.com/nils/mg4-server:`).
    +- The server image is pinned by digest in `server.yaml`. To upgrade, push a new tag and put its
    +  digest there (`skopeo inspect docker://git.stinnesbeck.com/nils/mg4-server:`).
    diff --git a/k8s/ingress.yaml b/k8s/ingress.yaml
    index 335dc03..0719533 100644
    --- a/k8s/ingress.yaml
    +++ b/k8s/ingress.yaml
    @@ -1,5 +1,6 @@
    -# Routes on the traefik-internal class, like the rest of *.k8s.stinnesbeck.com. TLS uses
    -# Traefik's default certificate, so there is no per-host Certificate here.
    +# Routes on the traefik-internal class, like the rest of *.k8s.stinnesbeck.com. No tls section
    +# and no per-host Certificate: TLS is configured on Traefik's websecure entrypoint, which serves
    +# the default certificate.
     apiVersion: traefik.io/v1alpha1
     kind: IngressRoute
     metadata:
    @@ -18,7 +19,6 @@ spec:
               name: mg4-server
               namespace: mg4
               port: 80
    -  tls: {}
     ---
     apiVersion: traefik.io/v1alpha1
     kind: IngressRoute
    @@ -39,4 +39,3 @@ spec:
               name: grafana
               namespace: mg4
               port: 80
    -  tls: {}
    diff --git a/k8s/kustomization.yaml b/k8s/kustomization.yaml
    index bd41dd3..a82bbc6 100644
    --- a/k8s/kustomization.yaml
    +++ b/k8s/kustomization.yaml
    @@ -1,5 +1,5 @@
    -# Render with `kustomize build k8s/` (or `kubectl kustomize k8s/`) and apply it yourself.
    -# The Secrets are not included: create them from secrets.example.yaml first.
    +# Apply with `kubectl apply -k k8s/` — not `-f k8s/`, which skips the Grafana ConfigMaps below.
    +# The Secrets are not included: create them from secrets/secrets.example.yaml first.
     apiVersion: kustomize.config.k8s.io/v1beta1
     kind: Kustomization
     resources:
    @@ -8,10 +8,6 @@ resources:
       - server.yaml
       - grafana.yaml
       - ingress.yaml
    -images:
    -  - name: git.stinnesbeck.com/nils/mg4-server
    -    # 2.1.0, pinned by digest so a re-pushed tag cannot change what runs.
    -    digest: sha256:e5f6e2d2ef9dc2650055a0823a3f86d8903e46918a76e990540482edf819766d
     generatorOptions:
       labels:
         app.kubernetes.io/part-of: mg4
    diff --git a/k8s/secrets/secrets.example.yaml b/k8s/secrets/secrets.example.yaml
    index 6bea4d4..f97d66b 100644
    --- a/k8s/secrets/secrets.example.yaml
    +++ b/k8s/secrets/secrets.example.yaml
    @@ -1,5 +1,6 @@
    -# TEMPLATE ONLY — not part of kustomization.yaml. Copy it to k8s/secrets.yaml (gitignored),
    -# fill in real values there, and apply that file yourself. Never put real values in here.
    +# TEMPLATE ONLY — not part of kustomization.yaml. Copy it to k8s/secrets/secrets.yaml
    +# (gitignored), fill in real values there, and apply that file yourself. Never put real values
    +# in here. This folder is outside `kubectl apply -f k8s/`, which does not recurse.
     #
     #   openssl rand -hex 32     for every token and password below
     apiVersion: v1
    diff --git a/k8s/server.yaml b/k8s/server.yaml
    index 7fc4743..d698e1e 100644
    --- a/k8s/server.yaml
    +++ b/k8s/server.yaml
    @@ -29,7 +29,8 @@ spec:
               type: RuntimeDefault
           containers:
             - name: server
    -          image: git.stinnesbeck.com/nils/mg4-server:latest # pinned by kustomization.yaml
    +          # 2.1.0, pinned by digest so a re-pushed tag cannot change what runs.
    +          image: git.stinnesbeck.com/nils/mg4-server@sha256:e5f6e2d2ef9dc2650055a0823a3f86d8903e46918a76e990540482edf819766d
               ports:
                 - name: http
                   containerPort: 8080
    
    From 756ae65d0bdac6e07e334818a1bad415442b1b26 Mon Sep 17 00:00:00 2001
    From: Nils Stinnesbeck 
    Date: Sat, 17 Oct 2026 21:43:49 +0200
    Subject: [PATCH 30/79] Grafana: startup probe so a slow first start is not
     killed
    
    Grafana's first start creates and migrates its database: 44 s at
    0.5 CPU and 3 minutes at 0.1 CPU (measured with 13.2.3 on an empty
    volume). With only 50m requested, the liveness probe (30 s delay,
    3 x 30 s) restarted it mid-migration, in a loop, and readiness never
    passed.
    
    - startupProbe on /api/health allows up to 10 minutes; liveness and
      readiness start only after it succeeds.
    - 5 s timeouts on all probes instead of the 1 s default.
    - Requests raised to 250m CPU / 256Mi (measured about 240 MB in use);
      still no CPU limit.
    
    Co-Authored-By: Claude Opus 5.5 
    Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
    ---
     k8s/grafana.yaml | 19 ++++++++++++++++---
     1 file changed, 16 insertions(+), 3 deletions(-)
    
    diff --git a/k8s/grafana.yaml b/k8s/grafana.yaml
    index c3bb156..0032a52 100644
    --- a/k8s/grafana.yaml
    +++ b/k8s/grafana.yaml
    @@ -85,21 +85,34 @@ spec:
                   mountPath: /etc/grafana/provisioning/dashboards
                 - name: dashboards
                   mountPath: /var/lib/grafana/dashboards/mg4
    +          # The first start creates and migrates Grafana's database: measured 44 s at 0.5 CPU
    +          # and 3 minutes at 0.1 CPU. The startup probe allows up to 10 minutes for that, and
    +          # liveness/readiness only begin once it succeeded, so a slow first start is never
    +          # killed mid-migration (which would restart it slowly again, in a loop).
    +          startupProbe:
    +            httpGet:
    +              path: /api/health
    +              port: http
    +            periodSeconds: 10
    +            timeoutSeconds: 5
    +            failureThreshold: 60
               readinessProbe:
                 httpGet:
                   path: /api/health
                   port: http
                 periodSeconds: 10
    +            timeoutSeconds: 5
               livenessProbe:
                 httpGet:
                   path: /api/health
                   port: http
    -            initialDelaySeconds: 30
                 periodSeconds: 30
    +            timeoutSeconds: 5
    +            failureThreshold: 3
               resources:
                 requests:
    -              cpu: 50m
    -              memory: 128Mi
    +              cpu: 250m
    +              memory: 256Mi
                 limits:
                   memory: 512Mi
               securityContext:
    
    From cee0eaeab37ff40ba1e63c12b639c02f73c42be8 Mon Sep 17 00:00:00 2001
    From: Nils Stinnesbeck 
    Date: Sat, 17 Oct 2026 21:45:56 +0200
    Subject: [PATCH 31/79] Grafana: memory request 512Mi, limit 1Gi
    
    It runs at about 406 MiB in the cluster, too close to the 512Mi limit:
    long-range dashboard queries could get it OOM-killed. The request now
    matches what it uses, so the scheduler reserves it.
    
    Co-Authored-By: Claude Opus 5.5 
    Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
    ---
     k8s/grafana.yaml | 6 ++++--
     1 file changed, 4 insertions(+), 2 deletions(-)
    
    diff --git a/k8s/grafana.yaml b/k8s/grafana.yaml
    index 0032a52..135aa75 100644
    --- a/k8s/grafana.yaml
    +++ b/k8s/grafana.yaml
    @@ -109,12 +109,14 @@ spec:
                 periodSeconds: 30
                 timeoutSeconds: 5
                 failureThreshold: 3
    +          # About 406 MiB in steady state in the cluster: request what it uses, and leave room
    +          # for long-range dashboard queries before the limit OOM-kills it.
               resources:
                 requests:
                   cpu: 250m
    -              memory: 256Mi
    -            limits:
                   memory: 512Mi
    +            limits:
    +              memory: 1Gi
               securityContext:
                 allowPrivilegeEscalation: false
                 capabilities:
    
    From f3de272c697c99b300f7fa0b9e36924802a354c1 Mon Sep 17 00:00:00 2001
    From: Nils Stinnesbeck 
    Date: Sat, 17 Oct 2026 21:59:17 +0200
    Subject: [PATCH 32/79] Grafana: map zooms to the route; plain hyphens in
     titles
    
    - "Where the car was" fits the route again: in Grafana's fit view the
      zoom value is the maximum zoom, and 6 held it at country level. Now
      15 with 10 % padding. Checked by rendering the Live dashboard with a
      simulated 10 km drive.
    - Dashboard titles use "-" instead of an em dash: "MG4 - Live",
      "MG4 - Consumption", "MG4 - Charging".
    - SPECS.md: Grafana keeps its state in SQLite on its PVC, which is why
      it runs as one replica with Recreate; high availability needs
      PostgreSQL or MySQL first.
    
    Co-Authored-By: Claude Opus 5.5 
    Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
    ---
     SPECS.md                                | 7 +++++++
     k8s/grafana/dashboards/charging.json    | 2 +-
     k8s/grafana/dashboards/consumption.json | 2 +-
     k8s/grafana/dashboards/live.json        | 5 +++--
     4 files changed, 12 insertions(+), 4 deletions(-)
    
    diff --git a/SPECS.md b/SPECS.md
    index a0e0914..5423990 100644
    --- a/SPECS.md
    +++ b/SPECS.md
    @@ -56,6 +56,13 @@ car ──live telemetry───▶ car.k8s.stinnesbeck.com (Go) ──▶ ABRP
           (`ALLOWED_HOSTS`). For git.stinnesbeck.com it needs the host on the allowlist, and the
           response parsing checked against that server's releases API. Release assets must be named
           `NilsMG4-unstable-.apk` with a `.sha256` sidecar (`build-car` writes both).
    +- [ ] **Grafana high availability needs another database.** Grafana keeps its own state (users,
    +      sessions, preferences, provisioned-dashboard records, alerting state) in SQLite at
    +      `/var/lib/grafana/grafana.db` on the `grafana-data` PVC. That is fine for one replica, and is
    +      why `k8s/grafana.yaml` uses `replicas: 1` and the `Recreate` strategy: only one Grafana may
    +      write the file. To run more than one replica, first move Grafana to PostgreSQL or MySQL
    +      (`GF_DATABASE_TYPE`, `GF_DATABASE_HOST`, …); the replicas then share that database instead
    +      of a volume. The car data itself is in InfluxDB and unaffected.
     - [ ] **Telemetry queue survives restarts.** Unsent live samples are kept in memory only.
     - [ ] **Translate the rest.** The docs (`CHANGELOG.md`, `OTA.md`'s
           message table, comments in `mise.toml`/`app/build.gradle`) are still Turkish or French.
    diff --git a/k8s/grafana/dashboards/charging.json b/k8s/grafana/dashboards/charging.json
    index 28471c1..9550abd 100644
    --- a/k8s/grafana/dashboards/charging.json
    +++ b/k8s/grafana/dashboards/charging.json
    @@ -1,6 +1,6 @@
     {
       "uid": "mg4-charging",
    -  "title": "MG4 — Charging",
    +  "title": "MG4 - Charging",
       "description": "DC charging sessions metered by the car. Prices in euro, as entered in the car's Charging tab.",
       "tags": [
         "mg4"
    diff --git a/k8s/grafana/dashboards/consumption.json b/k8s/grafana/dashboards/consumption.json
    index 99b2078..ff1e6b3 100644
    --- a/k8s/grafana/dashboards/consumption.json
    +++ b/k8s/grafana/dashboards/consumption.json
    @@ -1,6 +1,6 @@
     {
       "uid": "mg4-consumption",
    -  "title": "MG4 — Consumption",
    +  "title": "MG4 - Consumption",
       "description": "Daily consumption, summed from the chunks the car sends while driving.",
       "tags": [
         "mg4"
    diff --git a/k8s/grafana/dashboards/live.json b/k8s/grafana/dashboards/live.json
    index 77dda27..5046df4 100644
    --- a/k8s/grafana/dashboards/live.json
    +++ b/k8s/grafana/dashboards/live.json
    @@ -1,6 +1,6 @@
     {
       "uid": "mg4-live",
    -  "title": "MG4 — Live",
    +  "title": "MG4 - Live",
       "description": "Live telemetry from the car, as stored before it is forwarded to ABRP.",
       "tags": [
         "mg4"
    @@ -488,7 +488,8 @@
               "id": "fit",
               "lat": 0,
               "lon": 0,
    -          "zoom": 6,
    +          "zoom": 15,
    +          "padding": 10,
               "allLayers": true
             },
             "controls": {
    
    From 582c084b3562b0fef4abe2b58f2efe4911b09f59 Mon Sep 17 00:00:00 2001
    From: Nils Stinnesbeck 
    Date: Sat, 17 Oct 2026 22:17:41 +0200
    Subject: [PATCH 33/79] Live dashboard: show what the car sends, compute pack
     energy
    
    The car publishes no battery, cabin or outside temperature and no pack
    energy (checked against the fields that reached InfluxDB). Instead of
    panels that stay empty:
    
    - Battery temperature tile -> Climate setpoint.
    - Outside temperature tile -> Last update ("2 minutes ago").
    - Energy in pack = SOC x usable capacity, from the hidden dashboard
      constant usableKwh = 50.8 (51 kWh battery).
    - Temperatures chart -> Battery voltage and current.
    
    Checked with a simulated drive carrying exactly the car's fields: all
    11 panels return data, energy matches 61 % x 50.8 kWh. SPECS.md lists
    the missing signals and why.
    
    Co-Authored-By: Claude Opus 5.5 
    Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
    ---
     SPECS.md                         | 19 +++++++++++
     k8s/grafana/dashboards/live.json | 57 +++++++++++++++++++++++---------
     2 files changed, 61 insertions(+), 15 deletions(-)
    
    diff --git a/SPECS.md b/SPECS.md
    index 5423990..15496f6 100644
    --- a/SPECS.md
    +++ b/SPECS.md
    @@ -44,6 +44,22 @@ car ──live telemetry───▶ car.k8s.stinnesbeck.com (Go) ──▶ ABRP
     - [ ] Check Grafana's host in `k8s/ingress.yaml` (placeholder `car-grafana.k8s.stinnesbeck.com`).
     - [ ] First pairing and drive with 2.4.0: telemetry arrives in InfluxDB, Grafana and ABRP.
     
    +### Signals the car does not provide
    +
    +Checked 2026-10-17 against what actually reached InfluxDB (`schema.measurementFieldKeys`). The car
    +sends soc, est_battery_range, odometer, power, voltage, current, speed, hvac_setpoint, the four
    +tyre pressures, GPS (lat, lon, elevation, heading) and the is_* flags. It does **not** send:
    +
    +- **Battery temperature:** read from `EV_BATTERY_AVERAGE_TEMPERATURE`, an Android 14 property; the
    +  head unit runs Android 9, and the SAIC services offer none.
    +- **Cabin temperature:** `HVAC_TEMPERATURE_CURRENT` is not published; only the setpoint is.
    +- **Outside temperature:** not published by the vehicle (upstream noted the same). Upstream used
    +  the head unit's weather service as a stand-in (`HeadUnitWeather`, removed in `ea8032a`);
    +  restoring it was considered and declined.
    +- **Pack energy (`soe`) and capacity:** not published. The Live dashboard computes energy in pack
    +  as SOC × usable capacity, from the hidden dashboard constant `usableKwh` (50.8 kWh for the
    +  51 kWh battery) in `k8s/grafana/dashboards/live.json`.
    +
     ## To do
     
     - [ ] **About dialog link and QR code → https://git.stinnesbeck.com/nils/mg4.**
    @@ -98,6 +114,9 @@ car ──live telemetry───▶ car.k8s.stinnesbeck.com (Go) ──▶ ABRP
       INGEST_TOKEN) into git and pushed it. Commit amended and force-pushed; both values regenerated in
       the now gitignored `k8s/secrets.yaml` (since moved to `k8s/secrets/secrets.yaml`, outside
       `kubectl apply -f k8s/`). Filled-in secrets live only there from now on.
    +- 2026-10-17 — Live dashboard reworked around the signals the car sends: Climate setpoint, Last
    +  update and a computed Energy in pack replace the empty temperature tiles; Battery voltage and
    +  current replaces the Temperatures chart.
     - 2026-10-17 — Temporary on-screen diagnostics (b2a5b32) to debug blank values without adb;
       removed again (1002962) once values showed on the car. Revert 1002962 to bring them back.
     
    diff --git a/k8s/grafana/dashboards/live.json b/k8s/grafana/dashboards/live.json
    index 5046df4..73605d9 100644
    --- a/k8s/grafana/dashboards/live.json
    +++ b/k8s/grafana/dashboards/live.json
    @@ -33,6 +33,14 @@
             "current": {},
             "hide": 0,
             "sort": 1
    +      },
    +      {
    +        "name": "usableKwh",
    +        "label": "Usable battery capacity (kWh)",
    +        "type": "constant",
    +        "query": "50.8",
    +        "hide": 2,
    +        "description": "MG4 51 kWh battery: 50.8 kWh usable."
           }
         ]
       },
    @@ -187,7 +195,7 @@
         {
           "id": 4,
           "type": "stat",
    -      "title": "Battery temperature",
    +      "title": "Climate setpoint",
           "datasource": {
             "type": "influxdb",
             "uid": "mg4-influxdb"
    @@ -205,7 +213,7 @@
                 "type": "influxdb",
                 "uid": "mg4-influxdb"
               },
    -          "query": "from(bucket: \"mg4\")\n  |> range(start: -30d)\n  |> filter(fn: (r) => r._measurement == \"telemetry\" and r.installationId == \"${installationId}\" and r._field == \"batt_temp\")\n  |> last()"
    +          "query": "from(bucket: \"mg4\")\n  |> range(start: -30d)\n  |> filter(fn: (r) => r._measurement == \"telemetry\" and r.installationId == \"${installationId}\" and r._field == \"hvac_setpoint\")\n  |> last()"
             }
           ],
           "fieldConfig": {
    @@ -235,7 +243,7 @@
         {
           "id": 5,
           "type": "stat",
    -      "title": "Outside temperature",
    +      "title": "Last update",
           "datasource": {
             "type": "influxdb",
             "uid": "mg4-influxdb"
    @@ -253,17 +261,16 @@
                 "type": "influxdb",
                 "uid": "mg4-influxdb"
               },
    -          "query": "from(bucket: \"mg4\")\n  |> range(start: -30d)\n  |> filter(fn: (r) => r._measurement == \"telemetry\" and r.installationId == \"${installationId}\" and r._field == \"ext_temp\")\n  |> last()"
    +          "query": "from(bucket: \"mg4\")\n  |> range(start: -30d)\n  |> filter(fn: (r) => r._measurement == \"telemetry\" and r.installationId == \"${installationId}\" and r._field == \"soc\")\n  |> last()\n  |> map(fn: (r) => ({_value: int(v: r._time) / 1000000}))"
             }
           ],
           "fieldConfig": {
             "defaults": {
    -          "unit": "celsius",
    +          "unit": "dateTimeFromNow",
               "color": {
                 "mode": "fixed",
                 "fixedColor": "#E0424C"
    -          },
    -          "decimals": 0
    +          }
             },
             "overrides": []
           },
    @@ -278,7 +285,8 @@
             "colorMode": "value",
             "graphMode": "none",
             "textMode": "value"
    -      }
    +      },
    +      "description": "When the car last reported."
         },
         {
           "id": 6,
    @@ -301,7 +309,7 @@
                 "type": "influxdb",
                 "uid": "mg4-influxdb"
               },
    -          "query": "from(bucket: \"mg4\")\n  |> range(start: -30d)\n  |> filter(fn: (r) => r._measurement == \"telemetry\" and r.installationId == \"${installationId}\" and r._field == \"soe\")\n  |> last()"
    +          "query": "from(bucket: \"mg4\")\n  |> range(start: -30d)\n  |> filter(fn: (r) => r._measurement == \"telemetry\" and r.installationId == \"${installationId}\" and r._field == \"soc\")\n  |> last()\n  |> map(fn: (r) => ({_value: r._value / 100.0 * float(v: \"${usableKwh}\")}))"
             }
           ],
           "fieldConfig": {
    @@ -326,7 +334,8 @@
             "colorMode": "value",
             "graphMode": "none",
             "textMode": "value"
    -      }
    +      },
    +      "description": "Computed: state of charge × usable capacity (dashboard constant usableKwh, 50.8 kWh for the 51 kWh battery). The car publishes no pack energy itself."
         },
         {
           "id": 7,
    @@ -527,7 +536,7 @@
         {
           "id": 10,
           "type": "timeseries",
    -      "title": "Temperatures",
    +      "title": "Battery voltage and current",
           "datasource": {
             "type": "influxdb",
             "uid": "mg4-influxdb"
    @@ -545,12 +554,12 @@
                 "type": "influxdb",
                 "uid": "mg4-influxdb"
               },
    -          "query": "from(bucket: \"mg4\")\n  |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n  |> filter(fn: (r) => r._measurement == \"telemetry\" and r.installationId == \"${installationId}\" and (r._field == \"batt_temp\" or r._field == \"cabin_temp\" or r._field == \"ext_temp\" or r._field == \"hvac_setpoint\"))\n  |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n  |> keep(columns: [\"_time\", \"_field\", \"_value\"])"
    +          "query": "from(bucket: \"mg4\")\n  |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n  |> filter(fn: (r) => r._measurement == \"telemetry\" and r.installationId == \"${installationId}\" and (r._field == \"voltage\" or r._field == \"current\"))\n  |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n  |> keep(columns: [\"_time\", \"_field\", \"_value\"])"
             }
           ],
           "fieldConfig": {
             "defaults": {
    -          "unit": "celsius",
    +          "unit": "volt",
               "custom": {
                 "lineWidth": 2,
                 "fillOpacity": 8,
    @@ -558,7 +567,24 @@
                 "showPoints": "never"
               }
             },
    -        "overrides": []
    +        "overrides": [
    +          {
    +            "matcher": {
    +              "id": "byRegexp",
    +              "options": ".*current.*"
    +            },
    +            "properties": [
    +              {
    +                "id": "unit",
    +                "value": "amp"
    +              },
    +              {
    +                "id": "custom.axisPlacement",
    +                "value": "right"
    +              }
    +            ]
    +          }
    +        ]
           },
           "options": {
             "legend": {
    @@ -568,7 +594,8 @@
             "tooltip": {
               "mode": "multi"
             }
    -      }
    +      },
    +      "description": "Traction battery. Current is negative while charging or regenerating."
         },
         {
           "id": 11,
    
    From c5d61d1d116d9a3d6003b6df4d5f5acc179a5264 Mon Sep 17 00:00:00 2001
    From: Nils Stinnesbeck 
    Date: Sat, 17 Oct 2026 22:25:35 +0200
    Subject: [PATCH 34/79] Grafana: line charts in MG red
    
    The tiles, bar charts and route map were already MG red; the line
    charts still used Grafana's default palette. Now the first line of a
    pair is MG red (#E0424C) and the second light red (#FF9A9E), as in
    the app; the tyre chart adds deep (#A8323A) and pale (#FFD0D2) red.
    Covers SoC/range, power/speed, voltage/current, tyre pressures and the
    charging curve. Checked by rendering the Live and Charging dashboards.
    
    Co-Authored-By: Claude Opus 5.5 
    Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
    ---
     k8s/grafana/dashboards/charging.json |  30 ++++++
     k8s/grafana/dashboards/live.json     | 153 ++++++++++++++++++++++++++-
     2 files changed, 182 insertions(+), 1 deletion(-)
    
    diff --git a/k8s/grafana/dashboards/charging.json b/k8s/grafana/dashboards/charging.json
    index 9550abd..30590d1 100644
    --- a/k8s/grafana/dashboards/charging.json
    +++ b/k8s/grafana/dashboards/charging.json
    @@ -499,6 +499,36 @@
                     "value": "right"
                   }
                 ]
    +          },
    +          {
    +            "matcher": {
    +              "id": "byName",
    +              "options": "powerKw"
    +            },
    +            "properties": [
    +              {
    +                "id": "color",
    +                "value": {
    +                  "mode": "fixed",
    +                  "fixedColor": "#E0424C"
    +                }
    +              }
    +            ]
    +          },
    +          {
    +            "matcher": {
    +              "id": "byName",
    +              "options": "socPercent"
    +            },
    +            "properties": [
    +              {
    +                "id": "color",
    +                "value": {
    +                  "mode": "fixed",
    +                  "fixedColor": "#FF9A9E"
    +                }
    +              }
    +            ]
               }
             ]
           },
    diff --git a/k8s/grafana/dashboards/live.json b/k8s/grafana/dashboards/live.json
    index 73605d9..3a93f6d 100644
    --- a/k8s/grafana/dashboards/live.json
    +++ b/k8s/grafana/dashboards/live.json
    @@ -387,6 +387,36 @@
                     "value": "right"
                   }
                 ]
    +          },
    +          {
    +            "matcher": {
    +              "id": "byName",
    +              "options": "soc"
    +            },
    +            "properties": [
    +              {
    +                "id": "color",
    +                "value": {
    +                  "mode": "fixed",
    +                  "fixedColor": "#E0424C"
    +                }
    +              }
    +            ]
    +          },
    +          {
    +            "matcher": {
    +              "id": "byName",
    +              "options": "est_battery_range"
    +            },
    +            "properties": [
    +              {
    +                "id": "color",
    +                "value": {
    +                  "mode": "fixed",
    +                  "fixedColor": "#FF9A9E"
    +                }
    +              }
    +            ]
               }
             ]
           },
    @@ -450,6 +480,36 @@
                     "value": "right"
                   }
                 ]
    +          },
    +          {
    +            "matcher": {
    +              "id": "byName",
    +              "options": "power"
    +            },
    +            "properties": [
    +              {
    +                "id": "color",
    +                "value": {
    +                  "mode": "fixed",
    +                  "fixedColor": "#E0424C"
    +                }
    +              }
    +            ]
    +          },
    +          {
    +            "matcher": {
    +              "id": "byName",
    +              "options": "speed"
    +            },
    +            "properties": [
    +              {
    +                "id": "color",
    +                "value": {
    +                  "mode": "fixed",
    +                  "fixedColor": "#FF9A9E"
    +                }
    +              }
    +            ]
               }
             ]
           },
    @@ -583,6 +643,36 @@
                     "value": "right"
                   }
                 ]
    +          },
    +          {
    +            "matcher": {
    +              "id": "byName",
    +              "options": "voltage"
    +            },
    +            "properties": [
    +              {
    +                "id": "color",
    +                "value": {
    +                  "mode": "fixed",
    +                  "fixedColor": "#E0424C"
    +                }
    +              }
    +            ]
    +          },
    +          {
    +            "matcher": {
    +              "id": "byName",
    +              "options": "current"
    +            },
    +            "properties": [
    +              {
    +                "id": "color",
    +                "value": {
    +                  "mode": "fixed",
    +                  "fixedColor": "#FF9A9E"
    +                }
    +              }
    +            ]
               }
             ]
           },
    @@ -631,7 +721,68 @@
                 "showPoints": "never"
               }
             },
    -        "overrides": []
    +        "overrides": [
    +          {
    +            "matcher": {
    +              "id": "byName",
    +              "options": "tire_pressure_fl"
    +            },
    +            "properties": [
    +              {
    +                "id": "color",
    +                "value": {
    +                  "mode": "fixed",
    +                  "fixedColor": "#E0424C"
    +                }
    +              }
    +            ]
    +          },
    +          {
    +            "matcher": {
    +              "id": "byName",
    +              "options": "tire_pressure_fr"
    +            },
    +            "properties": [
    +              {
    +                "id": "color",
    +                "value": {
    +                  "mode": "fixed",
    +                  "fixedColor": "#FF9A9E"
    +                }
    +              }
    +            ]
    +          },
    +          {
    +            "matcher": {
    +              "id": "byName",
    +              "options": "tire_pressure_rl"
    +            },
    +            "properties": [
    +              {
    +                "id": "color",
    +                "value": {
    +                  "mode": "fixed",
    +                  "fixedColor": "#A8323A"
    +                }
    +              }
    +            ]
    +          },
    +          {
    +            "matcher": {
    +              "id": "byName",
    +              "options": "tire_pressure_rr"
    +            },
    +            "properties": [
    +              {
    +                "id": "color",
    +                "value": {
    +                  "mode": "fixed",
    +                  "fixedColor": "#FFD0D2"
    +                }
    +              }
    +            ]
    +          }
    +        ]
           },
           "options": {
             "legend": {
    
    From 5f6cf1e901a352aeb62fd81fccc7640517a31b9c Mon Sep 17 00:00:00 2001
    From: Nils Stinnesbeck 
    Date: Sat, 17 Oct 2026 22:27:37 +0200
    Subject: [PATCH 35/79] Revert "Grafana: line charts in MG red"
    
    Back to Grafana's default palette for the line charts, as before.
    This reverts commit c5d61d1.
    
    Co-Authored-By: Claude Opus 5.5 
    Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
    ---
     k8s/grafana/dashboards/charging.json |  30 ------
     k8s/grafana/dashboards/live.json     | 153 +--------------------------
     2 files changed, 1 insertion(+), 182 deletions(-)
    
    diff --git a/k8s/grafana/dashboards/charging.json b/k8s/grafana/dashboards/charging.json
    index 30590d1..9550abd 100644
    --- a/k8s/grafana/dashboards/charging.json
    +++ b/k8s/grafana/dashboards/charging.json
    @@ -499,36 +499,6 @@
                     "value": "right"
                   }
                 ]
    -          },
    -          {
    -            "matcher": {
    -              "id": "byName",
    -              "options": "powerKw"
    -            },
    -            "properties": [
    -              {
    -                "id": "color",
    -                "value": {
    -                  "mode": "fixed",
    -                  "fixedColor": "#E0424C"
    -                }
    -              }
    -            ]
    -          },
    -          {
    -            "matcher": {
    -              "id": "byName",
    -              "options": "socPercent"
    -            },
    -            "properties": [
    -              {
    -                "id": "color",
    -                "value": {
    -                  "mode": "fixed",
    -                  "fixedColor": "#FF9A9E"
    -                }
    -              }
    -            ]
               }
             ]
           },
    diff --git a/k8s/grafana/dashboards/live.json b/k8s/grafana/dashboards/live.json
    index 3a93f6d..73605d9 100644
    --- a/k8s/grafana/dashboards/live.json
    +++ b/k8s/grafana/dashboards/live.json
    @@ -387,36 +387,6 @@
                     "value": "right"
                   }
                 ]
    -          },
    -          {
    -            "matcher": {
    -              "id": "byName",
    -              "options": "soc"
    -            },
    -            "properties": [
    -              {
    -                "id": "color",
    -                "value": {
    -                  "mode": "fixed",
    -                  "fixedColor": "#E0424C"
    -                }
    -              }
    -            ]
    -          },
    -          {
    -            "matcher": {
    -              "id": "byName",
    -              "options": "est_battery_range"
    -            },
    -            "properties": [
    -              {
    -                "id": "color",
    -                "value": {
    -                  "mode": "fixed",
    -                  "fixedColor": "#FF9A9E"
    -                }
    -              }
    -            ]
               }
             ]
           },
    @@ -480,36 +450,6 @@
                     "value": "right"
                   }
                 ]
    -          },
    -          {
    -            "matcher": {
    -              "id": "byName",
    -              "options": "power"
    -            },
    -            "properties": [
    -              {
    -                "id": "color",
    -                "value": {
    -                  "mode": "fixed",
    -                  "fixedColor": "#E0424C"
    -                }
    -              }
    -            ]
    -          },
    -          {
    -            "matcher": {
    -              "id": "byName",
    -              "options": "speed"
    -            },
    -            "properties": [
    -              {
    -                "id": "color",
    -                "value": {
    -                  "mode": "fixed",
    -                  "fixedColor": "#FF9A9E"
    -                }
    -              }
    -            ]
               }
             ]
           },
    @@ -643,36 +583,6 @@
                     "value": "right"
                   }
                 ]
    -          },
    -          {
    -            "matcher": {
    -              "id": "byName",
    -              "options": "voltage"
    -            },
    -            "properties": [
    -              {
    -                "id": "color",
    -                "value": {
    -                  "mode": "fixed",
    -                  "fixedColor": "#E0424C"
    -                }
    -              }
    -            ]
    -          },
    -          {
    -            "matcher": {
    -              "id": "byName",
    -              "options": "current"
    -            },
    -            "properties": [
    -              {
    -                "id": "color",
    -                "value": {
    -                  "mode": "fixed",
    -                  "fixedColor": "#FF9A9E"
    -                }
    -              }
    -            ]
               }
             ]
           },
    @@ -721,68 +631,7 @@
                 "showPoints": "never"
               }
             },
    -        "overrides": [
    -          {
    -            "matcher": {
    -              "id": "byName",
    -              "options": "tire_pressure_fl"
    -            },
    -            "properties": [
    -              {
    -                "id": "color",
    -                "value": {
    -                  "mode": "fixed",
    -                  "fixedColor": "#E0424C"
    -                }
    -              }
    -            ]
    -          },
    -          {
    -            "matcher": {
    -              "id": "byName",
    -              "options": "tire_pressure_fr"
    -            },
    -            "properties": [
    -              {
    -                "id": "color",
    -                "value": {
    -                  "mode": "fixed",
    -                  "fixedColor": "#FF9A9E"
    -                }
    -              }
    -            ]
    -          },
    -          {
    -            "matcher": {
    -              "id": "byName",
    -              "options": "tire_pressure_rl"
    -            },
    -            "properties": [
    -              {
    -                "id": "color",
    -                "value": {
    -                  "mode": "fixed",
    -                  "fixedColor": "#A8323A"
    -                }
    -              }
    -            ]
    -          },
    -          {
    -            "matcher": {
    -              "id": "byName",
    -              "options": "tire_pressure_rr"
    -            },
    -            "properties": [
    -              {
    -                "id": "color",
    -                "value": {
    -                  "mode": "fixed",
    -                  "fixedColor": "#FFD0D2"
    -                }
    -              }
    -            ]
    -          }
    -        ]
    +        "overrides": []
           },
           "options": {
             "legend": {
    
    From a53ea589b2db3e038692b44f8f102805e6c1a4fa Mon Sep 17 00:00:00 2001
    From: Nils Stinnesbeck 
    Date: Sat, 17 Oct 2026 22:33:10 +0200
    Subject: [PATCH 36/79] App 2.5.0: range estimates from current and 5-minute
     average power
    MIME-Version: 1.0
    Content-Type: text/plain; charset=UTF-8
    Content-Transfer-Encoding: 8bit
    
    The Vehicle tab gets two tiles next to the car's own range:
    
    - Range at current power: remaining energy / (power / speed) right now.
    - Range at 5-min average: remaining energy / (energy used / distance
      driven) over the last 5 minutes, integrated time-weighted from both
      the service tick (15 s) and the open screen (2 s), so it is ready as
      soon as the app opens.
    
    Each shows the consumption it is based on (kWh/100 km). Remaining
    energy is SOC x usable capacity, because the car publishes no pack
    energy; the capacity is a new setting in the Consumption tab (default
    50.8 kWh, the 51 kWh battery). Estimates read "—" with the reason when
    they cannot mean anything: standing still, regenerating, too little
    driving, or across a gap in the samples; over 999 km shows "> 999 km".
    
    RangeEstimator is Android-free and has 7 tests. Also fixes the battery
    card's note, which still said the SoH goes to MongoDB.
    
    Co-Authored-By: Claude Opus 5.5 
    Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
    ---
     app/build.gradle                              |   4 +-
     .../com/evsuite/abrp/AbrpUploadService.java   |   2 +
     .../java/com/evsuite/abrp/MainActivity.java   |  61 +++++++++-
     .../java/com/evsuite/abrp/RangeEstimator.java | 107 ++++++++++++++++++
     .../main/res/layout-w1000dp/pane_vehicle.xml  |  13 +++
     app/src/main/res/layout/pane_consumption.xml  |   7 +-
     app/src/main/res/layout/pane_vehicle.xml      |  10 ++
     app/src/main/res/values/strings.xml           |  11 ++
     .../com/evsuite/abrp/RangeEstimatorTest.java  |  85 ++++++++++++++
     9 files changed, 296 insertions(+), 4 deletions(-)
     create mode 100644 app/src/main/java/com/evsuite/abrp/RangeEstimator.java
     create mode 100644 app/src/test/java/com/evsuite/abrp/RangeEstimatorTest.java
    
    diff --git a/app/build.gradle b/app/build.gradle
    index 09ee60c..a04f404 100644
    --- a/app/build.gradle
    +++ b/app/build.gradle
    @@ -34,8 +34,8 @@ android {
             applicationId "com.stinnesbeck.nilsmg4"
             minSdk 27
             targetSdk 36
    -        versionCode 61
    -        versionName "2.4.3"
    +        versionCode 62
    +        versionName "2.5.0"
     
             // Nothing about the server ships in the APK: its URL and ingest token are entered in
             // the app's Server tab (ServerConfig), and the database is only reachable by the server.
    diff --git a/app/src/main/java/com/evsuite/abrp/AbrpUploadService.java b/app/src/main/java/com/evsuite/abrp/AbrpUploadService.java
    index 74e1a33..dac4284 100644
    --- a/app/src/main/java/com/evsuite/abrp/AbrpUploadService.java
    +++ b/app/src/main/java/com/evsuite/abrp/AbrpUploadService.java
    @@ -155,6 +155,8 @@ public class AbrpUploadService extends Service {
         private void doUpload() {
             long sampleMs = System.currentTimeMillis();
             EnergySnapshot vehicle = energyReader.read(sampleMs);
    +        // Keeps the 5-minute range average going while the app is in the background.
    +        RangeEstimator.shared().add(sampleMs, vehicle.getBatteryPowerKw(), vehicle.getSpeedKmh());
             consumptionTracker.sample(vehicle);
             chargeSessionTracker.sample(vehicle);
             consumptionCloudClient.syncIfDue(consumptionTracker, chargeSessionTracker, sampleMs);
    diff --git a/app/src/main/java/com/evsuite/abrp/MainActivity.java b/app/src/main/java/com/evsuite/abrp/MainActivity.java
    index fb7d9d6..57b4aa1 100644
    --- a/app/src/main/java/com/evsuite/abrp/MainActivity.java
    +++ b/app/src/main/java/com/evsuite/abrp/MainActivity.java
    @@ -89,7 +89,13 @@ public class MainActivity extends AppCompatActivity {
         private EnergyTelemetryReader vehicleReader;
         private TextView vehSoc, vehRange, vehPower, vehSpeed, vehOdometer,
                 vehCharging, vehHvac,
    -            vehTireFl, vehTireFr, vehTireRl, vehTireRr;
    +            vehTireFl, vehTireFr, vehTireRl, vehTireRr,
    +            vehRangePower, vehRangePowerNote, vehRangeAverage, vehRangeAverageNote;
    +
    +    /** Usable pack capacity for the range estimates; the car does not publish it. */
    +    static final String USABLE_KWH_KEY = "usable_kwh";
    +    private TextInputLayout usableCapacityLayout;
    +    private TextInputEditText usableCapacityInput;
         private ConsumptionTracker consumptionTracker;
         private ConsumptionCloudClient consumptionCloudClient;
         private ConsumptionTracker.Period consumptionPeriod = ConsumptionTracker.Period.LIFETIME;
    @@ -921,6 +927,41 @@ public class MainActivity extends AppCompatActivity {
             vehTireFr    = vehiclePane.findViewById(R.id.vehicle_tire_fr);
             vehTireRl    = vehiclePane.findViewById(R.id.vehicle_tire_rl);
             vehTireRr    = vehiclePane.findViewById(R.id.vehicle_tire_rr);
    +        vehRangePower       = vehiclePane.findViewById(R.id.vehicle_range_power);
    +        vehRangePowerNote   = vehiclePane.findViewById(R.id.vehicle_range_power_note);
    +        vehRangeAverage     = vehiclePane.findViewById(R.id.vehicle_range_average);
    +        vehRangeAverageNote = vehiclePane.findViewById(R.id.vehicle_range_average_note);
    +    }
    +
    +    private double usableKwh() {
    +        return prefs.getFloat(USABLE_KWH_KEY, (float) RangeEstimator.DEFAULT_USABLE_KWH);
    +    }
    +
    +    /**
    +     * The two range estimates. Both need the car to be moving on battery power; the average
    +     * also needs a minute or more of driving, which the service has usually collected already.
    +     */
    +    private void refreshRangeEstimates(EnergySnapshot s, long nowMs) {
    +        RangeEstimator estimator = RangeEstimator.shared();
    +        estimator.add(nowMs, s.getBatteryPowerKw(), s.getSpeedKmh());
    +        Double remaining = RangeEstimator.remainingKwh(s.getSocPercent(), usableKwh());
    +        showEstimate(vehRangePower, vehRangePowerNote,
    +                RangeEstimator.instant(remaining, s.getBatteryPowerKw(), s.getSpeedKmh()),
    +                R.string.range_not_driving);
    +        showEstimate(vehRangeAverage, vehRangeAverageNote,
    +                estimator.average(remaining, nowMs), R.string.range_waiting);
    +    }
    +
    +    private void showEstimate(TextView value, TextView note, RangeEstimator.Estimate e, int whyNot) {
    +        if (e == null) {
    +            value.setText("—");
    +            note.setText(whyNot);
    +            return;
    +        }
    +        // Coasting at speed costs almost nothing, which would read as thousands of km.
    +        value.setText(e.rangeKm > 999 ? getString(R.string.range_over, 999)
    +                : String.format(java.util.Locale.US, "%.0f km", e.rangeKm));
    +        note.setText(getString(R.string.range_consumption, e.kwhPer100Km));
         }
     
         /**
    @@ -948,6 +989,7 @@ public class MainActivity extends AppCompatActivity {
                 vehHvac.setText(fmt(s.getClimate().getDriverTargetCelsius(), "%.0f °C"));
     
                 vehCharging.setText(chargingLabel(s));
    +            refreshRangeEstimates(s, System.currentTimeMillis());
     
                 vehTireFl.setText(fmt(s.getTirePressures().getFrontLeftKpa(), "%.0f kPa"));
                 vehTireFr.setText(fmt(s.getTirePressures().getFrontRightKpa(), "%.0f kPa"));
    @@ -979,6 +1021,10 @@ public class MainActivity extends AppCompatActivity {
             if (savedSoh != null) consumptionSohInput.setText(String.format(
                     java.util.Locale.US, "%.1f", savedSoh));
             consumptionPane.findViewById(R.id.consumption_soh_save).setOnClickListener(v -> saveSoh());
    +        usableCapacityLayout = consumptionPane.findViewById(R.id.usable_capacity_layout);
    +        usableCapacityInput = consumptionPane.findViewById(R.id.usable_capacity_input);
    +        usableCapacityInput.setText(String.format(java.util.Locale.US, "%.1f", usableKwh()));
    +        consumptionPane.findViewById(R.id.usable_capacity_save).setOnClickListener(v -> saveUsableCapacity());
             bindPeriodButton(R.id.consumption_lifetime, ConsumptionTracker.Period.LIFETIME);
             bindPeriodButton(R.id.consumption_week, ConsumptionTracker.Period.WEEK);
             bindPeriodButton(R.id.consumption_month, ConsumptionTracker.Period.MONTH);
    @@ -998,6 +1044,19 @@ public class MainActivity extends AppCompatActivity {
             });
         }
     
    +    private void saveUsableCapacity() {
    +        try {
    +            float value = Float.parseFloat(textOf(usableCapacityInput).replace(',', '.'));
    +            if (!(value >= 10f && value <= 150f)) throw new NumberFormatException();
    +            prefs.edit().putFloat(USABLE_KWH_KEY, value).apply();
    +            usableCapacityLayout.setError(null);
    +            usableCapacityInput.setText(String.format(java.util.Locale.US, "%.1f", value));
    +            Toast.makeText(this, R.string.usable_capacity_saved, Toast.LENGTH_SHORT).show();
    +        } catch (NumberFormatException e) {
    +            usableCapacityLayout.setError(getString(R.string.usable_capacity_invalid));
    +        }
    +    }
    +
         private void saveSoh() {
             String raw = textOf(consumptionSohInput).replace(',', '.');
             try {
    diff --git a/app/src/main/java/com/evsuite/abrp/RangeEstimator.java b/app/src/main/java/com/evsuite/abrp/RangeEstimator.java
    new file mode 100644
    index 0000000..120db9a
    --- /dev/null
    +++ b/app/src/main/java/com/evsuite/abrp/RangeEstimator.java
    @@ -0,0 +1,107 @@
    +package com.evsuite.abrp;
    +
    +import java.util.ArrayDeque;
    +import java.util.Iterator;
    +
    +/**
    + * Estimated range from the energy left in the pack and how fast it is being used.
    + *
    + *   instant:   remaining kWh / (power now / speed now)
    + *   average:   remaining kWh / (energy used / distance driven over the last 5 minutes)
    + *
    + * Remaining energy is SOC x usable capacity: the car publishes no pack energy, so the capacity
    + * is a setting. Every estimate is null when it cannot mean anything — standing still,
    + * regenerating, too little data — rather than a misleading number.
    + *
    + * Fed by both the service tick (15 s) and the open Vehicle page (2 s); the integration is
    + * time-weighted, so the two rates mix correctly. Free of Android types, unit-tested.
    + */
    +final class RangeEstimator {
    +
    +    static final long WINDOW_MS = 5 * 60_000L;
    +    /** Less than this much driving in the window is too little to average. */
    +    static final long MIN_WINDOW_MS = 60_000L;
    +    static final double MIN_DISTANCE_KM = 0.2;
    +    /** Below this the car is not really moving, and power / speed blows up. */
    +    static final double MIN_SPEED_KMH = 5;
    +    /** A gap between samples longer than this (app paused, car off) is not integrated. */
    +    static final long MAX_GAP_MS = 60_000L;
    +
    +    static final double DEFAULT_USABLE_KWH = 50.8; // MG4 51 kWh battery
    +
    +    private static final RangeEstimator SHARED = new RangeEstimator();
    +
    +    static RangeEstimator shared() { return SHARED; }
    +
    +    /** An estimate: the range and the consumption it is based on. */
    +    static final class Estimate {
    +        final double rangeKm;
    +        final double kwhPer100Km;
    +
    +        Estimate(double rangeKm, double kwhPer100Km) {
    +            this.rangeKm = rangeKm;
    +            this.kwhPer100Km = kwhPer100Km;
    +        }
    +    }
    +
    +    private static final class Sample {
    +        final long timeMs;
    +        final double powerKw;
    +        final double speedKmh;
    +
    +        Sample(long timeMs, double powerKw, double speedKmh) {
    +            this.timeMs = timeMs;
    +            this.powerKw = powerKw;
    +            this.speedKmh = speedKmh;
    +        }
    +    }
    +
    +    private final ArrayDeque samples = new ArrayDeque<>();
    +
    +    /** Power in kW, positive leaving the battery; speed in km/h. Unread values are skipped. */
    +    synchronized void add(long timeMs, Float powerKw, Float speedKmh) {
    +        if (powerKw == null || speedKmh == null || !Float.isFinite(powerKw) || !Float.isFinite(speedKmh)) return;
    +        Sample last = samples.peekLast();
    +        if (last != null && timeMs <= last.timeMs) return; // out of order or duplicate tick
    +        samples.addLast(new Sample(timeMs, powerKw, Math.max(0f, speedKmh)));
    +        while (!samples.isEmpty() && samples.peekFirst().timeMs < timeMs - WINDOW_MS) samples.removeFirst();
    +    }
    +
    +    static Double remainingKwh(Float socPercent, double usableKwh) {
    +        if (socPercent == null || !Float.isFinite(socPercent) || usableKwh <= 0) return null;
    +        return Math.max(0, Math.min(100, socPercent)) / 100.0 * usableKwh;
    +    }
    +
    +    static Estimate instant(Double remainingKwh, Float powerKw, Float speedKmh) {
    +        if (remainingKwh == null || powerKw == null || speedKmh == null) return null;
    +        if (speedKmh < MIN_SPEED_KMH || powerKw <= 0) return null;
    +        double kwhPerKm = powerKw.doubleValue() / speedKmh.doubleValue();
    +        return new Estimate(remainingKwh / kwhPerKm, kwhPerKm * 100);
    +    }
    +
    +    synchronized Estimate average(Double remainingKwh, long nowMs) {
    +        if (remainingKwh == null) return null;
    +        double energyKwh = 0, distanceKm = 0;
    +        long covered = 0;
    +        Sample previous = null;
    +        for (Iterator it = samples.iterator(); it.hasNext(); ) {
    +            Sample s = it.next();
    +            if (s.timeMs < nowMs - WINDOW_MS) continue;
    +            if (previous != null) {
    +                long dt = s.timeMs - previous.timeMs;
    +                if (dt > 0 && dt <= MAX_GAP_MS) {
    +                    double hours = dt / 3_600_000.0;
    +                    energyKwh += (previous.powerKw + s.powerKw) / 2 * hours;
    +                    distanceKm += (previous.speedKmh + s.speedKmh) / 2 * hours;
    +                    covered += dt;
    +                }
    +            }
    +            previous = s;
    +        }
    +        if (covered < MIN_WINDOW_MS || distanceKm < MIN_DISTANCE_KM || energyKwh <= 0) return null;
    +        double kwhPerKm = energyKwh / distanceKm;
    +        return new Estimate(remainingKwh / kwhPerKm, kwhPerKm * 100);
    +    }
    +
    +    synchronized void clear() { samples.clear(); }
    +}
    diff --git a/app/src/main/res/layout-w1000dp/pane_vehicle.xml b/app/src/main/res/layout-w1000dp/pane_vehicle.xml
    index 676aa76..ff3cb6b 100644
    --- a/app/src/main/res/layout-w1000dp/pane_vehicle.xml
    +++ b/app/src/main/res/layout-w1000dp/pane_vehicle.xml
    @@ -57,5 +57,18 @@
                     
                 
             
    +        
    +        
    +            
    +                
    +                
    +                
    +            
    +            
    +                
    +                
    +                
    +            
    +        
         
     
    diff --git a/app/src/main/res/layout/pane_consumption.xml b/app/src/main/res/layout/pane_consumption.xml
    index da37318..1222f28 100644
    --- a/app/src/main/res/layout/pane_consumption.xml
    +++ b/app/src/main/res/layout/pane_consumption.xml
    @@ -32,7 +32,12 @@
             
           
           
    -      
    +      
    +      
    +        
    +      
    +      
    +      
         
         
           
    diff --git a/app/src/main/res/layout/pane_vehicle.xml b/app/src/main/res/layout/pane_vehicle.xml
    index f4cc6c7..6ce8956 100644
    --- a/app/src/main/res/layout/pane_vehicle.xml
    +++ b/app/src/main/res/layout/pane_vehicle.xml
    @@ -54,6 +54,16 @@
                     
                     
                 
    +            
    +                
    +                
    +                
    +            
    +            
    +                
    +                
    +                
    +            
             
         
     
    diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
    index b92d940..b99000d 100644
    --- a/app/src/main/res/values/strings.xml
    +++ b/app/src/main/res/values/strings.xml
    @@ -31,6 +31,17 @@
         Charge (SOC)
         Range
         Power
    +    Range at current power
    +    Range at 5-min average
    +    %1$.1f kWh/100 km
    +    > %1$d km
    +    needs a few minutes of driving
    +    only while driving
    +    Usable battery capacity
    +    Save capacity
    +    Used for the range estimates on the Vehicle tab. The 51 kWh MG4 battery has 50.8 kWh usable, the 64 kWh one 61.7, the 77 kWh one 74.4.
    +    Usable capacity saved
    +    Enter a value between 10 and 150 kWh
         Speed
         Odometer
         Battery Temperature
    diff --git a/app/src/test/java/com/evsuite/abrp/RangeEstimatorTest.java b/app/src/test/java/com/evsuite/abrp/RangeEstimatorTest.java
    new file mode 100644
    index 0000000..6b73e59
    --- /dev/null
    +++ b/app/src/test/java/com/evsuite/abrp/RangeEstimatorTest.java
    @@ -0,0 +1,85 @@
    +package com.evsuite.abrp;
    +
    +import static org.junit.Assert.assertEquals;
    +import static org.junit.Assert.assertNotNull;
    +import static org.junit.Assert.assertNull;
    +
    +import org.junit.Test;
    +
    +/** Range estimates from power and speed — pure JVM. */
    +public class RangeEstimatorTest {
    +
    +    private static final double USABLE = 50.8;
    +
    +    @Test
    +    public void remainingEnergyIsSocTimesUsableCapacity() {
    +        assertEquals(25.4, RangeEstimator.remainingKwh(50f, USABLE), 1e-9);
    +        assertEquals(50.8, RangeEstimator.remainingKwh(120f, USABLE), 1e-9); // clamped
    +        assertNull(RangeEstimator.remainingKwh(null, USABLE));
    +    }
    +
    +    @Test
    +    public void instantRangeFromPowerAndSpeed() {
    +        // 15 kW at 100 km/h = 15 kWh/100 km; 25.4 kWh left = 169.3 km.
    +        RangeEstimator.Estimate e = RangeEstimator.instant(25.4, 15f, 100f);
    +        assertNotNull(e);
    +        assertEquals(15.0, e.kwhPer100Km, 1e-9);
    +        assertEquals(169.333, e.rangeKm, 0.001);
    +    }
    +
    +    @Test
    +    public void noInstantEstimateWhenStandingRegeneratingOrUnread() {
    +        assertNull(RangeEstimator.instant(25.4, 2f, 3f));     // not really moving
    +        assertNull(RangeEstimator.instant(25.4, -10f, 60f));  // regenerating
    +        assertNull(RangeEstimator.instant(25.4, null, 60f));  // power unread
    +        assertNull(RangeEstimator.instant(null, 15f, 60f));   // SOC unread
    +    }
    +
    +    @Test
    +    public void averageIntegratesEnergyOverDistance() {
    +        RangeEstimator r = new RangeEstimator();
    +        // 4 minutes at a steady 20 kW and 100 km/h, sampled every 2 s: 20 kWh/100 km.
    +        for (long t = 0; t <= 240_000; t += 2_000) r.add(t, 20f, 100f);
    +        RangeEstimator.Estimate e = r.average(25.4, 240_000);
    +        assertNotNull(e);
    +        assertEquals(20.0, e.kwhPer100Km, 1e-6);
    +        assertEquals(127.0, e.rangeKm, 1e-6);
    +    }
    +
    +    @Test
    +    public void averageMixesTheServiceAndScreenRates() {
    +        RangeEstimator r = new RangeEstimator();
    +        // A slow 15 s service tick for two minutes, then the 2 s screen tick: still 20 kWh/100 km.
    +        for (long t = 0; t < 120_000; t += 15_000) r.add(t, 20f, 100f);
    +        for (long t = 120_000; t <= 240_000; t += 2_000) r.add(t, 20f, 100f);
    +        assertEquals(20.0, r.average(25.4, 240_000).kwhPer100Km, 1e-6);
    +    }
    +
    +    @Test
    +    public void averageOnlyCoversTheLastFiveMinutes() {
    +        RangeEstimator r = new RangeEstimator();
    +        for (long t = 0; t <= 300_000; t += 2_000) r.add(t, 40f, 100f);        // thirsty start
    +        for (long t = 302_000; t <= 900_000; t += 2_000) r.add(t, 10f, 100f);  // then economical
    +        assertEquals(10.0, r.average(25.4, 900_000).kwhPer100Km, 1e-6);
    +    }
    +
    +    @Test
    +    public void noAverageWithTooLittleDataOrDistanceOrAcrossAGap() {
    +        RangeEstimator r = new RangeEstimator();
    +        for (long t = 0; t <= 30_000; t += 2_000) r.add(t, 20f, 100f);
    +        assertNull("30 s is too little", r.average(25.4, 30_000));
    +
    +        RangeEstimator parked = new RangeEstimator();
    +        for (long t = 0; t <= 240_000; t += 2_000) parked.add(t, 1f, 0f);
    +        assertNull("no distance", parked.average(25.4, 240_000));
    +
    +        RangeEstimator gap = new RangeEstimator();
    +        gap.add(0, 20f, 100f);
    +        gap.add(200_000, 20f, 100f); // the app was closed in between
    +        assertNull("a gap is not integrated", gap.average(25.4, 200_000));
    +
    +        RangeEstimator downhill = new RangeEstimator();
    +        for (long t = 0; t <= 240_000; t += 2_000) downhill.add(t, -5f, 80f);
    +        assertNull("net regeneration", downhill.average(25.4, 240_000));
    +    }
    +}
    
    From 11d53e7bc7ae23e4004104d1bffc1342e3e1679a Mon Sep 17 00:00:00 2001
    From: Nils Stinnesbeck 
    Date: Sat, 17 Oct 2026 22:55:15 +0200
    Subject: [PATCH 37/79] App 2.6.0: floating button to open the app
    
    The MG4's launcher does not list third-party apps, so the app could
    only be opened by reinstalling it. Now a small round MG-red button
    floats over the car's screens: tap opens the app, drag moves it (the
    position is kept and clamped to the screen), and it hides while the
    app itself is open. A switch in the Upload tab turns it off.
    
    The always-on service owns the overlay, so the button is there from
    boot. It needs the overlay permission (SYSTEM_ALERT_WINDOW), which the
    car build has through the system UID; without it, or if the window
    manager refuses, the button stays away and nothing crashes, and the
    Upload tab offers the system permission screen.
    
    Tested on an Android 9 AOSP emulator at the head unit's 1920x1080 /
    160 dpi with the car APK (installs as UID 1000): button appears over
    the home screen, tap opens the app, hidden while open, drag moves it
    without opening the app, position survives a force-stop, the switch
    works through the UI. FloatingButtonGeometry has 3 unit tests.
    
    Co-Authored-By: Claude Opus 5.5 
    Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
    ---
     SPECS.md                                      |   5 +
     app/build.gradle                              |   4 +-
     app/src/main/AndroidManifest.xml              |   2 +
     .../com/evsuite/abrp/AbrpUploadService.java   |   5 +
     .../java/com/evsuite/abrp/FloatingButton.java | 224 ++++++++++++++++++
     .../evsuite/abrp/FloatingButtonGeometry.java  |  31 +++
     .../java/com/evsuite/abrp/MainActivity.java   |  32 ++-
     .../java/com/evsuite/abrp/UploadSettings.java |   7 +
     .../main/res/layout-w1000dp/pane_service.xml  |   6 +
     app/src/main/res/layout/pane_service.xml      |   6 +
     app/src/main/res/values/strings.xml           |   6 +
     .../abrp/FloatingButtonGeometryTest.java      |  37 +++
     12 files changed, 362 insertions(+), 3 deletions(-)
     create mode 100644 app/src/main/java/com/evsuite/abrp/FloatingButton.java
     create mode 100644 app/src/main/java/com/evsuite/abrp/FloatingButtonGeometry.java
     create mode 100644 app/src/test/java/com/evsuite/abrp/FloatingButtonGeometryTest.java
    
    diff --git a/SPECS.md b/SPECS.md
    index 15496f6..f1bd1f5 100644
    --- a/SPECS.md
    +++ b/SPECS.md
    @@ -114,6 +114,11 @@ tyre pressures, GPS (lat, lon, elevation, heading) and the is_* flags. It does *
       INGEST_TOKEN) into git and pushed it. Commit amended and force-pushed; both values regenerated in
       the now gitignored `k8s/secrets.yaml` (since moved to `k8s/secrets/secrets.yaml`, outside
       `kubectl apply -f k8s/`). Filled-in secrets live only there from now on.
    +- 2026-10-17 — App 2.6.0: floating button to open the app, because the MG4 launcher does not list
    +  it. Drawn over other apps (needs the overlay permission, which the car build has through the
    +  system UID), draggable, hidden while the app is open, switch in the Upload tab. Tested on an
    +  Android 9 AOSP emulator at 1920x1080/160 dpi with the car APK (installs as UID 1000 there too).
    +- 2026-10-17 — App 2.5.0: range estimates at current and 5-minute average power (Vehicle tab).
     - 2026-10-17 — Live dashboard reworked around the signals the car sends: Climate setpoint, Last
       update and a computed Energy in pack replace the empty temperature tiles; Battery voltage and
       current replaces the Temperatures chart.
    diff --git a/app/build.gradle b/app/build.gradle
    index a04f404..0b35644 100644
    --- a/app/build.gradle
    +++ b/app/build.gradle
    @@ -34,8 +34,8 @@ android {
             applicationId "com.stinnesbeck.nilsmg4"
             minSdk 27
             targetSdk 36
    -        versionCode 62
    -        versionName "2.5.0"
    +        versionCode 63
    +        versionName "2.6.0"
     
             // Nothing about the server ships in the APK: its URL and ingest token are entered in
             // the app's Server tab (ServerConfig), and the database is only reachable by the server.
    diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
    index 36b8413..191f880 100644
    --- a/app/src/main/AndroidManifest.xml
    +++ b/app/src/main/AndroidManifest.xml
    @@ -13,6 +13,8 @@
         
         
         
    +    
    +    
     
         
    -    
     
         
         Config imported and saved — press Test to verify
    +    Config imported and saved; the file was deleted
    +    Config imported and saved, but the file could not be deleted. It holds your token: delete %1$s
         Could not read that file
         No settings found in that file
         No config file found. Copy it to %1$s
    diff --git a/app/src/test/java/com/evsuite/abrp/SecurePrefsTest.java b/app/src/test/java/com/evsuite/abrp/SecurePrefsTest.java
    new file mode 100644
    index 0000000..cb60b31
    --- /dev/null
    +++ b/app/src/test/java/com/evsuite/abrp/SecurePrefsTest.java
    @@ -0,0 +1,40 @@
    +package com.evsuite.abrp;
    +
    +import static org.junit.Assert.assertEquals;
    +import static org.junit.Assert.assertFalse;
    +
    +import java.util.HashMap;
    +import java.util.Map;
    +
    +import org.junit.Test;
    +
    +/** Moving a keystore-fallback run's settings back into encrypted storage — pure JVM. */
    +public class SecurePrefsTest {
    +
    +    private static Map map(String... kv) {
    +        Map m = new HashMap<>();
    +        for (int i = 0; i < kv.length; i += 2) m.put(kv[i], kv[i + 1]);
    +        return m;
    +    }
    +
    +    @Test
    +    public void aTokenSavedDuringTheFallbackIsKept() {
    +        Map moves = SecurePrefs.mergePlaintext(
    +                map(ServerConfig.KEY_TOKEN, "new-token", ServerConfig.KEY_URL, "https://car.example.com"),
    +                map(ServerConfig.KEY_TOKEN, "old-token"));
    +        assertEquals("new-token", moves.get(ServerConfig.KEY_TOKEN));
    +        assertEquals("https://car.example.com", moves.get(ServerConfig.KEY_URL));
    +    }
    +
    +    @Test
    +    public void theStoredInstallationIdWins() {
    +        Map moves = SecurePrefs.mergePlaintext(
    +                map(ServerConfig.KEY_INSTALLATION_ID, "made-up-in-the-fallback"),
    +                map(ServerConfig.KEY_INSTALLATION_ID, "the-one-history-is-under"));
    +        assertFalse(moves.containsKey(ServerConfig.KEY_INSTALLATION_ID));
    +
    +        // With none stored yet, the fallback's id is the only one there is.
    +        moves = SecurePrefs.mergePlaintext(map(ServerConfig.KEY_INSTALLATION_ID, "only-one"), map());
    +        assertEquals("only-one", moves.get(ServerConfig.KEY_INSTALLATION_ID));
    +    }
    +}
    diff --git a/app/src/test/java/com/evsuite/abrp/ServerConfigTest.java b/app/src/test/java/com/evsuite/abrp/ServerConfigTest.java
    index f5bf0c8..ea803d4 100644
    --- a/app/src/test/java/com/evsuite/abrp/ServerConfigTest.java
    +++ b/app/src/test/java/com/evsuite/abrp/ServerConfigTest.java
    @@ -44,4 +44,21 @@ public class ServerConfigTest {
             assertFalse(ServerConfig.isValidInstallationId("not-a-uuid"));
             assertFalse(ServerConfig.isValidInstallationId(""));
         }
    +
    +    @Test
    +    public void theInstallationIdFollowsTheHeadUnit() {
    +        String id = ServerConfig.installationIdFor("a1b2c3d4e5f60718");
    +        assertTrue(id, ServerConfig.isValidInstallationId(id));
    +        // The same head unit gets the same id after a reinstall; another gets another.
    +        assertEquals(id, ServerConfig.installationIdFor(" A1B2C3D4E5F60718 "));
    +        assertFalse(id.equals(ServerConfig.installationIdFor("0000000000000001")));
    +        assertFalse(id.contains("a1b2c3d4e5f60718"));
    +    }
    +
    +    @Test
    +    public void noUsableAndroidIdMeansNoDerivedId() {
    +        assertNull(ServerConfig.installationIdFor(null));
    +        assertNull(ServerConfig.installationIdFor(""));
    +        assertNull(ServerConfig.installationIdFor("9774d56d682e549c"));
    +    }
     }
    diff --git a/app/src/test/java/com/evsuite/abrp/TelemetryPayloadTest.java b/app/src/test/java/com/evsuite/abrp/TelemetryPayloadTest.java
    index dc184a0..f91af99 100644
    --- a/app/src/test/java/com/evsuite/abrp/TelemetryPayloadTest.java
    +++ b/app/src/test/java/com/evsuite/abrp/TelemetryPayloadTest.java
    @@ -311,4 +311,45 @@ public class TelemetryPayloadTest {
             assertTrue(summary, summary.startsWith("utc=1700000000"));
             assertTrue(summary, summary.contains("| omitted: soc,speed,"));
         }
    +
    +    // ---------- The server's ranges (server/model.go) ----------
    +
    +    @Test
    +    public void reversingSendsSpeedAsAMagnitude() throws Exception {
    +        TelemetryPayload t = new TelemetryPayload(1_700_000_000L);
    +        t.speedKmh = -4.2f;
    +        assertEquals(4, new JSONObject(t.build()).getInt("speed"));
    +    }
    +
    +    @Test
    +    public void valuesOutsideTheServersRangeAreLeftOut() throws Exception {
    +        TelemetryPayload t = new TelemetryPayload(1_700_000_000L);
    +        t.soc = 55;
    +        t.extTempC = 72f;        // plausible to the car's guard, above the server's 70
    +        t.hvacSetpointC = 50f;   // a HI sentinel, above the server's 40
    +        t.rangeKm = 2500;
    +        JSONObject json = new JSONObject(t.build());
    +        assertEquals(55, json.getInt("soc"));
    +        assertFalse(json.has("ext_temp"));
    +        assertFalse(json.has("hvac_setpoint"));
    +        assertFalse(json.has("est_battery_range"));
    +    }
    +
    +    @Test
    +    public void theAppsLimitsMatchEveryNumericServerField() {
    +        String[] serverFields = {"soc", "speed", "power", "est_battery_range", "capacity", "soe",
    +                "kwh_charged", "odometer", "lat", "lon", "elevation", "heading", "batt_temp",
    +                "cabin_temp", "ext_temp", "hvac_setpoint", "voltage", "current",
    +                "tire_pressure_fl", "tire_pressure_fr", "tire_pressure_rl", "tire_pressure_rr"};
    +        for (String field : serverFields) {
    +            assertTrue(field, TelemetryPayload.SERVER_LIMITS.containsKey(field));
    +        }
    +    }
    +
    +    @Test
    +    public void aClockBefore2001IsNotATime() {
    +        assertFalse(TelemetryPayload.isPlausibleUtc(0L));
    +        assertFalse(TelemetryPayload.isPlausibleUtc(999_999_999L));
    +        assertTrue(TelemetryPayload.isPlausibleUtc(1_790_000_000L));
    +    }
     }
    diff --git a/app/src/test/java/com/evsuite/abrp/TelemetryUploaderTest.java b/app/src/test/java/com/evsuite/abrp/TelemetryUploaderTest.java
    new file mode 100644
    index 0000000..517fe2f
    --- /dev/null
    +++ b/app/src/test/java/com/evsuite/abrp/TelemetryUploaderTest.java
    @@ -0,0 +1,19 @@
    +package com.evsuite.abrp;
    +
    +import static org.junit.Assert.assertEquals;
    +
    +import org.junit.Test;
    +
    +/** Reading the server's answer to a telemetry batch — pure JVM. */
    +public class TelemetryUploaderTest {
    +
    +    @Test
    +    public void theLogSaysWhatTheServerDropped() {
    +        assertEquals(", 2 value(s) dropped by the server", TelemetryUploader.droppedNote(
    +                new ServerHttp.Response(200, "{\"stored\":3,\"droppedCount\":2,\"dropped\":[]}")));
    +        assertEquals("", TelemetryUploader.droppedNote(
    +                new ServerHttp.Response(200, "{\"stored\":3,\"droppedCount\":0,\"dropped\":[]}")));
    +        // An older server answers 204 without a body.
    +        assertEquals("", TelemetryUploader.droppedNote(new ServerHttp.Response(204, "")));
    +    }
    +}
    diff --git a/k8s/README.md b/k8s/README.md
    index f2d2c0b..a2f694e 100644
    --- a/k8s/README.md
    +++ b/k8s/README.md
    @@ -22,8 +22,8 @@ is applied automatically. Apply them yourself.
        covered by Traefik's default certificate.
     2. **Create the Secrets.** `cp k8s/secrets/secrets.example.yaml k8s/secrets/secrets.yaml` (gitignored), replace
        every `CHANGE-ME` there (`openssl rand -hex 32`): `influxdb-auth`, `mg4-server` (ingest token,
    -   approval password for pairing, your ABRP API key and user token) and `grafana-admin`. Keep
    -   the template itself on placeholders only.
    +   approval password for pairing, your ABRP API key and user token), `grafana-admin` and
    +   `grafana-influx` (see below). Keep the template itself on placeholders only.
     3. **Registry access.** The `mg4-server` package on git.stinnesbeck.com is public, so no pull
        secret is needed. If you make it private, add an `imagePullSecrets` entry to `server.yaml`.
     
    @@ -38,12 +38,34 @@ kubectl apply -k k8s/
     Use **`-k`**, not `-f k8s/`: plain `-f` applies the files without kustomize, so the Grafana
     ConfigMaps (datasource and dashboards) are never created and Grafana does not start.
     
    +## Grafana's InfluxDB token
    +
    +Grafana reads InfluxDB with a token that can only read the `mg4` bucket, from the
    +`grafana-influx` Secret. InfluxDB has to create that token, so it is a step of its own:
    +
    +1. With InfluxDB running, create the token. The `influx` CLI in the pod is already signed in
    +   as admin:
    +
    +   ```sh
    +   kubectl -n mg4 exec influxdb-0 -- sh -c \
    +     'influx auth create --org mg4 --description grafana-read-only --json \
    +        --read-bucket "$(influx bucket find --name mg4 --hide-headers | cut -f1)"'
    +   ```
    +
    +2. Copy the `"token"` value from the output into `grafana-influx` → `token` in
    +   `k8s/secrets/secrets.yaml`, apply that file, and restart Grafana:
    +   `kubectl -n mg4 rollout restart deployment/grafana`.
    +
    +Until the Secret exists, Grafana does not start (the container waits for the missing Secret).
    +On a fresh install, apply everything once without Grafana working, create the token, then
    +apply the Secret.
    +
     ## Notes
     
     - InfluxDB's setup mode runs only on an empty volume. Changing `admin-token` in the Secret later
       does not change the token inside InfluxDB.
    -- The server and Grafana use the InfluxDB admin token. A token scoped to the `mg4` bucket (read
    -  for Grafana, read/write for the server) can replace it later.
    +- The server uses the InfluxDB admin token; a token scoped to read/write on the `mg4` bucket can
    +  replace it later. Grafana has its own read-only token (below).
     - The dashboards are read-only in Grafana's UI. Change the JSON in `grafana/dashboards/`; the
       ConfigMap name changes with its content, which rolls Grafana.
     - The server image is pinned by digest in `server.yaml`. To upgrade, push a new tag and put its
    diff --git a/k8s/grafana.yaml b/k8s/grafana.yaml
    index 135aa75..0cc1911 100644
    --- a/k8s/grafana.yaml
    +++ b/k8s/grafana.yaml
    @@ -71,11 +71,13 @@ spec:
                 # Read by datasources.yaml.
                 - name: INFLUX_URL
                   value: http://influxdb.mg4.svc:8086
    +            # Read-only on the mg4 bucket; the dashboards never write (README, "Grafana's
    +            # InfluxDB token").
                 - name: INFLUX_TOKEN
                   valueFrom:
                     secretKeyRef:
    -                  name: influxdb-auth
    -                  key: admin-token
    +                  name: grafana-influx
    +                  key: token
               volumeMounts:
                 - name: data
                   mountPath: /var/lib/grafana
    diff --git a/k8s/secrets/secrets.example.yaml b/k8s/secrets/secrets.example.yaml
    index f97d66b..3064ca0 100644
    --- a/k8s/secrets/secrets.example.yaml
    +++ b/k8s/secrets/secrets.example.yaml
    @@ -41,3 +41,14 @@ stringData:
       # Grafana's admin login (user "admin"). Only read on first start: change it in Grafana
       # afterwards, or reset it with grafana cli.
       admin-password: CHANGE-ME
    +---
    +apiVersion: v1
    +kind: Secret
    +metadata:
    +  name: grafana-influx
    +  namespace: mg4
    +type: Opaque
    +stringData:
    +  # A read-only InfluxDB token for the mg4 bucket, not the admin token. InfluxDB makes it;
    +  # the command is in k8s/README.md ("Grafana's InfluxDB token").
    +  token: CHANGE-ME-read-only-token-from-influx-auth-create
    diff --git a/k8s/server.yaml b/k8s/server.yaml
    index d698e1e..5cb7dc3 100644
    --- a/k8s/server.yaml
    +++ b/k8s/server.yaml
    @@ -29,8 +29,8 @@ spec:
               type: RuntimeDefault
           containers:
             - name: server
    -          # 2.1.0, pinned by digest so a re-pushed tag cannot change what runs.
    -          image: git.stinnesbeck.com/nils/mg4-server@sha256:e5f6e2d2ef9dc2650055a0823a3f86d8903e46918a76e990540482edf819766d
    +          # 2.2.0, pinned by digest so a re-pushed tag cannot change what runs.
    +          image: git.stinnesbeck.com/nils/mg4-server@sha256:09eb151ff65300b3990a74063cc44a14d24bef810461e4e0e63451cfbcfd8258
               ports:
                 - name: http
                   containerPort: 8080
    diff --git a/mise.toml b/mise.toml
    index 35eb1ab..dcd9206 100644
    --- a/mise.toml
    +++ b/mise.toml
    @@ -121,7 +121,7 @@ run = './gradlew -Dorg.gradle.java.home=$JAVA_HOME installStableDebug'
     
     [tasks.logs]
     description = "Suit les logs de l'app sur le véhicule"
    -run = "adb logcat -s AbrpUploadService:V CarPropertyAdapter:V VhalProbe:V SecurePrefs:V OtaUpdater:V UpdateHook:V"
    +run = "adb logcat -s AbrpUploadService:V CarPropertyAdapter:V VhalProbe:V SecurePrefs:V OtaUpdater:V UpdateHook:V NilsMG4.Telemetry:V NilsMG4.Mongo:V NilsMG4.SteeringKey:V EVABRP.Boot:V EVABRP.Wifi:V"
     
     [tasks.release]
     description = "APK release des deux canaux (stable sans OTA, unstable avec OTA)"
    diff --git a/server/README.md b/server/README.md
    index 53a2fd9..0f565f3 100644
    --- a/server/README.md
    +++ b/server/README.md
    @@ -31,18 +31,22 @@ The ABRP credentials never reach the car.
     ## Endpoints
     
     The ingest endpoints accept `Authorization: Bearer ` with either the shared `INGEST_TOKEN`
    -or a paired car's own token.
    +or a paired car's own token. A paired token only works for the installation id it was paired
    +with (403 otherwise); the shared token works for any.
     
     - `POST /v1/telemetry` — `{ installationId, samples: [...] }`, up to 500 samples in ABRP's `tlm`
       field names and units (`utc` in epoch seconds; flags as 0/1 or booleans). Unknown fields are
    -  dropped, known ones bounded. Stored, then the newest sample is forwarded to ABRP if it is under
    -  5 minutes old.
    +  dropped, known ones bounded: a value out of range is dropped on its own, and a sample without
    +  a plausible time (before 2001, or more than an hour ahead) is dropped whole. The rest is stored
    +  and the answer is `200 {stored, droppedCount, dropped}`, even when nothing was usable, since
    +  the car would only send it again. The newest sample is forwarded to ABRP if it is under
    +  5 minutes old and not ahead of the server's clock.
     - `PUT /v1/consumption/daily` — one delta chunk of a day's consumption.
     - `PUT /v1/charging-sessions/{sessionId}` — a finished DC session with its curve.
     - `GET /v1/consumption/history/{installationId}` — the car reads its own totals back.
     - `GET /v1/health` — `{"status":"ok"}`.
     
    -A bad request is a 400 naming the field; a database failure is a 500 without detail.
    +A malformed request is a 400 naming the field; a database failure is a 500 without detail.
     
     ## Pairing a car by QR code
     
    @@ -52,13 +56,15 @@ on only when `APPROVAL_PASSWORD` is set.
     1. The car calls `POST /v1/pair/start` `{installationId}` and shows the returned
        `verificationUriComplete` as a QR code, plus the short `userCode` (e.g. `K7F-3QX`).
     2. The phone opens `/pair?code=…`, the owner checks the code and approves with the approval
    -   password. Five wrong passwords lock approvals for a minute.
    +   password. Five wrong passwords from one address lock that address out for a minute; other
    +   addresses are unaffected (the address is the last `X-Forwarded-For` entry, which Traefik sets).
    +   An address may hold three open requests; a car starting again replaces its own.
     3. The car polls `POST /v1/pair/poll` `{deviceCode}`: 202 pending, 200 with its own token
        (handed out once), 403 denied, 410 expired (after 10 minutes) or unknown.
     
     Each car gets its own 43-character token; only its SHA-256 is stored (measurement `device` in
    -the same bucket), and the device is persisted before the car can collect the token, so a
    -restart never loses a paired car. Manage cars with the approval password:
    +the same bucket), and the token is only handed out, and only accepted, once the device is
    +persisted, so a restart never loses a paired car. Manage cars with the approval password:
     
     ```sh
     curl -H "Authorization: Bearer $APPROVAL_PASSWORD" https://car.k8s.stinnesbeck.com/v1/devices
    diff --git a/server/abrp.go b/server/abrp.go
    index 9869cc6..50df2d6 100644
    --- a/server/abrp.go
    +++ b/server/abrp.go
    @@ -37,6 +37,8 @@ const (
     	abrpEndpoint = "https://api.iternio.com/1/tlm/send"
     	// Older than this, a sample is history rather than live state, and ABRP gets nothing.
     	abrpMaxAge = 5 * time.Minute
    +	// Newer than now by more than this, a sample's clock is wrong, and ABRP gets nothing.
    +	abrpMaxAhead = 30 * time.Second
     )
     
     func (f *Forwarder) configured() bool {
    @@ -46,16 +48,17 @@ func (f *Forwarder) configured() bool {
     // pick chooses the sample to forward, or false when there is nothing new or it is too
     // soon. It records the choice, so two batches arriving together cannot both forward.
     func (f *Forwarder) pick(samples []TelemetrySample, now time.Time) (TelemetrySample, bool) {
    -	if len(samples) == 0 {
    -		return TelemetrySample{}, false
    -	}
    -	newest := samples[0]
    -	for _, s := range samples[1:] {
    -		if s.UTC > newest.UTC {
    -			newest = s
    +	// A sample stamped ahead of now has a wrong clock. Taking it would also set lastUTC into
    +	// the future, and every real sample after it would look older and never be sent.
    +	latest := now.Add(abrpMaxAhead).Unix()
    +	var newest TelemetrySample
    +	found := false
    +	for _, s := range samples {
    +		if s.UTC <= latest && (!found || s.UTC > newest.UTC) {
    +			newest, found = s, true
     		}
     	}
    -	if now.Sub(time.Unix(newest.UTC, 0)) > abrpMaxAge {
    +	if !found || now.Sub(time.Unix(newest.UTC, 0)) > abrpMaxAge {
     		return TelemetrySample{}, false
     	}
     	f.mu.Lock()
    diff --git a/server/api.go b/server/api.go
    index ce1390e..7b7bb83 100644
    --- a/server/api.go
    +++ b/server/api.go
    @@ -8,6 +8,7 @@ import (
     	"log/slog"
     	"net/http"
     	"strings"
    +	"time"
     )
     
     // API serves the car. Every endpoint but /v1/health needs the ingest token.
    @@ -45,30 +46,49 @@ func (a *API) Routes() http.Handler {
     	return mux
     }
     
    -// hasIngestToken accepts the shared token (compared in constant time; main refuses to start
    +// caller is who sent an ingest request. A paired car's token is bound to the installation
    +// id it was paired with; the shared INGEST_TOKEN may write and read for any.
    +type caller struct {
    +	installationID string // empty for the shared token
    +}
    +
    +// mayUse reports whether the caller may write or read data stored under id.
    +func (c caller) mayUse(id string) bool {
    +	return c.installationID == "" || strings.EqualFold(c.installationID, id)
    +}
    +
    +// errOtherInstallation is a 403: a paired car's token used for another car's data.
    +var errOtherInstallation = errors.New("this token belongs to another installation; pair again after changing the installation id")
    +
    +// authenticate accepts the shared token (compared in constant time; main refuses to start
     // with one under 24 characters, so an empty or short one never matches) or a paired car's
     // own, unrevoked token.
    -func (a *API) hasIngestToken(r *http.Request) bool {
    +func (a *API) authenticate(r *http.Request) (caller, bool) {
     	header := r.Header.Get("Authorization")
     	supplied, ok := strings.CutPrefix(header, "Bearer ")
     	if !ok || supplied == "" {
    -		return false
    +		return caller{}, false
     	}
     	if len(a.IngestToken) >= minIngestTokenLength &&
     		subtle.ConstantTimeCompare([]byte(supplied), []byte(a.IngestToken)) == 1 {
    -		return true
    +		return caller{}, true
     	}
    -	return a.Pairing.Authorized(supplied)
    +	d, ok := a.Pairing.Authorized(supplied)
    +	if !ok {
    +		return caller{}, false
    +	}
    +	return caller{installationID: d.InstallationID}, true
     }
     
    -type handler func(w http.ResponseWriter, r *http.Request, body []byte) error
    +type handler func(w http.ResponseWriter, r *http.Request, c caller, body []byte) error
     
     // ingest wraps a handler with the token check, the body limit and error mapping: a
    -// validation failure is a 400 naming the field; anything else is a 500 whose detail
    -// stays in the server log.
    +// validation failure is a 400 naming the field, another car's data a 403; anything else
    +// is a 500 whose detail stays in the server log.
     func (a *API) ingest(h handler) http.HandlerFunc {
     	return func(w http.ResponseWriter, r *http.Request) {
    -		if !a.hasIngestToken(r) {
    +		c, ok := a.authenticate(r)
    +		if !ok {
     			writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
     			return
     		}
    @@ -81,7 +101,11 @@ func (a *API) ingest(h handler) http.HandlerFunc {
     			writeJSON(w, http.StatusBadRequest, map[string]string{"error": "body is too large"})
     			return
     		}
    -		if err := h(w, r, body); err != nil {
    +		if err := h(w, r, c, body); err != nil {
    +			if errors.Is(err, errOtherInstallation) {
    +				writeJSON(w, http.StatusForbidden, map[string]string{"error": err.Error()})
    +				return
    +			}
     			var verr *ValidationError
     			if errors.As(err, &verr) {
     				writeJSON(w, http.StatusBadRequest, map[string]string{"error": verr.Error()})
    @@ -93,29 +117,53 @@ func (a *API) ingest(h handler) http.HandlerFunc {
     	}
     }
     
    +// maxDroppedListed caps the dropped values named in a telemetry response and log line.
    +const maxDroppedListed = 20
    +
     // telemetry stores the batch first; only once the write succeeded is the newest sample
     // handed to ABRP, so the car's retry of a failed request cannot leave ABRP with data the
    -// database never got.
    -func (a *API) telemetry(w http.ResponseWriter, r *http.Request, body []byte) error {
    -	batch, err := validateTelemetryBatch(body)
    +// database never got. The answer says how many samples were stored and what was dropped;
    +// a batch with nothing usable is still a 200, since sending it again would not help.
    +func (a *API) telemetry(w http.ResponseWriter, r *http.Request, c caller, body []byte) error {
    +	batch, err := validateTelemetryBatch(body, time.Now())
     	if err != nil {
     		return err
     	}
    -	if err := a.Store.WriteTelemetry(r.Context(), batch.InstallationID, batch.Samples); err != nil {
    -		return err
    +	if !c.mayUse(batch.InstallationID) {
    +		return errOtherInstallation
     	}
    -	a.Forwarder.Forward(batch.Samples)
    -	w.WriteHeader(http.StatusNoContent)
    +	if len(batch.Samples) > 0 {
    +		if err := a.Store.WriteTelemetry(r.Context(), batch.InstallationID, batch.Samples); err != nil {
    +			return err
    +		}
    +		a.Forwarder.Forward(batch.Samples)
    +	}
    +	dropped := batch.Dropped
    +	if len(dropped) > maxDroppedListed {
    +		dropped = dropped[:maxDroppedListed]
    +	}
    +	if len(batch.Dropped) > 0 {
    +		slog.Warn("telemetry values dropped", "installation", batch.InstallationID,
    +			"count", len(batch.Dropped), "first", dropped)
    +	}
    +	writeJSON(w, http.StatusOK, map[string]any{
    +		"stored":       len(batch.Samples),
    +		"droppedCount": len(batch.Dropped),
    +		"dropped":      append([]string{}, dropped...),
    +	})
     	return nil
     }
     
     // consumption stores one delta chunk at its recordedAt. Reads add a day's chunks up, so a
     // chunk the car re-sends from its offline cache replaces itself instead of counting twice.
    -func (a *API) consumption(w http.ResponseWriter, r *http.Request, body []byte) error {
    +func (a *API) consumption(w http.ResponseWriter, r *http.Request, c caller, body []byte) error {
     	chunk, err := validateConsumption(body)
     	if err != nil {
     		return err
     	}
    +	if !c.mayUse(chunk.InstallationID) {
    +		return errOtherInstallation
    +	}
     	if err := a.Store.WriteConsumptionChunk(r.Context(), chunk); err != nil {
     		return err
     	}
    @@ -123,11 +171,14 @@ func (a *API) consumption(w http.ResponseWriter, r *http.Request, body []byte) e
     	return nil
     }
     
    -func (a *API) chargingSession(w http.ResponseWriter, r *http.Request, body []byte) error {
    +func (a *API) chargingSession(w http.ResponseWriter, r *http.Request, c caller, body []byte) error {
     	session, err := validateChargingSession(body, r.PathValue("sessionId"))
     	if err != nil {
     		return err
     	}
    +	if !c.mayUse(session.InstallationID) {
    +		return errOtherInstallation
    +	}
     	if err := a.Store.WriteChargingSession(r.Context(), session); err != nil {
     		return err
     	}
    @@ -137,11 +188,14 @@ func (a *API) chargingSession(w http.ResponseWriter, r *http.Request, body []byt
     
     // history is the car's read-back of its own uploaded totals, so an app reinstall does not
     // blank its Consumption tab. Up to 400 days, newest first, plus the lifetime sum of all.
    -func (a *API) history(w http.ResponseWriter, r *http.Request, _ []byte) error {
    +func (a *API) history(w http.ResponseWriter, r *http.Request, c caller, _ []byte) error {
     	id, err := validUUID(r.PathValue("installationId"), "installationId")
     	if err != nil {
     		return err
     	}
    +	if !c.mayUse(id) {
    +		return errOtherInstallation
    +	}
     	chunks, err := a.Store.ReadConsumptionChunks(r.Context(), id)
     	if err != nil {
     		return err
    diff --git a/server/api_test.go b/server/api_test.go
    index c3832ae..4a69fa5 100644
    --- a/server/api_test.go
    +++ b/server/api_test.go
    @@ -116,17 +116,32 @@ func TestEveryOtherEndpointNeedsTheIngestToken(t *testing.T) {
     	}
     }
     
    -func TestTelemetryIsStoredAndBadInputIsA400(t *testing.T) {
    +func TestTelemetryIsStoredAndBadValuesAreDropped(t *testing.T) {
     	api, store := newTestAPI()
     	h := api.Routes()
     	rec := do(t, h, "POST", "/v1/telemetry", testToken, string(telemetryBody(`{"utc":1790000000,"soc":80}`)))
    -	if rec.Code != 204 || len(store.telemetry) != 1 {
    -		t.Fatalf("%d, stored %d", rec.Code, len(store.telemetry))
    +	if rec.Code != 200 || len(store.telemetry) != 1 || !strings.Contains(rec.Body.String(), `"stored":1`) {
    +		t.Fatalf("%d %s, stored %d", rec.Code, rec.Body, len(store.telemetry))
     	}
    -	rec = do(t, h, "POST", "/v1/telemetry", testToken, string(telemetryBody(`{"utc":1790000000,"soc":120}`)))
    -	if rec.Code != 400 || !strings.Contains(rec.Body.String(), "samples[0].soc is invalid") {
    +	// One bad value costs that value, not the batch: the car drops what the server rejects.
    +	rec = do(t, h, "POST", "/v1/telemetry", testToken,
    +		string(telemetryBody(`{"utc":1790000000,"soc":120,"speed":-3},{"utc":1790000015,"soc":79}`)))
    +	if rec.Code != 200 || len(store.telemetry) != 3 || !strings.Contains(rec.Body.String(), "samples[0].soc") ||
    +		!strings.Contains(rec.Body.String(), "samples[0].speed") || !strings.Contains(rec.Body.String(), `"stored":2`) {
    +		t.Fatalf("%d %s, stored %d", rec.Code, rec.Body, len(store.telemetry))
    +	}
    +	if _, ok := store.telemetry[1].Numbers["soc"]; ok {
    +		t.Fatal("out-of-range soc stored")
    +	}
    +	// Nothing usable is still a 200: sending it again would not help.
    +	rec = do(t, h, "POST", "/v1/telemetry", testToken, string(telemetryBody(`{"utc":5}`)))
    +	if rec.Code != 200 || len(store.telemetry) != 3 || !strings.Contains(rec.Body.String(), `"stored":0`) {
     		t.Fatalf("%d %s", rec.Code, rec.Body)
     	}
    +	// A malformed request is still a 400.
    +	if rec := do(t, h, "POST", "/v1/telemetry", testToken, `{"installationId":"nope","samples":[]}`); rec.Code != 400 {
    +		t.Fatalf("malformed: %d", rec.Code)
    +	}
     	if rec := do(t, h, "GET", "/v1/telemetry", testToken, ""); rec.Code != 405 {
     		t.Fatalf("GET on a POST route: want 405, got %d", rec.Code)
     	}
    diff --git a/server/logic_test.go b/server/logic_test.go
    index bf83bf9..6f8371c 100644
    --- a/server/logic_test.go
    +++ b/server/logic_test.go
    @@ -75,6 +75,18 @@ func TestForwarderPicksOnlyTheNewestFreshSampleOncePerInterval(t *testing.T) {
     	if _, ok := fresh.pick([]TelemetrySample{{UTC: testNowS - 600}}, now); ok {
     		t.Fatal("forwarded a stale sample")
     	}
    +	// A sample from a clock a day ahead is skipped, and does not block the real ones after it.
    +	ahead := &Forwarder{MinInterval: 5 * time.Second}
    +	got, ok = ahead.pick([]TelemetrySample{{UTC: testNowS + 86400}, {UTC: testNowS - 1}}, now)
    +	if !ok || got.UTC != testNowS-1 {
    +		t.Fatalf("picked %+v %v", got, ok)
    +	}
    +	if _, ok := ahead.pick([]TelemetrySample{{UTC: testNowS + 86400}}, now.Add(time.Minute)); ok {
    +		t.Fatal("forwarded a sample from the future")
    +	}
    +	if _, ok := ahead.pick([]TelemetrySample{{UTC: testNowS + 60}}, now.Add(time.Minute)); !ok {
    +		t.Fatal("a real sample after a future one was not forwarded")
    +	}
     }
     
     func TestForwarderPutsCredentialsInTheBodyNotTheURL(t *testing.T) {
    diff --git a/server/model.go b/server/model.go
    index 955f932..b121c87 100644
    --- a/server/model.go
    +++ b/server/model.go
    @@ -105,4 +105,6 @@ type TelemetrySample struct {
     type TelemetryBatch struct {
     	InstallationID string
     	Samples        []TelemetrySample
    +	// Dropped names the values and samples validation left out, e.g. "samples[3].speed".
    +	Dropped []string
     }
    diff --git a/server/pairing.go b/server/pairing.go
    index f67438c..4b698ee 100644
    --- a/server/pairing.go
    +++ b/server/pairing.go
    @@ -21,12 +21,19 @@ import (
     // replica. Approved devices are persisted (see Device) so their tokens survive a restart.
     
     const (
    -	pairingTTL        = 10 * time.Minute
    -	pairingInterval   = 5 * time.Second
    -	maxPendingPairing = 50
    -	// After this many wrong approval passwords in a window, approvals pause for the window.
    -	maxFailedLogins    = 5
    -	failedLoginsWindow = time.Minute
    +	pairingTTL      = 10 * time.Minute
    +	pairingInterval = 5 * time.Second
    +	// Pending requests per client address, and in all. A car needs one; a client that opens
    +	// more only crowds out its own, and the total bounds memory.
    +	maxPendingPerClient = 3
    +	maxPendingPairing   = 1000
    +	// After this many wrong approval passwords from one client address in a window, that
    +	// address waits for the window. Other addresses are not affected, so a stranger guessing
    +	// cannot lock the owner out. The global cap only bounds guessing spread over many
    +	// addresses; it is far above anything a person types.
    +	maxFailedLogins       = 5
    +	maxFailedLoginsGlobal = 100
    +	failedLoginsWindow    = time.Minute
     	// Unambiguous characters only: no 0/O, 1/I/L, so a code reads right off a car screen.
     	userCodeAlphabet = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
     )
    @@ -52,6 +59,9 @@ type pairingState int
     
     const (
     	pairingPending pairingState = iota
    +	// pairingSaving: approved, and the device is being persisted. The car still sees
    +	// pending, so it cannot collect a token whose device might not be saved.
    +	pairingSaving
     	pairingApproved
     	pairingDenied
     )
    @@ -60,6 +70,7 @@ type pairingRequest struct {
     	deviceCode     string
     	userCode       string
     	installationID string
    +	client         string // address that opened the request
     	expires        time.Time
     	state          pairingState
     	token          string // set on approval, handed out once
    @@ -75,7 +86,9 @@ type Pairing struct {
     	byDevice map[string]*pairingRequest
     	byUser   map[string]*pairingRequest
     	devices  map[string]Device // by token hash
    -	failures []time.Time
    +	// Wrong passwords: per client address, and all of them together.
    +	failures       map[string][]time.Time
    +	globalFailures []time.Time
     }
     
     func NewPairing(password string, devices []Device) *Pairing {
    @@ -85,6 +98,7 @@ func NewPairing(password string, devices []Device) *Pairing {
     		byDevice:         map[string]*pairingRequest{},
     		byUser:           map[string]*pairingRequest{},
     		devices:          map[string]Device{},
    +		failures:         map[string][]time.Time{},
     	}
     	for _, d := range devices {
     		p.devices[d.TokenHash] = d
    @@ -137,25 +151,43 @@ func hashToken(token string) string {
     
     func (p *Pairing) expireLocked(now time.Time) {
     	for code, r := range p.byDevice {
    -		if now.After(r.expires) {
    +		// A request being saved is finished by Commit or Forget, not expired under them.
    +		if now.After(r.expires) && r.state != pairingSaving {
     			delete(p.byDevice, code)
     			delete(p.byUser, r.userCode)
     		}
     	}
     }
     
    -// Start opens a pairing request for a car.
    -func (p *Pairing) Start(installationID string) (*pairingRequest, error) {
    +func (p *Pairing) dropLocked(r *pairingRequest) {
    +	delete(p.byDevice, r.deviceCode)
    +	delete(p.byUser, r.userCode)
    +}
    +
    +// Start opens a pairing request for a car. A new request replaces the same installation's
    +// undecided one; a client already holding maxPendingPerClient requests gets errTooMany.
    +func (p *Pairing) Start(installationID, client string) (*pairingRequest, error) {
     	p.mu.Lock()
     	defer p.mu.Unlock()
     	now := p.Now()
     	p.expireLocked(now)
    -	if len(p.byDevice) >= maxPendingPairing {
    +	perClient := 0
    +	for _, r := range p.byDevice {
    +		if r.state == pairingPending && strings.EqualFold(r.installationID, installationID) {
    +			p.dropLocked(r)
    +			continue
    +		}
    +		if r.client == client {
    +			perClient++
    +		}
    +	}
    +	if perClient >= maxPendingPerClient || len(p.byDevice) >= maxPendingPairing {
     		return nil, errTooMany
     	}
     	r := &pairingRequest{
     		deviceCode:     randomString(32),
     		installationID: installationID,
    +		client:         client,
     		expires:        now.Add(pairingTTL),
     	}
     	for {
    @@ -182,32 +214,46 @@ func (p *Pairing) Lookup(userCode string) (pairingRequest, error) {
     	return *r, nil
     }
     
    -// checkPassword counts failures and refuses everything while too many are recent.
    -func (p *Pairing) checkPasswordLocked(password string, now time.Time) error {
    -	recent := p.failures[:0]
    -	for _, t := range p.failures {
    +func recentOnly(times []time.Time, now time.Time) []time.Time {
    +	recent := times[:0]
    +	for _, t := range times {
     		if now.Sub(t) < failedLoginsWindow {
     			recent = append(recent, t)
     		}
     	}
    -	p.failures = recent
    -	if len(p.failures) >= maxFailedLogins {
    +	return recent
    +}
    +
    +// checkPasswordLocked counts failures per client address and refuses a client while it
    +// has too many recent ones (or while too many arrive from everywhere at once).
    +func (p *Pairing) checkPasswordLocked(password, client string, now time.Time) error {
    +	for c, times := range p.failures {
    +		if recent := recentOnly(times, now); len(recent) == 0 {
    +			delete(p.failures, c)
    +		} else {
    +			p.failures[c] = recent
    +		}
    +	}
    +	p.globalFailures = recentOnly(p.globalFailures, now)
    +	if len(p.failures[client]) >= maxFailedLogins || len(p.globalFailures) >= maxFailedLoginsGlobal {
     		return errTooManyFailed
     	}
     	if subtle.ConstantTimeCompare([]byte(password), []byte(p.ApprovalPassword)) != 1 {
    -		p.failures = append(p.failures, now)
    +		p.failures[client] = append(p.failures[client], now)
    +		p.globalFailures = append(p.globalFailures, now)
     		return errWrongPassword
     	}
     	return nil
     }
     
     // Decide approves or denies a pending request. On approval it returns the new device, which
    -// the caller must persist before the car can collect the token.
    -func (p *Pairing) Decide(userCode, password string, approve bool) (Device, error) {
    +// the caller must persist and then Commit (or Forget, if saving failed): until Commit the
    +// car keeps seeing pending and the token is not accepted anywhere.
    +func (p *Pairing) Decide(userCode, password, client string, approve bool) (Device, error) {
     	p.mu.Lock()
     	defer p.mu.Unlock()
     	now := p.Now()
    -	if err := p.checkPasswordLocked(password, now); err != nil {
    +	if err := p.checkPasswordLocked(password, client, now); err != nil {
     		return Device{}, err
     	}
     	p.expireLocked(now)
    @@ -229,20 +275,29 @@ func (p *Pairing) Decide(userCode, password string, approve bool) (Device, error
     		TokenHash:      hashToken(token),
     		CreatedAt:      now,
     	}
    -	r.state = pairingApproved
    +	r.state = pairingSaving
     	r.token = token
     	r.deviceID = d.ID
    -	p.devices[d.TokenHash] = d
     	return d, nil
     }
     
    +// Commit finishes an approval once its device is persisted: the token now works, and the
    +// car collects it at its next poll.
    +func (p *Pairing) Commit(userCode string, d Device) {
    +	p.mu.Lock()
    +	defer p.mu.Unlock()
    +	p.devices[d.TokenHash] = d
    +	if r, ok := p.byUser[normalizeUserCode(userCode)]; ok && r.deviceID == d.ID {
    +		r.state = pairingApproved
    +	}
    +}
    +
     // Forget undoes an approval whose device could not be persisted, so no token is handed out
    -// that would stop working at the next restart.
    +// that would stop working at the next restart. The request is pending again.
     func (p *Pairing) Forget(userCode string, d Device) {
     	p.mu.Lock()
     	defer p.mu.Unlock()
    -	delete(p.devices, d.TokenHash)
    -	if r, ok := p.byUser[normalizeUserCode(userCode)]; ok {
    +	if r, ok := p.byUser[normalizeUserCode(userCode)]; ok && r.deviceID == d.ID {
     		r.state = pairingPending
     		r.token = ""
     		r.deviceID = ""
    @@ -259,23 +314,27 @@ func (p *Pairing) Poll(deviceCode string) (state pairingState, token, deviceID s
     		return 0, "", "", errUnknownCode
     	}
     	switch r.state {
    +	case pairingSaving:
    +		return pairingPending, "", "", nil
     	case pairingApproved, pairingDenied:
     		// Decided: hand the outcome out once, then forget the request.
    -		delete(p.byDevice, r.deviceCode)
    -		delete(p.byUser, r.userCode)
    +		p.dropLocked(r)
     	}
     	return r.state, r.token, r.deviceID, nil
     }
     
    -// Authorized reports whether a token belongs to a paired, unrevoked device.
    -func (p *Pairing) Authorized(token string) bool {
    +// Authorized returns the paired, unrevoked device a token belongs to.
    +func (p *Pairing) Authorized(token string) (Device, bool) {
     	if p == nil || token == "" {
    -		return false
    +		return Device{}, false
     	}
     	p.mu.Lock()
     	defer p.mu.Unlock()
     	d, ok := p.devices[hashToken(token)]
    -	return ok && !d.Revoked
    +	if !ok || d.Revoked {
    +		return Device{}, false
    +	}
    +	return d, true
     }
     
     func (p *Pairing) Devices() []Device {
    @@ -304,8 +363,8 @@ func (p *Pairing) Revoke(id string) (Device, bool) {
     
     // CheckAdmin verifies the approval password for the device-management API, with the same
     // failure counting as the approval page.
    -func (p *Pairing) CheckAdmin(password string) error {
    +func (p *Pairing) CheckAdmin(password, client string) error {
     	p.mu.Lock()
     	defer p.mu.Unlock()
    -	return p.checkPasswordLocked(password, p.Now())
    +	return p.checkPasswordLocked(password, client, p.Now())
     }
    diff --git a/server/pairing_http.go b/server/pairing_http.go
    index f8341f1..2456969 100644
    --- a/server/pairing_http.go
    +++ b/server/pairing_http.go
    @@ -5,6 +5,7 @@ import (
     	"html/template"
     	"io"
     	"log/slog"
    +	"net"
     	"net/http"
     	"net/url"
     	"strings"
    @@ -79,7 +80,7 @@ func (a *API) pairStart(w http.ResponseWriter, r *http.Request) {
     		writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
     		return
     	}
    -	req, err := a.Pairing.Start(id)
    +	req, err := a.Pairing.Start(id, clientAddress(r))
     	if err != nil {
     		writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": err.Error()})
     		return
    @@ -118,6 +119,25 @@ func (a *API) pairPoll(w http.ResponseWriter, r *http.Request) {
     	}
     }
     
    +// clientAddress is who is asking, for the per-client limits: the last X-Forwarded-For entry,
    +// which is the one Traefik adds (anything left of it comes from the client and can be
    +// made up), or the connection's own address without a proxy.
    +func clientAddress(r *http.Request) string {
    +	if forwarded := r.Header.Values("X-Forwarded-For"); len(forwarded) > 0 {
    +		last := forwarded[len(forwarded)-1]
    +		if i := strings.LastIndex(last, ","); i >= 0 {
    +			last = last[i+1:]
    +		}
    +		if addr := strings.TrimSpace(last); addr != "" {
    +			return addr
    +		}
    +	}
    +	if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
    +		return host
    +	}
    +	return r.RemoteAddr
    +}
    +
     // ---------- Approval page ----------
     
     type pageData struct {
    @@ -204,7 +224,7 @@ func (a *API) pairDecide(w http.ResponseWriter, r *http.Request) {
     	}
     	code := normalizeUserCode(r.PostForm.Get("code"))
     	approve := r.PostForm.Get("action") != "deny"
    -	device, err := a.Pairing.Decide(code, r.PostForm.Get("password"), approve)
    +	device, err := a.Pairing.Decide(code, r.PostForm.Get("password"), clientAddress(r), approve)
     	switch {
     	case errors.Is(err, errWrongPassword):
     		renderPage(w, http.StatusUnauthorized, pageData{Code: code, Error: "Wrong password."})
    @@ -231,6 +251,7 @@ func (a *API) pairDecide(w http.ResponseWriter, r *http.Request) {
     		renderPage(w, http.StatusInternalServerError, pageData{Code: code, Error: "The car could not be saved. Try again."})
     		return
     	}
    +	a.Pairing.Commit(code, device)
     	slog.Info("car paired", "device", device.ID, "installation", device.InstallationID)
     	renderPage(w, http.StatusOK, pageData{Done: true, Message: "Approved. The car finishes pairing on its own within a few seconds — you can close this page."})
     }
    @@ -246,7 +267,7 @@ func (a *API) adminAuthorized(w http.ResponseWriter, r *http.Request) bool {
     		writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
     		return false
     	}
    -	switch err := a.Pairing.CheckAdmin(supplied); {
    +	switch err := a.Pairing.CheckAdmin(supplied, clientAddress(r)); {
     	case errors.Is(err, errTooManyFailed):
     		writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": err.Error()})
     		return false
    diff --git a/server/pairing_test.go b/server/pairing_test.go
    index fce9b53..2be5f00 100644
    --- a/server/pairing_test.go
    +++ b/server/pairing_test.go
    @@ -34,10 +34,19 @@ func poll(t *testing.T, h http.Handler, deviceCode string) (int, map[string]stri
     }
     
     func submit(t *testing.T, h http.Handler, code, password, action string) *httptest.ResponseRecorder {
    +	t.Helper()
    +	return submitFrom(t, h, "", code, password, action)
    +}
    +
    +// submitFrom submits the approval form as if from forwardedFor, the address Traefik adds.
    +func submitFrom(t *testing.T, h http.Handler, forwardedFor, code, password, action string) *httptest.ResponseRecorder {
     	t.Helper()
     	form := url.Values{"code": {code}, "password": {password}, "action": {action}}
     	req := httptest.NewRequest("POST", "/pair", strings.NewReader(form.Encode()))
     	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
    +	if forwardedFor != "" {
    +		req.Header.Set("X-Forwarded-For", forwardedFor)
    +	}
     	rec := httptest.NewRecorder()
     	h.ServeHTTP(rec, req)
     	return rec
    @@ -86,7 +95,7 @@ func TestPairingHappyPath(t *testing.T) {
     	}
     	// And it works on the ingest endpoints.
     	rec := do(t, h, "POST", "/v1/telemetry", got["token"], string(telemetryBody(`{"utc":1790000000,"soc":80}`)))
    -	if rec.Code != 204 {
    +	if rec.Code != 200 {
     		t.Fatalf("paired token on telemetry: %d %s", rec.Code, rec.Body)
     	}
     }
    @@ -107,6 +116,11 @@ func TestPairingWrongPasswordAndLockout(t *testing.T) {
     	if status, _ := poll(t, h, deviceCode); status != 202 || len(store.devices) != 0 {
     		t.Fatal("a locked-out attempt changed the request")
     	}
    +	// The lockout is per address: someone guessing elsewhere does not lock the owner out.
    +	if rec := submitFrom(t, h, "198.51.100.7", userCode, testApprovalPassword, "deny"); rec.Code != 200 {
    +		t.Fatalf("other address locked out too: %d", rec.Code)
    +	}
    +	deviceCode, userCode, _ = startPairing(t, h)
     	// After the window the right password works again.
     	api.Pairing.Now = func() time.Time { return time.Now().Add(failedLoginsWindow + time.Second) }
     	if rec := submit(t, h, userCode, testApprovalPassword, "approve"); rec.Code != 200 {
    @@ -186,7 +200,7 @@ func TestRevokedTokenIsRejected(t *testing.T) {
     		t.Fatalf("revoked token accepted: %d", rec.Code)
     	}
     	// The shared ingest token is unaffected.
    -	if rec := do(t, h, "POST", "/v1/telemetry", testToken, string(telemetryBody(`{"utc":1790000000,"soc":1}`))); rec.Code != 204 {
    +	if rec := do(t, h, "POST", "/v1/telemetry", testToken, string(telemetryBody(`{"utc":1790000000,"soc":1}`))); rec.Code != 200 {
     		t.Fatalf("shared token: %d", rec.Code)
     	}
     }
    @@ -195,7 +209,9 @@ func TestPairedDevicesSurviveARestart(t *testing.T) {
     	devices := []Device{{ID: "d1", TokenHash: hashToken("restored-token-0123456789abcdef"), CreatedAt: time.Now()},
     		{ID: "d2", TokenHash: hashToken("revoked-token-0123456789abcdef"), CreatedAt: time.Now(), Revoked: true}}
     	p := NewPairing(testApprovalPassword, devices)
    -	if !p.Authorized("restored-token-0123456789abcdef") || p.Authorized("revoked-token-0123456789abcdef") {
    +	_, restored := p.Authorized("restored-token-0123456789abcdef")
    +	_, revoked := p.Authorized("revoked-token-0123456789abcdef")
    +	if !restored || revoked {
     		t.Fatal("restored devices not honoured")
     	}
     }
    @@ -232,3 +248,84 @@ func TestApprovalPasswordMustBeLongEnough(t *testing.T) {
     }
     
     var regexpUserCode = regexp.MustCompile(`^[` + userCodeAlphabet + `]{3}-[` + userCodeAlphabet + `]{3}$`)
    +
    +func TestPairedTokenOnlyServesItsOwnInstallation(t *testing.T) {
    +	api, _ := newTestAPI()
    +	h := api.Routes()
    +	deviceCode, userCode, _ := startPairing(t, h)
    +	submit(t, h, userCode, testApprovalPassword, "approve")
    +	_, got := poll(t, h, deviceCode)
    +	token := got["token"]
    +	other := "7e2b3c4d-5e6f-4a70-9b8c-1d2e3f4a5b6c"
    +	body := `{"installationId":"` + other + `","samples":[{"utc":1790000000,"soc":5}]}`
    +	if rec := do(t, h, "POST", "/v1/telemetry", token, body); rec.Code != 403 {
    +		t.Fatalf("telemetry for another car: %d", rec.Code)
    +	}
    +	if rec := do(t, h, "GET", "/v1/consumption/history/"+other, token, ""); rec.Code != 403 {
    +		t.Fatalf("another car's history: %d", rec.Code)
    +	}
    +	if rec := do(t, h, "GET", "/v1/consumption/history/"+testInstallation, token, ""); rec.Code != 200 {
    +		t.Fatalf("own history: %d", rec.Code)
    +	}
    +	// The shared token serves any installation.
    +	if rec := do(t, h, "POST", "/v1/telemetry", testToken, body); rec.Code != 200 {
    +		t.Fatalf("shared token: %d", rec.Code)
    +	}
    +}
    +
    +func TestATokenIsCollectableOnlyOnceSaved(t *testing.T) {
    +	p := NewPairing(testApprovalPassword, nil)
    +	r, err := p.Start(testInstallation, "192.0.2.1")
    +	if err != nil {
    +		t.Fatal(err)
    +	}
    +	d, err := p.Decide(r.userCode, testApprovalPassword, "192.0.2.1", true)
    +	if err != nil {
    +		t.Fatal(err)
    +	}
    +	// Saving is in flight: the car still waits, and the token is not accepted yet.
    +	if state, token, _, _ := p.Poll(r.deviceCode); state != pairingPending || token != "" {
    +		t.Fatalf("collectable while saving: %v %q", state, token)
    +	}
    +	p.Forget(r.userCode, d)
    +	if _, err := p.Decide(r.userCode, testApprovalPassword, "192.0.2.1", true); err != nil {
    +		t.Fatalf("not approvable again after a failed save: %v", err)
    +	}
    +}
    +
    +func TestPairingStartsAreLimitedPerAddress(t *testing.T) {
    +	p := NewPairing(testApprovalPassword, nil)
    +	ids := []string{"6f1a2b3c-4d5e-4f60-8a9b-0c1d2e3f4a51", "6f1a2b3c-4d5e-4f60-8a9b-0c1d2e3f4a52",
    +		"6f1a2b3c-4d5e-4f60-8a9b-0c1d2e3f4a53", "6f1a2b3c-4d5e-4f60-8a9b-0c1d2e3f4a54"}
    +	for _, id := range ids[:maxPendingPerClient] {
    +		if _, err := p.Start(id, "203.0.113.9"); err != nil {
    +			t.Fatal(err)
    +		}
    +	}
    +	if _, err := p.Start(ids[3], "203.0.113.9"); !errors.Is(err, errTooMany) {
    +		t.Fatalf("over the per-address limit: %v", err)
    +	}
    +	// The car on its own address is unaffected, and starting again replaces its request.
    +	first, err := p.Start(testInstallation, "192.0.2.1")
    +	if err != nil {
    +		t.Fatal(err)
    +	}
    +	if _, err := p.Start(testInstallation, "192.0.2.1"); err != nil {
    +		t.Fatal(err)
    +	}
    +	if _, _, _, err := p.Poll(first.deviceCode); !errors.Is(err, errUnknownCode) {
    +		t.Fatal("the replaced request is still open")
    +	}
    +}
    +
    +func TestClientAddressTakesTheProxysEntry(t *testing.T) {
    +	req := httptest.NewRequest("GET", "/", nil)
    +	req.Header.Set("X-Forwarded-For", "10.9.9.9, 198.51.100.7")
    +	if got := clientAddress(req); got != "198.51.100.7" {
    +		t.Fatalf("got %q", got)
    +	}
    +	req.Header.Del("X-Forwarded-For")
    +	if got := clientAddress(req); got != "192.0.2.1" {
    +		t.Fatalf("without a proxy: %q", got)
    +	}
    +}
    diff --git a/server/validate.go b/server/validate.go
    index 146a64e..c82cbe3 100644
    --- a/server/validate.go
    +++ b/server/validate.go
    @@ -6,6 +6,7 @@ import (
     	"fmt"
     	"math"
     	"regexp"
    +	"sort"
     	"time"
     )
     
    @@ -252,8 +253,13 @@ func validateChargingSession(body []byte, pathSessionID string) (ChargingSession
     
     // validateTelemetryBatch checks a batch of live samples in ABRP's tlm field names and
     // units. Unknown fields are dropped rather than rejected, so a newer app can add one
    -// without a server release; every known field is bounded; a flag may be 0/1 or a boolean.
    -func validateTelemetryBatch(body []byte) (TelemetryBatch, error) {
    +// without a server release; a flag may be 0/1 or a boolean.
    +//
    +// Only a malformed request is a 400. A bad value inside a sample costs that value, and a
    +// sample without a usable time costs that sample: the car drops a batch the server
    +// rejects, and one sensor reading out of range must not take hours of queued driving with
    +// it. What was dropped is listed in Dropped, for the response and the log.
    +func validateTelemetryBatch(body []byte, now time.Time) (TelemetryBatch, error) {
     	o, err := decodeObject(body)
     	if err != nil {
     		return TelemetryBatch{}, err
    @@ -266,34 +272,45 @@ func validateTelemetryBatch(body []byte) (TelemetryBatch, error) {
     	if !ok || len(raw) == 0 || len(raw) > maxSamples {
     		return TelemetryBatch{}, invalid("samples is invalid")
     	}
    -	samples := make([]TelemetrySample, 0, len(raw))
    +	batch := TelemetryBatch{InstallationID: id, Samples: make([]TelemetrySample, 0, len(raw))}
    +	latest := now.Add(maxClockAhead).Unix()
     	for i, entry := range raw {
    -		sample, err := validateSample(entry, fmt.Sprintf("samples[%d]", i))
    -		if err != nil {
    -			return TelemetryBatch{}, err
    +		name := fmt.Sprintf("samples[%d]", i)
    +		sample, dropped, ok := validateSample(entry, name, latest)
    +		batch.Dropped = append(batch.Dropped, dropped...)
    +		if ok {
    +			batch.Samples = append(batch.Samples, sample)
     		}
    -		samples = append(samples, sample)
     	}
    -	return TelemetryBatch{InstallationID: id, Samples: samples}, nil
    +	return batch, nil
     }
     
    -func validateSample(entry any, name string) (TelemetrySample, error) {
    +// maxClockAhead is how far in the future a sample may be stamped. The head unit sets its
    +// clock from GPS; a sample from further ahead has a wrong time, and stored there it would
    +// pose as the car's latest state until that time came.
    +const maxClockAhead = time.Hour
    +
    +// validateSample keeps every valid field of one sample. It returns false when the sample
    +// as a whole is unusable: not an object, or no plausible time. dropped names what was left
    +// out.
    +func validateSample(entry any, name string, latestUTC int64) (sample TelemetrySample, dropped []string, ok bool) {
     	o, err := asObject(entry, name)
     	if err != nil {
    -		return TelemetrySample{}, err
    +		return TelemetrySample{}, []string{name}, false
     	}
     	utc, err := integer(o["utc"], name+".utc")
    -	if err != nil || utc < utcMin || utc > utcMax {
    -		return TelemetrySample{}, invalid("%s.utc is invalid", name)
    +	if err != nil || utc < utcMin || utc > utcMax || utc > latestUTC {
    +		return TelemetrySample{}, []string{name + ".utc"}, false
     	}
    -	sample := TelemetrySample{UTC: utc, Numbers: map[string]float64{}, Flags: map[string]bool{}}
    +	sample = TelemetrySample{UTC: utc, Numbers: map[string]float64{}, Flags: map[string]bool{}}
     	for field, limits := range telemetryNumbers {
     		if !o.has(field) {
     			continue
     		}
     		f, err := bounded(o[field], limits[0], limits[1], name+"."+field)
     		if err != nil {
    -			return TelemetrySample{}, err
    +			dropped = append(dropped, name+"."+field)
    +			continue
     		}
     		sample.Numbers[field] = f
     	}
    @@ -311,17 +328,20 @@ func validateSample(entry any, name string) (TelemetrySample, error) {
     			case "0":
     				sample.Flags[field] = false
     			default:
    -				return TelemetrySample{}, invalid("%s.%s is invalid", name, field)
    +				dropped = append(dropped, name+"."+field)
     			}
     		default:
    -			return TelemetrySample{}, invalid("%s.%s is invalid", name, field)
    +			dropped = append(dropped, name+"."+field)
     		}
     	}
     	// A position is only a position as a pair.
     	_, hasLat := sample.Numbers["lat"]
     	_, hasLon := sample.Numbers["lon"]
     	if hasLat != hasLon {
    -		return TelemetrySample{}, invalid("%s.lat and lon must be sent together", name)
    +		delete(sample.Numbers, "lat")
    +		delete(sample.Numbers, "lon")
    +		dropped = append(dropped, name+".lat/lon")
     	}
    -	return sample, nil
    +	sort.Strings(dropped)
    +	return sample, dropped, true
     }
    diff --git a/server/validate_test.go b/server/validate_test.go
    index baead87..8d05212 100644
    --- a/server/validate_test.go
    +++ b/server/validate_test.go
    @@ -4,6 +4,7 @@ import (
     	"errors"
     	"strings"
     	"testing"
    +	"time"
     )
     
     const (
    @@ -12,6 +13,9 @@ const (
     	testNowS         = 1_790_000_000
     )
     
    +// testNow is the sample time the telemetry tests send, as "now" for the clock-ahead check.
    +var testNow = time.Unix(testNowS, 0)
    +
     func isValidationError(err error) bool {
     	var v *ValidationError
     	return errors.As(err, &v)
    @@ -125,7 +129,7 @@ func telemetryBody(samples string) []byte {
     
     func TestTelemetryKeepsOnlyWhatWasSent(t *testing.T) {
     	b, err := validateTelemetryBatch(telemetryBody(
    -		`{"utc":1790000000,"soc":81,"power":-12.5,"speed":0,"lat":52.1,"lon":7.6,"is_charging":1,"is_dcfc":false}`))
    +		`{"utc":1790000000,"soc":81,"power":-12.5,"speed":0,"lat":52.1,"lon":7.6,"is_charging":1,"is_dcfc":false}`), testNow)
     	if err != nil {
     		t.Fatal(err)
     	}
    @@ -146,7 +150,7 @@ func TestTelemetryKeepsOnlyWhatWasSent(t *testing.T) {
     }
     
     func TestTelemetryDropsUnknownFields(t *testing.T) {
    -	b, err := validateTelemetryBatch(telemetryBody(`{"utc":1790000000,"future_field":3}`))
    +	b, err := validateTelemetryBatch(telemetryBody(`{"utc":1790000000,"future_field":3}`), testNow)
     	if err != nil {
     		t.Fatal(err)
     	}
    @@ -155,31 +159,49 @@ func TestTelemetryDropsUnknownFields(t *testing.T) {
     	}
     }
     
    -func TestTelemetryRejectsBadSamples(t *testing.T) {
    -	for name, sample := range map[string]string{
    -		"milliseconds":   `{"utc":1790000000000}`,
    -		"fractional utc": `{"utc":1790000000.5}`,
    -		"soc above 100":  `{"utc":1790000000,"soc":101}`,
    -		"string speed":   `{"utc":1790000000,"speed":"fast"}`,
    -		"flag 2":         `{"utc":1790000000,"is_parked":2}`,
    -		"half position":  `{"utc":1790000000,"lat":52.1}`,
    -		"not an object":  `5`,
    +func TestTelemetryDropsBadValuesAndKeepsTheRest(t *testing.T) {
    +	for name, c := range map[string]struct {
    +		sample  string
    +		dropped string
    +		kept    bool // the sample itself survives
    +	}{
    +		"milliseconds":      {`{"utc":1790000000000,"soc":5}`, "samples[0].utc", false},
    +		"fractional utc":    {`{"utc":1790000000.5,"soc":5}`, "samples[0].utc", false},
    +		"clock unset":       {`{"utc":0,"soc":5}`, "samples[0].utc", false},
    +		"clock a day ahead": {`{"utc":1790086400,"soc":5}`, "samples[0].utc", false},
    +		"not an object":     {`5`, "samples[0]", false},
    +		"soc above 100":     {`{"utc":1790000000,"soc":101,"speed":20}`, "samples[0].soc", true},
    +		"reversing":         {`{"utc":1790000000,"soc":5,"speed":-4}`, "samples[0].speed", true},
    +		"string speed":      {`{"utc":1790000000,"soc":5,"speed":"fast"}`, "samples[0].speed", true},
    +		"flag 2":            {`{"utc":1790000000,"soc":5,"is_parked":2}`, "samples[0].is_parked", true},
    +		"half position":     {`{"utc":1790000000,"soc":5,"lat":52.1}`, "samples[0].lat/lon", true},
     	} {
    -		if _, err := validateTelemetryBatch(telemetryBody(sample)); !isValidationError(err) {
    -			t.Errorf("%s: want ValidationError, got %v", name, err)
    +		b, err := validateTelemetryBatch(telemetryBody(c.sample), testNow)
    +		if err != nil {
    +			t.Errorf("%s: the batch was rejected: %v", name, err)
    +			continue
    +		}
    +		if len(b.Dropped) != 1 || b.Dropped[0] != c.dropped {
    +			t.Errorf("%s: dropped %v, want %s", name, b.Dropped, c.dropped)
    +		}
    +		if (len(b.Samples) == 1) != c.kept {
    +			t.Errorf("%s: %d samples kept", name, len(b.Samples))
    +		}
    +		if c.kept && b.Samples[0].Numbers["soc"] != 5 && name != "soc above 100" {
    +			t.Errorf("%s: the valid soc was lost", name)
     		}
     	}
     }
     
     func TestTelemetryRejectsEmptyAndOversizedBatches(t *testing.T) {
    -	if _, err := validateTelemetryBatch(telemetryBody("")); !isValidationError(err) {
    +	if _, err := validateTelemetryBatch(telemetryBody(""), testNow); !isValidationError(err) {
     		t.Error("empty batch accepted")
     	}
     	many := strings.TrimSuffix(strings.Repeat(`{"utc":1790000000},`, 501), ",")
    -	if _, err := validateTelemetryBatch(telemetryBody(many)); !isValidationError(err) {
    +	if _, err := validateTelemetryBatch(telemetryBody(many), testNow); !isValidationError(err) {
     		t.Error("501 samples accepted")
     	}
    -	if _, err := validateTelemetryBatch([]byte(`{"installationId":"nope","samples":[{"utc":1790000000}]}`)); !isValidationError(err) {
    +	if _, err := validateTelemetryBatch([]byte(`{"installationId":"nope","samples":[{"utc":1790000000}]}`), testNow); !isValidationError(err) {
     		t.Error("bad installation id accepted")
     	}
     }
    
    From 2a0c4c9675ce6137c42f62ac801db0f94241066b Mon Sep 17 00:00:00 2001
    From: Nils Stinnesbeck 
    Date: Sun, 18 Oct 2026 10:01:11 +0200
    Subject: [PATCH 47/79] App 2.10.0: range since start, usage in its own cards
    
    "Range since start" replaces the range at current power: remaining
    energy over the consumption since the car was switched on, standing
    with the climate on included. A trip starts again after 10 minutes
    without samples (the car was off) or when charging.
    
    The kWh/100 km for both estimates moved out of the range tiles into
    two cards of their own, directly below them; the overview still fits
    the full-screen 1920x720 display without scrolling.
    
    Co-Authored-By: Claude Opus 5.5 
    Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
    ---
     SPECS.md                                      |  5 ++
     app/build.gradle                              |  4 +-
     .../java/com/evsuite/abrp/MainActivity.java   | 43 ++++++++----
     .../java/com/evsuite/abrp/RangeEstimator.java | 69 +++++++++++++++----
     .../main/res/layout-w1000dp/pane_vehicle.xml  | 22 +++++-
     app/src/main/res/layout/pane_vehicle.xml      | 16 ++++-
     app/src/main/res/values/strings.xml           |  8 ++-
     .../com/evsuite/abrp/RangeEstimatorTest.java  | 52 +++++++++++---
     8 files changed, 169 insertions(+), 50 deletions(-)
    
    diff --git a/SPECS.md b/SPECS.md
    index 433bd62..d5e5754 100644
    --- a/SPECS.md
    +++ b/SPECS.md
    @@ -112,6 +112,11 @@ tyre pressures, GPS (lat, lon, elevation, heading) and the is_* flags. It does *
       INGEST_TOKEN) into git and pushed it. Commit amended and force-pushed; both values regenerated in
       the now gitignored `k8s/secrets.yaml` (since moved to `k8s/secrets/secrets.yaml`, outside
       `kubectl apply -f k8s/`). Filled-in secrets live only there from now on.
    +- 2026-10-18 — App 2.10.0: "Range since start" replaces the range at current power. It uses
    +  the consumption since the car was switched on, including standing with the climate on, and a
    +  new trip starts after 10 minutes without samples or when charging. Usage (kWh/100 km) for both
    +  estimates moved into cards of their own under the range tiles, which fit on the full-screen
    +  1920x720 overview without scrolling.
     - 2026-10-18 — Code review fixes. App 2.9.0, server 2.2.0, Grafana token:
       - The server drops a bad value (or a sample without a plausible time) instead of rejecting
         the batch, and answers 200 with what it dropped; the app sends only values within the
    diff --git a/app/build.gradle b/app/build.gradle
    index 3abff06..02e8036 100644
    --- a/app/build.gradle
    +++ b/app/build.gradle
    @@ -34,8 +34,8 @@ android {
             applicationId "com.stinnesbeck.nilsmg4"
             minSdk 27
             targetSdk 36
    -        versionCode 69
    -        versionName "2.9.0"
    +        versionCode 70
    +        versionName "2.10.0"
     
             // Nothing about the server ships in the APK: its URL and ingest token are entered in
             // the app's Server tab (ServerConfig), and the database is only reachable by the server.
    diff --git a/app/src/main/java/com/evsuite/abrp/MainActivity.java b/app/src/main/java/com/evsuite/abrp/MainActivity.java
    index 1d27277..883dbe4 100644
    --- a/app/src/main/java/com/evsuite/abrp/MainActivity.java
    +++ b/app/src/main/java/com/evsuite/abrp/MainActivity.java
    @@ -90,7 +90,8 @@ public class MainActivity extends AppCompatActivity {
         private TextView vehSoc, vehRange, vehPower, vehSpeed, vehOdometer,
                 vehCharging, vehHvac,
                 vehTireFl, vehTireFr, vehTireRl, vehTireRr,
    -            vehRangePower, vehRangePowerNote, vehRangeAverage, vehRangeAverageNote;
    +            vehRangeTrip, vehRangeTripNote, vehRangeAverage, vehRangeAverageNote,
    +            vehUsageTrip, vehUsageTripNote, vehUsageAverage, vehUsageAverageNote;
     
         /** Usable pack capacity for the range estimates; the car does not publish it. */
         static final String USABLE_KWH_KEY = "usable_kwh";
    @@ -995,10 +996,14 @@ public class MainActivity extends AppCompatActivity {
             vehTireFr    = vehiclePane.findViewById(R.id.vehicle_tire_fr);
             vehTireRl    = vehiclePane.findViewById(R.id.vehicle_tire_rl);
             vehTireRr    = vehiclePane.findViewById(R.id.vehicle_tire_rr);
    -        vehRangePower       = vehiclePane.findViewById(R.id.vehicle_range_power);
    -        vehRangePowerNote   = vehiclePane.findViewById(R.id.vehicle_range_power_note);
    +        vehRangeTrip        = vehiclePane.findViewById(R.id.vehicle_range_trip);
    +        vehRangeTripNote    = vehiclePane.findViewById(R.id.vehicle_range_trip_note);
             vehRangeAverage     = vehiclePane.findViewById(R.id.vehicle_range_average);
             vehRangeAverageNote = vehiclePane.findViewById(R.id.vehicle_range_average_note);
    +        vehUsageTrip        = vehiclePane.findViewById(R.id.vehicle_usage_trip);
    +        vehUsageTripNote    = vehiclePane.findViewById(R.id.vehicle_usage_trip_note);
    +        vehUsageAverage     = vehiclePane.findViewById(R.id.vehicle_usage_average);
    +        vehUsageAverageNote = vehiclePane.findViewById(R.id.vehicle_usage_average_note);
         }
     
         private double usableKwh() {
    @@ -1006,31 +1011,39 @@ public class MainActivity extends AppCompatActivity {
         }
     
         /**
    -     * The two range estimates. Both need the car to be moving on battery power; the average
    -     * also needs a minute or more of driving, which the service has usually collected already.
    +     * The range estimates since start and at the 5-minute average, each with its usage in a
    +     * card below it. Since start needs the car to have moved; the average needs a minute or more
    +     * of driving, which the service has usually collected already.
          */
         private void refreshRangeEstimates(EnergySnapshot s) {
             RangeEstimator estimator = RangeEstimator.shared();
             long monotonicMs = android.os.SystemClock.elapsedRealtime();
             estimator.add(monotonicMs, s.getBatteryPowerKw(), s.getSpeedKmh());
             Double remaining = RangeEstimator.remainingKwh(s.getSocPercent(), usableKwh());
    -        showEstimate(vehRangePower, vehRangePowerNote,
    -                RangeEstimator.instant(remaining, s.getBatteryPowerKw(), s.getSpeedKmh()),
    -                R.string.range_not_driving);
    -        showEstimate(vehRangeAverage, vehRangeAverageNote,
    -                estimator.average(remaining, monotonicMs), R.string.range_waiting);
    +        RangeEstimator.Estimate trip = estimator.sinceStart(remaining);
    +        showEstimate(vehRangeTrip, vehRangeTripNote, vehUsageTrip, vehUsageTripNote, trip,
    +                trip == null ? "" : getString(R.string.usage_over_distance, trip.distanceKm),
    +                R.string.range_trip_waiting);
    +        showEstimate(vehRangeAverage, vehRangeAverageNote, vehUsageAverage, vehUsageAverageNote,
    +                estimator.average(remaining, monotonicMs), getString(R.string.usage_last_5_min),
    +                R.string.range_waiting);
         }
     
    -    private void showEstimate(TextView value, TextView note, RangeEstimator.Estimate e, int whyNot) {
    +    private void showEstimate(TextView range, TextView rangeNote, TextView usage, TextView usageNote,
    +                              RangeEstimator.Estimate e, String basis, int whyNot) {
             if (e == null) {
    -            value.setText("—");
    -            note.setText(whyNot);
    +            range.setText("—");
    +            rangeNote.setText(whyNot);
    +            usage.setText("—");
    +            usageNote.setText(whyNot);
                 return;
             }
             // Coasting at speed costs almost nothing, which would read as thousands of km.
    -        value.setText(e.rangeKm > 999 ? getString(R.string.range_over, 999)
    +        range.setText(e.rangeKm > 999 ? getString(R.string.range_over, 999)
                     : String.format(java.util.Locale.US, "%.0f km", e.rangeKm));
    -        note.setText(getString(R.string.range_consumption, e.kwhPer100Km));
    +        rangeNote.setText("");
    +        usage.setText(getString(R.string.range_consumption, e.kwhPer100Km));
    +        usageNote.setText(basis);
         }
     
         /**
    diff --git a/app/src/main/java/com/evsuite/abrp/RangeEstimator.java b/app/src/main/java/com/evsuite/abrp/RangeEstimator.java
    index 351bd1f..58af824 100644
    --- a/app/src/main/java/com/evsuite/abrp/RangeEstimator.java
    +++ b/app/src/main/java/com/evsuite/abrp/RangeEstimator.java
    @@ -6,8 +6,13 @@ import java.util.Iterator;
     /**
      * Estimated range from the energy left in the pack and how fast it is being used.
      *
    - *   instant:   remaining kWh / (power now / speed now)
    - *   average:   remaining kWh / (energy used / distance driven over the last 5 minutes)
    + *   since start: remaining kWh / (energy used / distance driven since the car was switched on)
    + *   average:     remaining kWh / (energy used / distance driven over the last 5 minutes)
    + *
    + * "Since start" is a trip like the car's own trip computer: it begins with the first sample
    + * after the head unit wakes (or after charging), so it includes standing with the climate on.
    + * A gap of {@link #TRIP_RESET_GAP_MS} without samples is the car having been off, and starts
    + * a new trip; so does charging.
      *
      * Remaining energy is SOC x usable capacity: the car publishes no pack energy, so the capacity
      * is a setting. Every estimate is null when it cannot mean anything — standing still,
    @@ -24,25 +29,30 @@ final class RangeEstimator {
         /** Less than this much driving in the window is too little to average. */
         static final long MIN_WINDOW_MS = 60_000L;
         static final double MIN_DISTANCE_KM = 0.2;
    -    /** Below this the car is not really moving, and power / speed blows up. */
    -    static final double MIN_SPEED_KMH = 5;
         /** A gap between samples longer than this (app paused, car off) is not integrated. */
         static final long MAX_GAP_MS = 60_000L;
     
    +    /** No sample for this long: the car was off, and the next sample starts a new trip. */
    +    static final long TRIP_RESET_GAP_MS = 10 * 60_000L;
    +    /** Standing still and taking in more than this: charging, which ends the trip. */
    +    static final double CHARGING_KW = -1.0;
    +
         static final double DEFAULT_USABLE_KWH = 50.8; // MG4 51 kWh battery
     
         private static final RangeEstimator SHARED = new RangeEstimator();
     
         static RangeEstimator shared() { return SHARED; }
     
    -    /** An estimate: the range and the consumption it is based on. */
    +    /** An estimate: the range, the consumption it is based on, and the distance behind it. */
         static final class Estimate {
             final double rangeKm;
             final double kwhPer100Km;
    +        final double distanceKm;
     
    -        Estimate(double rangeKm, double kwhPer100Km) {
    +        Estimate(double rangeKm, double kwhPer100Km, double distanceKm) {
                 this.rangeKm = rangeKm;
                 this.kwhPer100Km = kwhPer100Km;
    +            this.distanceKm = distanceKm;
             }
         }
     
    @@ -60,12 +70,19 @@ final class RangeEstimator {
     
         private final ArrayDeque samples = new ArrayDeque<>();
     
    +    // The trip since start: running totals, so it is not limited to the 5-minute window.
    +    private double tripEnergyKwh;
    +    private double tripDistanceKm;
    +    private Sample tripLast;
    +
         /** Power in kW, positive leaving the battery; speed in km/h. Unread values are skipped. */
         synchronized void add(long timeMs, Float powerKw, Float speedKmh) {
             if (powerKw == null || speedKmh == null || !Float.isFinite(powerKw) || !Float.isFinite(speedKmh)) return;
             Sample last = samples.peekLast();
             if (last != null && timeMs <= last.timeMs) return; // out of order or duplicate tick
    -        samples.addLast(new Sample(timeMs, powerKw, Math.max(0f, speedKmh)));
    +        Sample sample = new Sample(timeMs, powerKw, Math.max(0f, speedKmh));
    +        addToTrip(sample);
    +        samples.addLast(sample);
             while (!samples.isEmpty() && samples.peekFirst().timeMs < timeMs - WINDOW_MS) samples.removeFirst();
         }
     
    @@ -74,11 +91,30 @@ final class RangeEstimator {
             return Math.max(0, Math.min(100, socPercent)) / 100.0 * usableKwh;
         }
     
    -    static Estimate instant(Double remainingKwh, Float powerKw, Float speedKmh) {
    -        if (remainingKwh == null || powerKw == null || speedKmh == null) return null;
    -        if (speedKmh < MIN_SPEED_KMH || powerKw <= 0) return null;
    -        double kwhPerKm = powerKw.doubleValue() / speedKmh.doubleValue();
    -        return new Estimate(remainingKwh / kwhPerKm, kwhPerKm * 100);
    +    private void addToTrip(Sample s) {
    +        boolean charging = s.speedKmh < 1 && s.powerKw < CHARGING_KW;
    +        if (charging || (tripLast != null && s.timeMs - tripLast.timeMs > TRIP_RESET_GAP_MS)) {
    +            tripEnergyKwh = 0;
    +            tripDistanceKm = 0;
    +            tripLast = null;
    +        }
    +        if (charging) return; // the trip starts again after the charge
    +        if (tripLast != null) {
    +            long dt = s.timeMs - tripLast.timeMs;
    +            if (dt > 0 && dt <= MAX_GAP_MS) {
    +                double hours = dt / 3_600_000.0;
    +                tripEnergyKwh += (tripLast.powerKw + s.powerKw) / 2 * hours;
    +                tripDistanceKm += (tripLast.speedKmh + s.speedKmh) / 2 * hours;
    +            }
    +        }
    +        tripLast = s;
    +    }
    +
    +    /** Range at the consumption since start; null until the trip has some distance behind it. */
    +    synchronized Estimate sinceStart(Double remainingKwh) {
    +        if (remainingKwh == null || tripDistanceKm < MIN_DISTANCE_KM || tripEnergyKwh <= 0) return null;
    +        double kwhPerKm = tripEnergyKwh / tripDistanceKm;
    +        return new Estimate(remainingKwh / kwhPerKm, kwhPerKm * 100, tripDistanceKm);
         }
     
         synchronized Estimate average(Double remainingKwh, long nowMs) {
    @@ -102,8 +138,13 @@ final class RangeEstimator {
             }
             if (covered < MIN_WINDOW_MS || distanceKm < MIN_DISTANCE_KM || energyKwh <= 0) return null;
             double kwhPerKm = energyKwh / distanceKm;
    -        return new Estimate(remainingKwh / kwhPerKm, kwhPerKm * 100);
    +        return new Estimate(remainingKwh / kwhPerKm, kwhPerKm * 100, distanceKm);
         }
     
    -    synchronized void clear() { samples.clear(); }
    +    synchronized void clear() {
    +        samples.clear();
    +        tripEnergyKwh = 0;
    +        tripDistanceKm = 0;
    +        tripLast = null;
    +    }
     }
    diff --git a/app/src/main/res/layout-w1000dp/pane_vehicle.xml b/app/src/main/res/layout-w1000dp/pane_vehicle.xml
    index 16ee22d..3ec4f97 100644
    --- a/app/src/main/res/layout-w1000dp/pane_vehicle.xml
    +++ b/app/src/main/res/layout-w1000dp/pane_vehicle.xml
    @@ -12,9 +12,9 @@
                     
                 
                 
    -                
    -                
    -                
    +                
    +                
    +                
                 
                 
                     
    @@ -30,6 +30,22 @@
                     
                 
             
    +        
    +            
    +            
    +            
    +                
    +                
    +                
    +            
    +            
    +                
    +                
    +                
    +            
    +            
    +            
    +        
             
             
                 
    diff --git a/app/src/main/res/layout/pane_vehicle.xml b/app/src/main/res/layout/pane_vehicle.xml
    index f2cbfd7..707b73b 100644
    --- a/app/src/main/res/layout/pane_vehicle.xml
    +++ b/app/src/main/res/layout/pane_vehicle.xml
    @@ -12,15 +12,25 @@
                     
                 
                 
    -                
    -                
    -                
    +                
    +                
    +                
                 
                 
                     
                     
                     
                 
    +            
    +                
    +                
    +                
    +            
    +            
    +                
    +                
    +                
    +            
                 
                     
                     
    diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
    index e2cf188..d96f844 100644
    --- a/app/src/main/res/values/strings.xml
    +++ b/app/src/main/res/values/strings.xml
    @@ -31,12 +31,16 @@
         Charge (SOC)
         Range
         Power
    -    Range at current power
    +    Range since start
         Range at 5-min average
    +    Usage since start
    +    Usage, 5-min average
         %1$.1f kWh/100 km
         > %1$d km
         needs a few minutes of driving
    -    only while driving
    +    once the car has moved
    +    over %1$.1f km
    +    over the last 5 minutes
         Usable battery capacity
         Save capacity
         Used for the range estimates on the Vehicle tab. The 51 kWh MG4 battery has 50.8 kWh usable, the 64 kWh one 61.7, the 77 kWh one 74.4.
    diff --git a/app/src/test/java/com/evsuite/abrp/RangeEstimatorTest.java b/app/src/test/java/com/evsuite/abrp/RangeEstimatorTest.java
    index 6b73e59..50104c0 100644
    --- a/app/src/test/java/com/evsuite/abrp/RangeEstimatorTest.java
    +++ b/app/src/test/java/com/evsuite/abrp/RangeEstimatorTest.java
    @@ -3,6 +3,7 @@ package com.evsuite.abrp;
     import static org.junit.Assert.assertEquals;
     import static org.junit.Assert.assertNotNull;
     import static org.junit.Assert.assertNull;
    +import static org.junit.Assert.assertTrue;
     
     import org.junit.Test;
     
    @@ -19,20 +20,49 @@ public class RangeEstimatorTest {
         }
     
         @Test
    -    public void instantRangeFromPowerAndSpeed() {
    -        // 15 kW at 100 km/h = 15 kWh/100 km; 25.4 kWh left = 169.3 km.
    -        RangeEstimator.Estimate e = RangeEstimator.instant(25.4, 15f, 100f);
    -        assertNotNull(e);
    -        assertEquals(15.0, e.kwhPer100Km, 1e-9);
    -        assertEquals(169.333, e.rangeKm, 0.001);
    +    public void sinceStartCoversTheWholeTripNotJustFiveMinutes() {
    +        RangeEstimator r = new RangeEstimator();
    +        // 10 minutes at 30 kW then 10 minutes at 10 kW, both at 100 km/h: 20 kWh/100 km overall.
    +        for (long t = 0; t <= 600_000; t += 15_000) r.add(t, 30f, 100f);
    +        for (long t = 615_000; t <= 1_200_000; t += 15_000) r.add(t, 10f, 100f);
    +        RangeEstimator.Estimate trip = r.sinceStart(25.4);
    +        assertNotNull(trip);
    +        assertEquals(20.0, trip.kwhPer100Km, 0.2);   // plus the 15 s step between the two
    +        assertEquals(127.0, trip.rangeKm, 1.0);
    +        assertEquals(33.3, trip.distanceKm, 0.1);
    +        // The 5-minute average only sees the economical end.
    +        assertEquals(10.0, r.average(25.4, 1_200_000).kwhPer100Km, 0.1);
         }
     
         @Test
    -    public void noInstantEstimateWhenStandingRegeneratingOrUnread() {
    -        assertNull(RangeEstimator.instant(25.4, 2f, 3f));     // not really moving
    -        assertNull(RangeEstimator.instant(25.4, -10f, 60f));  // regenerating
    -        assertNull(RangeEstimator.instant(25.4, null, 60f));  // power unread
    -        assertNull(RangeEstimator.instant(null, 15f, 60f));   // SOC unread
    +    public void standingWithTheClimateOnCountsTowardsTheTrip() {
    +        RangeEstimator r = new RangeEstimator();
    +        for (long t = 0; t <= 120_000; t += 15_000) r.add(t, 3f, 0f);       // warming up
    +        for (long t = 135_000; t <= 735_000; t += 15_000) r.add(t, 15f, 60f);
    +        assertTrue(r.sinceStart(25.4).kwhPer100Km > 15.0);
    +    }
    +
    +    @Test
    +    public void aLongGapOrChargingStartsANewTrip() {
    +        RangeEstimator r = new RangeEstimator();
    +        for (long t = 0; t <= 600_000; t += 15_000) r.add(t, 30f, 100f);
    +        long later = 600_000 + RangeEstimator.TRIP_RESET_GAP_MS + 1;      // the car was off
    +        for (long t = later; t <= later + 600_000; t += 15_000) r.add(t, 10f, 100f);
    +        assertEquals(10.0, r.sinceStart(25.4).kwhPer100Km, 0.1);
    +
    +        long charge = later + 615_000;
    +        r.add(charge, -7f, 0f);                                             // plugged in
    +        assertNull(r.sinceStart(25.4));
    +    }
    +
    +    @Test
    +    public void noSinceStartBeforeAnyDistanceOrWithoutSoc() {
    +        RangeEstimator r = new RangeEstimator();
    +        assertNull(r.sinceStart(25.4));
    +        for (long t = 0; t <= 60_000; t += 15_000) r.add(t, 2f, 0f);
    +        assertNull(r.sinceStart(25.4));                                     // not moved yet
    +        for (long t = 75_000; t <= 300_000; t += 15_000) r.add(t, 15f, 60f);
    +        assertNull(r.sinceStart(null));
         }
     
         @Test
    
    From 3ae1e18a43351b6c43d55a9ef9f42a923b52695c Mon Sep 17 00:00:00 2001
    From: Nils Stinnesbeck 
    Date: Sun, 18 Oct 2026 10:07:08 +0200
    Subject: [PATCH 48/79] App 2.10.1: show negative usage while recuperating
    
    Net regeneration over the window (or the trip) used to hide both
    estimates behind "needs a few minutes of driving". It is a real
    reading: the usage now shows as negative kWh/100 km, and the range as
    "> 999 km" with "while recuperating" beneath it.
    
    Co-Authored-By: Claude Opus 5.5 
    Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
    ---
     SPECS.md                                      |  2 ++
     app/build.gradle                              |  4 ++--
     .../java/com/evsuite/abrp/MainActivity.java   |  2 +-
     .../java/com/evsuite/abrp/RangeEstimator.java | 22 +++++++++++++------
     app/src/main/res/values/strings.xml           |  1 +
     .../com/evsuite/abrp/RangeEstimatorTest.java  |  5 ++++-
     6 files changed, 25 insertions(+), 11 deletions(-)
    
    diff --git a/SPECS.md b/SPECS.md
    index d5e5754..42912a4 100644
    --- a/SPECS.md
    +++ b/SPECS.md
    @@ -112,6 +112,8 @@ tyre pressures, GPS (lat, lon, elevation, heading) and the is_* flags. It does *
       INGEST_TOKEN) into git and pushed it. Commit amended and force-pushed; both values regenerated in
       the now gitignored `k8s/secrets.yaml` (since moved to `k8s/secrets/secrets.yaml`, outside
       `kubectl apply -f k8s/`). Filled-in secrets live only there from now on.
    +- 2026-10-18 — App 2.10.1: net regeneration (recuperating downhill) shows as negative usage, and
    +  the range as "> 999 km" with "while recuperating", instead of "needs a few minutes of driving".
     - 2026-10-18 — App 2.10.0: "Range since start" replaces the range at current power. It uses
       the consumption since the car was switched on, including standing with the climate on, and a
       new trip starts after 10 minutes without samples or when charging. Usage (kWh/100 km) for both
    diff --git a/app/build.gradle b/app/build.gradle
    index 02e8036..791eb08 100644
    --- a/app/build.gradle
    +++ b/app/build.gradle
    @@ -34,8 +34,8 @@ android {
             applicationId "com.stinnesbeck.nilsmg4"
             minSdk 27
             targetSdk 36
    -        versionCode 70
    -        versionName "2.10.0"
    +        versionCode 71
    +        versionName "2.10.1"
     
             // Nothing about the server ships in the APK: its URL and ingest token are entered in
             // the app's Server tab (ServerConfig), and the database is only reachable by the server.
    diff --git a/app/src/main/java/com/evsuite/abrp/MainActivity.java b/app/src/main/java/com/evsuite/abrp/MainActivity.java
    index 883dbe4..6b36f54 100644
    --- a/app/src/main/java/com/evsuite/abrp/MainActivity.java
    +++ b/app/src/main/java/com/evsuite/abrp/MainActivity.java
    @@ -1041,7 +1041,7 @@ public class MainActivity extends AppCompatActivity {
             // Coasting at speed costs almost nothing, which would read as thousands of km.
             range.setText(e.rangeKm > 999 ? getString(R.string.range_over, 999)
                     : String.format(java.util.Locale.US, "%.0f km", e.rangeKm));
    -        rangeNote.setText("");
    +        rangeNote.setText(e.kwhPer100Km <= 0 ? getString(R.string.range_recuperating) : "");
             usage.setText(getString(R.string.range_consumption, e.kwhPer100Km));
             usageNote.setText(basis);
         }
    diff --git a/app/src/main/java/com/evsuite/abrp/RangeEstimator.java b/app/src/main/java/com/evsuite/abrp/RangeEstimator.java
    index 58af824..c3aa60d 100644
    --- a/app/src/main/java/com/evsuite/abrp/RangeEstimator.java
    +++ b/app/src/main/java/com/evsuite/abrp/RangeEstimator.java
    @@ -15,8 +15,8 @@ import java.util.Iterator;
      * a new trip; so does charging.
      *
      * Remaining energy is SOC x usable capacity: the car publishes no pack energy, so the capacity
    - * is a setting. Every estimate is null when it cannot mean anything — standing still,
    - * regenerating, too little data — rather than a misleading number.
    + * is a setting. An estimate is null when it cannot mean anything — not moved yet, too little
    + * data — rather than a misleading number. Net regeneration is shown as it is: negative usage.
      *
      * Fed by both the service tick (15 s) and the open Vehicle page (2 s); the integration is
      * time-weighted, so the two rates mix correctly. Times are on the monotonic clock
    @@ -112,9 +112,8 @@ final class RangeEstimator {
     
         /** Range at the consumption since start; null until the trip has some distance behind it. */
         synchronized Estimate sinceStart(Double remainingKwh) {
    -        if (remainingKwh == null || tripDistanceKm < MIN_DISTANCE_KM || tripEnergyKwh <= 0) return null;
    -        double kwhPerKm = tripEnergyKwh / tripDistanceKm;
    -        return new Estimate(remainingKwh / kwhPerKm, kwhPerKm * 100, tripDistanceKm);
    +        if (remainingKwh == null || tripDistanceKm < MIN_DISTANCE_KM) return null;
    +        return estimate(remainingKwh, tripEnergyKwh, tripDistanceKm);
         }
     
         synchronized Estimate average(Double remainingKwh, long nowMs) {
    @@ -136,9 +135,18 @@ final class RangeEstimator {
                 }
                 previous = s;
             }
    -        if (covered < MIN_WINDOW_MS || distanceKm < MIN_DISTANCE_KM || energyKwh <= 0) return null;
    +        if (covered < MIN_WINDOW_MS || distanceKm < MIN_DISTANCE_KM) return null;
    +        return estimate(remainingKwh, energyKwh, distanceKm);
    +    }
    +
    +    /**
    +     * Net regeneration (downhill, recuperating) is a real reading: the usage is negative, and
    +     * the range is unbounded rather than negative.
    +     */
    +    private static Estimate estimate(double remainingKwh, double energyKwh, double distanceKm) {
             double kwhPerKm = energyKwh / distanceKm;
    -        return new Estimate(remainingKwh / kwhPerKm, kwhPerKm * 100, distanceKm);
    +        double rangeKm = kwhPerKm > 0 ? remainingKwh / kwhPerKm : Double.POSITIVE_INFINITY;
    +        return new Estimate(rangeKm, kwhPerKm * 100, distanceKm);
         }
     
         synchronized void clear() {
    diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
    index d96f844..26b8b1c 100644
    --- a/app/src/main/res/values/strings.xml
    +++ b/app/src/main/res/values/strings.xml
    @@ -38,6 +38,7 @@
         %1$.1f kWh/100 km
         > %1$d km
         needs a few minutes of driving
    +    while recuperating
         once the car has moved
         over %1$.1f km
         over the last 5 minutes
    diff --git a/app/src/test/java/com/evsuite/abrp/RangeEstimatorTest.java b/app/src/test/java/com/evsuite/abrp/RangeEstimatorTest.java
    index 50104c0..cb442da 100644
    --- a/app/src/test/java/com/evsuite/abrp/RangeEstimatorTest.java
    +++ b/app/src/test/java/com/evsuite/abrp/RangeEstimatorTest.java
    @@ -110,6 +110,9 @@ public class RangeEstimatorTest {
     
             RangeEstimator downhill = new RangeEstimator();
             for (long t = 0; t <= 240_000; t += 2_000) downhill.add(t, -5f, 80f);
    -        assertNull("net regeneration", downhill.average(25.4, 240_000));
    +        RangeEstimator.Estimate regen = downhill.average(25.4, 240_000);
    +        assertNotNull("net regeneration is a reading", regen);
    +        assertEquals(-6.25, regen.kwhPer100Km, 0.01);
    +        assertTrue(Double.isInfinite(regen.rangeKm));
         }
     }
    
    From 087f193f8c0177290eac092cdbee12311bf3602d Mon Sep 17 00:00:00 2001
    From: Nils Stinnesbeck 
    Date: Sun, 18 Oct 2026 11:01:02 +0200
    Subject: [PATCH 49/79] App 2.10.2: infinity as the range while recuperating
    
    Co-Authored-By: Claude Opus 5.5 
    Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
    ---
     SPECS.md                                             | 1 +
     app/build.gradle                                     | 4 ++--
     app/src/main/java/com/evsuite/abrp/MainActivity.java | 6 ++++--
     3 files changed, 7 insertions(+), 4 deletions(-)
    
    diff --git a/SPECS.md b/SPECS.md
    index 42912a4..169ab12 100644
    --- a/SPECS.md
    +++ b/SPECS.md
    @@ -112,6 +112,7 @@ tyre pressures, GPS (lat, lon, elevation, heading) and the is_* flags. It does *
       INGEST_TOKEN) into git and pushed it. Commit amended and force-pushed; both values regenerated in
       the now gitignored `k8s/secrets.yaml` (since moved to `k8s/secrets/secrets.yaml`, outside
       `kubectl apply -f k8s/`). Filled-in secrets live only there from now on.
    +- 2026-10-18 — App 2.10.2: the range shows "∞" instead of "> 999 km" while recuperating.
     - 2026-10-18 — App 2.10.1: net regeneration (recuperating downhill) shows as negative usage, and
       the range as "> 999 km" with "while recuperating", instead of "needs a few minutes of driving".
     - 2026-10-18 — App 2.10.0: "Range since start" replaces the range at current power. It uses
    diff --git a/app/build.gradle b/app/build.gradle
    index 791eb08..36daf11 100644
    --- a/app/build.gradle
    +++ b/app/build.gradle
    @@ -34,8 +34,8 @@ android {
             applicationId "com.stinnesbeck.nilsmg4"
             minSdk 27
             targetSdk 36
    -        versionCode 71
    -        versionName "2.10.1"
    +        versionCode 72
    +        versionName "2.10.2"
     
             // Nothing about the server ships in the APK: its URL and ingest token are entered in
             // the app's Server tab (ServerConfig), and the database is only reachable by the server.
    diff --git a/app/src/main/java/com/evsuite/abrp/MainActivity.java b/app/src/main/java/com/evsuite/abrp/MainActivity.java
    index 6b36f54..a5e1b5f 100644
    --- a/app/src/main/java/com/evsuite/abrp/MainActivity.java
    +++ b/app/src/main/java/com/evsuite/abrp/MainActivity.java
    @@ -1038,8 +1038,10 @@ public class MainActivity extends AppCompatActivity {
                 usageNote.setText(whyNot);
                 return;
             }
    -        // Coasting at speed costs almost nothing, which would read as thousands of km.
    -        range.setText(e.rangeKm > 999 ? getString(R.string.range_over, 999)
    +        // Recuperating, the pack gains energy: the range is unbounded. Coasting at speed costs
    +        // almost nothing, which would read as thousands of km.
    +        range.setText(e.kwhPer100Km <= 0 ? "∞"
    +                : e.rangeKm > 999 ? getString(R.string.range_over, 999)
                     : String.format(java.util.Locale.US, "%.0f km", e.rangeKm));
             rangeNote.setText(e.kwhPer100Km <= 0 ? getString(R.string.range_recuperating) : "");
             usage.setText(getString(R.string.range_consumption, e.kwhPer100Km));
    
    From ca2f899403f7a44fa8b8983b69d98c07bb24a28e Mon Sep 17 00:00:00 2001
    From: Nils Stinnesbeck 
    Date: Sun, 18 Oct 2026 12:15:21 +0200
    Subject: [PATCH 50/79] App 2.11.0, server 2.3.0: live data straight to ABRP;
     settings regrouped
    
    ABRP keeps getting live data while the car server is down: with an ABRP
    API key and user token set in the app (Live data tab, or abrp_api_key /
    abrp_token in the config file), the car sends the newest sample to ABRP
    itself at every upload. It tells the server the newest sample ABRP took
    (abrpSentUtc), and the server forwards only newer ones, so ABRP gets
    each sample once. Without credentials in the app the server forwards
    everything, as before. The credentials sit in encrypted storage, go in
    the request body, and neither they nor ABRP's answer are logged.
    
    Settings are grouped by what they are for:
    - Live data (was Upload): on/off, ABRP credentials, upload frequency.
    - Settings (replaces the Wi-Fi tab): full screen and voice key,
      usable capacity and manual SoH (were on Consumption), charging price
      (was on Charging), Wi-Fi.
    Charging and Live data get icons of their own.
    
    Co-Authored-By: Claude Opus 5.5 
    Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
    ---
     README.md                                     |  34 +++--
     SPECS.md                                      |  10 ++
     app/build.gradle                              |   4 +-
     .../main/java/com/evsuite/abrp/AbrpApi.java   |  59 +++++++++
     .../java/com/evsuite/abrp/AbrpConfig.java     |  63 ++++++++++
     .../com/evsuite/abrp/AbrpUploadService.java   |   4 +-
     .../java/com/evsuite/abrp/ConfigImport.java   |  22 +++-
     .../java/com/evsuite/abrp/MainActivity.java   | 119 ++++++++++++++----
     .../java/com/evsuite/abrp/ServerHttp.java     |  20 ++-
     .../java/com/evsuite/abrp/TelemetryQueue.java |   3 +
     .../com/evsuite/abrp/TelemetryUploader.java   |  49 +++++++-
     app/src/main/res/drawable/ic_tab_charging.xml |  11 ++
     app/src/main/res/drawable/ic_tab_live.xml     |  17 +++
     app/src/main/res/drawable/ic_tab_service.xml  |   2 +-
     app/src/main/res/drawable/ic_tab_wifi.xml     |  12 --
     .../main/res/layout-w1000dp/activity_main.xml |   6 +-
     .../main/res/layout-w1000dp/pane_service.xml  |  49 +++++---
     .../main/res/layout-w1000dp/pane_settings.xml |  48 +++++++
     app/src/main/res/layout-w1000dp/pane_wifi.xml |  17 ---
     app/src/main/res/layout/activity_main.xml     |   6 +-
     app/src/main/res/layout/pane_charging.xml     |   7 --
     app/src/main/res/layout/pane_consumption.xml  |  13 --
     app/src/main/res/layout/pane_service.xml      |  20 ++-
     app/src/main/res/layout/pane_settings.xml     |  44 +++++++
     app/src/main/res/layout/pane_wifi.xml         |  18 ---
     app/src/main/res/values/strings.xml           |  24 +++-
     app/src/main/res/values/strings_design.xml    |  14 ++-
     .../java/com/evsuite/abrp/AbrpApiTest.java    |  40 ++++++
     .../com/evsuite/abrp/ConfigImportTest.java    |   9 ++
     k8s/secrets/secrets.example.yaml              |   3 +-
     k8s/server.yaml                               |   4 +-
     server/README.md                              |   4 +-
     server/api.go                                 |  16 ++-
     server/api_test.go                            |  20 +++
     server/model.go                               |   3 +
     server/validate.go                            |   5 +
     36 files changed, 636 insertions(+), 163 deletions(-)
     create mode 100644 app/src/main/java/com/evsuite/abrp/AbrpApi.java
     create mode 100644 app/src/main/java/com/evsuite/abrp/AbrpConfig.java
     create mode 100644 app/src/main/res/drawable/ic_tab_charging.xml
     create mode 100644 app/src/main/res/drawable/ic_tab_live.xml
     delete mode 100644 app/src/main/res/drawable/ic_tab_wifi.xml
     create mode 100644 app/src/main/res/layout-w1000dp/pane_settings.xml
     delete mode 100644 app/src/main/res/layout-w1000dp/pane_wifi.xml
     create mode 100644 app/src/main/res/layout/pane_settings.xml
     delete mode 100644 app/src/main/res/layout/pane_wifi.xml
     create mode 100644 app/src/test/java/com/evsuite/abrp/AbrpApiTest.java
    
    diff --git a/README.md b/README.md
    index 18a0a39..ab1bdb4 100644
    --- a/README.md
    +++ b/README.md
    @@ -47,32 +47,36 @@ vehicle there, so every value shows as a dash).
     ## How it fits together
     
     ```
    -car ──live telemetry───▶ car.k8s.stinnesbeck.com (Go) ──▶ ABRP
    -    ──daily chunks─────▶          │
    -    ──charging sessions▶          ▼
    -                              InfluxDB 2 ◀── Grafana
    +car ──live telemetry───▶ car.k8s.stinnesbeck.com (Go) ──▶ ABRP (what the car did not send)
    + │  ──daily chunks─────▶          │
    + │  ──charging sessions▶          ▼
    + │                            InfluxDB 2 ◀── Grafana
    + └──live telemetry (optional, direct)──────────────────▶ ABRP
     ```
     
    -The ABRP credentials live only on the server, never in the APK. The car authenticates with a
    -token of its own, obtained by pairing with a phone (below).
    +With ABRP credentials set in the app (Live data tab), the car sends live data to ABRP itself,
    +so ABRP keeps getting it while the server is down; the server then forwards only samples the
    +car could not deliver. Without them in the app, the server forwards everything with its own
    +ABRP credentials. The car authenticates with the server using a token of its own, obtained by
    +pairing with a phone (below).
     
     ## The app
     
     | Tab | What it shows |
     |---|---|
    -| **Overview** | Battery, range, range estimates at current power and at the 5-minute average, power, speed; odometer, climate setpoint and tyre pressures |
    +| **Overview** | Battery, range, range since start and at the 5-minute average with their usage, power, speed; odometer, climate setpoint and tyre pressures |
     | **Consumption** | Daily and period consumption, efficiency |
     | **Charging** | Charging sessions and curves, cost in euro |
    -| **Upload** | Live telemetry on/off, full screen, voice key, upload cadence |
    +| **Live data** | Live data on/off, ABRP credentials for sending directly from the car, upload frequency |
     | **Server** | Server URL, pairing by QR code, ingest token, installation ID, config import |
    -| **Wi-Fi** | Joins a network the head unit already knows (e.g. the phone hotspot) |
    +| **Settings** | Full screen, voice key, battery (usable capacity, manual SoH), charging price, Wi-Fi |
     | **Logs** | Upload log and the last steering-wheel key reports |
     
     - **Opening the app:** the MG4 launcher does not list third-party apps. **Double-press the
    -  voice key** on the steering wheel and the app comes to the front (switch on the Upload tab,
    -  on by default).
    +  voice key** on the steering wheel and the app comes to the front (switch on the Settings
    +  tab, on by default).
     - **Full screen:** hides SAIC's top and bottom bars while the app is open; a swipe from the
    -  edge shows them briefly (Upload tab, on by default).
    +  edge shows them briefly (Settings tab, on by default).
     - **Live telemetry:** every 15 s while driving or charging, much less often while parked. A
       value the car cannot read is left out, never sent as zero, so ABRP is never told the battery
       is empty because a read failed.
    @@ -113,7 +117,9 @@ pointed at this repository's releases (see `SPECS.md`).
     2. On the **Server** tab the URL is already `https://car.k8s.stinnesbeck.com`.
     3. Tap **Pair with phone**. Scan the QR code with a phone (or open the address shown and enter
        the code), and approve with the server's approval password. The car receives its token.
    -4. On the **Upload** tab, turn live telemetry on. The service starts with the car from then on.
    +4. On the **Live data** tab, turn live data on. The service starts with the car from then on.
    +5. Optional: enter your ABRP API key and user token on the same tab, so the car also sends
    +   live data straight to ABRP.
     
     ### Config file
     
    @@ -135,6 +141,8 @@ installation_id = only to carry over history from an install older than 2.9.0
     interval_sec    = 60      # 15 / 30 / 60 / 120 / 300
     boost_low_soc   = true
     low_soc_percent = 20
    +abrp_api_key    = optional: your ABRP API key, to send to ABRP directly
    +abrp_token      = optional: your ABRP user token
     ```
     
     After a successful import the app deletes the file, since it holds the token; credentials live
    diff --git a/SPECS.md b/SPECS.md
    index 169ab12..82eff48 100644
    --- a/SPECS.md
    +++ b/SPECS.md
    @@ -112,6 +112,16 @@ tyre pressures, GPS (lat, lon, elevation, heading) and the is_* flags. It does *
       INGEST_TOKEN) into git and pushed it. Commit amended and force-pushed; both values regenerated in
       the now gitignored `k8s/secrets.yaml` (since moved to `k8s/secrets/secrets.yaml`, outside
       `kubectl apply -f k8s/`). Filled-in secrets live only there from now on.
    +- 2026-10-18 — App 2.11.0, server 2.3.0:
    +  - Live data goes to ABRP straight from the car when ABRP credentials are set in the app
    +    (Live data tab or config file: `abrp_api_key`, `abrp_token`), so ABRP keeps getting it
    +    while the server is down. The car tells the server the newest sample ABRP took
    +    (`abrpSentUtc`); the server forwards only newer ones. Without credentials in the app the
    +    server forwards as before. This deliberately puts ABRP credentials in the car again
    +    (encrypted, never logged).
    +  - Settings regrouped: Live data (on/off, ABRP, frequency), Server, Settings (full screen, voice
    +    key, usable capacity and SoH from Consumption, charging price from Charging, Wi-Fi, which
    +    lost its own tab). Charging and Live data have their own icons.
     - 2026-10-18 — App 2.10.2: the range shows "∞" instead of "> 999 km" while recuperating.
     - 2026-10-18 — App 2.10.1: net regeneration (recuperating downhill) shows as negative usage, and
       the range as "> 999 km" with "while recuperating", instead of "needs a few minutes of driving".
    diff --git a/app/build.gradle b/app/build.gradle
    index 36daf11..61914b1 100644
    --- a/app/build.gradle
    +++ b/app/build.gradle
    @@ -34,8 +34,8 @@ android {
             applicationId "com.stinnesbeck.nilsmg4"
             minSdk 27
             targetSdk 36
    -        versionCode 72
    -        versionName "2.10.2"
    +        versionCode 73
    +        versionName "2.11.0"
     
             // Nothing about the server ships in the APK: its URL and ingest token are entered in
             // the app's Server tab (ServerConfig), and the database is only reachable by the server.
    diff --git a/app/src/main/java/com/evsuite/abrp/AbrpApi.java b/app/src/main/java/com/evsuite/abrp/AbrpApi.java
    new file mode 100644
    index 0000000..b658a09
    --- /dev/null
    +++ b/app/src/main/java/com/evsuite/abrp/AbrpApi.java
    @@ -0,0 +1,59 @@
    +package com.evsuite.abrp;
    +
    +import java.net.URLEncoder;
    +import java.nio.charset.StandardCharsets;
    +
    +/**
    + * ABRP's telemetry API, for sending live data straight from the car.
    + *
    + * Credentials go in the POST body, never in the query string: a URL travels through proxies,
    + * access logs and crash reports, a body does not. Neither is ever logged.
    + */
    +final class AbrpApi {
    +
    +    /** No query string: credentials travel in the body, so the URL is a constant. */
    +    static final String SEND_URL = "https://api.iternio.com/1/tlm/send";
    +
    +    /**
    +     * Read-only endpoint used to check credentials. The Test button must never touch
    +     * /tlm/send: a placeholder sample would tell ABRP the car really is in that state and
    +     * corrupt a live route plan.
    +     */
    +    static final String VERIFY_URL = "https://api.iternio.com/1/tlm/get_next_charge";
    +
    +    private AbrpApi() { }
    +
    +    /** Posts one tlm sample. Throws on a network failure. */
    +    static ServerHttp.Response send(AbrpConfig config, String tlmJson) throws Exception {
    +        byte[] body = formBody(config.apiKey, config.userToken, tlmJson).getBytes(StandardCharsets.UTF_8);
    +        return ServerHttp.send("POST", SEND_URL, null, "application/x-www-form-urlencoded", body);
    +    }
    +
    +    /**
    +     * Checks the credentials without writing anything. This endpoint only takes the token as a
    +     * query parameter; the API key goes in the Authorization header. One token in one URL when
    +     * the user presses Test beats writing fake telemetry on every press.
    +     */
    +    static ServerHttp.Response verify(AbrpConfig config) throws Exception {
    +        String url = VERIFY_URL + "?token=" + URLEncoder.encode(config.userToken, StandardCharsets.UTF_8.name());
    +        return ServerHttp.send("GET", url, "APIKEY " + config.apiKey, null, null);
    +    }
    +
    +    /** The form body: credentials and the sample, each URL-encoded. */
    +    static String formBody(String apiKey, String userToken, String tlmJson) throws Exception {
    +        String utf8 = StandardCharsets.UTF_8.name();
    +        return "api_key=" + URLEncoder.encode(apiKey, utf8)
    +             + "&token=" + URLEncoder.encode(userToken, utf8)
    +             + "&tlm=" + URLEncoder.encode(tlmJson, utf8);
    +    }
    +
    +    /** ABRP answers 200 with {"status":"ok"}; anything else is a rejection. */
    +    static boolean accepted(ServerHttp.Response response) {
    +        if (!response.ok()) return false;
    +        try {
    +            return "ok".equals(new org.json.JSONObject(response.body).optString("status"));
    +        } catch (Exception e) {
    +            return false;
    +        }
    +    }
    +}
    diff --git a/app/src/main/java/com/evsuite/abrp/AbrpConfig.java b/app/src/main/java/com/evsuite/abrp/AbrpConfig.java
    new file mode 100644
    index 0000000..6ffee1b
    --- /dev/null
    +++ b/app/src/main/java/com/evsuite/abrp/AbrpConfig.java
    @@ -0,0 +1,63 @@
    +package com.evsuite.abrp;
    +
    +import android.content.Context;
    +import android.content.SharedPreferences;
    +
    +/**
    + * The ABRP credentials for sending live data straight from the car: the API key (which app is
    + * sending) and the user token (which car in which ABRP account). Kept in {@link SecurePrefs}
    + * next to the server settings, entered on the Live data tab or imported from a config file.
    + *
    + * With both set, the car sends to ABRP itself, so ABRP keeps getting live data when the car
    + * server is down; the server then only forwards what the car could not deliver. Without them,
    + * the server forwards as before.
    + */
    +final class AbrpConfig {
    +
    +    static final String KEY_API_KEY = "abrp_api_key";
    +    static final String KEY_USER_TOKEN = "abrp_user_token";
    +
    +    final String apiKey;
    +    final String userToken;
    +
    +    private static volatile AbrpConfig current;
    +
    +    AbrpConfig(String apiKey, String userToken) {
    +        this.apiKey = apiKey;
    +        this.userToken = userToken;
    +    }
    +
    +    boolean isConfigured() {
    +        return !apiKey.isEmpty() && !userToken.isEmpty();
    +    }
    +
    +    static AbrpConfig get(Context context) {
    +        AbrpConfig config = current;
    +        if (config != null) return config;
    +        synchronized (AbrpConfig.class) {
    +            if (current == null) {
    +                SharedPreferences prefs = SecurePrefs.get(context);
    +                current = new AbrpConfig(prefs.getString(KEY_API_KEY, ""), prefs.getString(KEY_USER_TOKEN, ""));
    +            }
    +            return current;
    +        }
    +    }
    +
    +    /** Stores trimmed values; empty strings switch direct sending off. */
    +    static synchronized void save(Context context, String apiKey, String userToken) {
    +        String key = apiKey == null ? "" : apiKey.trim();
    +        String token = userToken == null ? "" : userToken.trim();
    +        SecurePrefs.get(context).edit()
    +                .putString(KEY_API_KEY, key)
    +                .putString(KEY_USER_TOKEN, token)
    +                .apply();
    +        current = new AbrpConfig(key, token);
    +    }
    +
    +    /** Plausible as an ABRP credential: no spaces, not absurdly short or long. */
    +    static boolean isPlausible(String value) {
    +        if (value == null) return false;
    +        String v = value.trim();
    +        return v.length() >= 8 && v.length() <= 200 && !v.contains(" ");
    +    }
    +}
    diff --git a/app/src/main/java/com/evsuite/abrp/AbrpUploadService.java b/app/src/main/java/com/evsuite/abrp/AbrpUploadService.java
    index be5cb34..c30a27b 100644
    --- a/app/src/main/java/com/evsuite/abrp/AbrpUploadService.java
    +++ b/app/src/main/java/com/evsuite/abrp/AbrpUploadService.java
    @@ -173,7 +173,9 @@ public class AbrpUploadService extends Service {
             }
     
             UploadSettings settings = UploadSettings.from(prefs);
    -        if (!ServerConfig.get(this).isConfigured()) {
    +        // Live data goes to the server, to ABRP directly, or both; with neither set up there is
    +        // nowhere to send it.
    +        if (!ServerConfig.get(this).isConfigured() && !AbrpConfig.get(this).isConfigured()) {
                 telemetryUploader.stop(); // no live telemetry, so no GPS either
                 updateNotification(getString(R.string.server_not_configured));
             } else if (!settings.abrpUploadEnabled) {
    diff --git a/app/src/main/java/com/evsuite/abrp/ConfigImport.java b/app/src/main/java/com/evsuite/abrp/ConfigImport.java
    index cbea2b1..674bac3 100644
    --- a/app/src/main/java/com/evsuite/abrp/ConfigImport.java
    +++ b/app/src/main/java/com/evsuite/abrp/ConfigImport.java
    @@ -13,6 +13,8 @@ package com.evsuite.abrp;
      *   interval_sec    = 60
      *   boost_low_soc   = true
      *   low_soc_percent = 20
    + *   abrp_api_key    = your-abrp-api-key       (optional: send live data to ABRP directly)
    + *   abrp_token      = your-abrp-user-token
      * 
    * * Usually only server_url and token are needed. installation_id is for a reinstall: it puts @@ -29,25 +31,29 @@ final class ConfigImport { final Integer intervalSec; final Boolean boostLowSoc; final Integer lowSocPercent; + final String abrpApiKey; + final String abrpToken; private ConfigImport(String serverUrl, String token, String installationId, Integer intervalSec, - Boolean boostLowSoc, Integer lowSocPercent) { + Boolean boostLowSoc, Integer lowSocPercent, String abrpApiKey, String abrpToken) { this.serverUrl = serverUrl; this.token = token; this.installationId = installationId; this.intervalSec = intervalSec; this.boostLowSoc = boostLowSoc; this.lowSocPercent = lowSocPercent; + this.abrpApiKey = abrpApiKey; + this.abrpToken = abrpToken; } /** True when the file yielded nothing usable — an empty or malformed file, not a config. */ boolean isEmpty() { return serverUrl == null && token == null && installationId == null && intervalSec == null - && boostLowSoc == null && lowSocPercent == null; + && boostLowSoc == null && lowSocPercent == null && abrpApiKey == null && abrpToken == null; } static ConfigImport parse(String text) { - String serverUrl = null, token = null, installationId = null; + String serverUrl = null, token = null, installationId = null, abrpApiKey = null, abrpToken = null; Integer intervalSec = null, lowSocPercent = null; Boolean boostLowSoc = null; @@ -85,13 +91,21 @@ final class ConfigImport { case "low_soc_percent": lowSocPercent = clampPercent(value); break; + case "abrp_api_key": + abrpApiKey = value; + break; + case "abrp_token": + case "abrp_user_token": + abrpToken = value; + break; default: // Unknown keys are ignored rather than rejected, so a file can carry // comments or future keys without breaking an older build. break; } } - return new ConfigImport(serverUrl, token, installationId, intervalSec, boostLowSoc, lowSocPercent); + return new ConfigImport(serverUrl, token, installationId, intervalSec, boostLowSoc, lowSocPercent, + abrpApiKey, abrpToken); } /** Snaps to the nearest offered choice: the UI spinner has no free-form interval. */ diff --git a/app/src/main/java/com/evsuite/abrp/MainActivity.java b/app/src/main/java/com/evsuite/abrp/MainActivity.java index a5e1b5f..4e1646d 100644 --- a/app/src/main/java/com/evsuite/abrp/MainActivity.java +++ b/app/src/main/java/com/evsuite/abrp/MainActivity.java @@ -42,16 +42,16 @@ public class MainActivity extends AppCompatActivity { /** Navigation destinations in page order. Parallel to {@link #panes}. */ private static final int[] TAB_IDS = { R.id.tabVehicle, R.id.tabConsumption, R.id.tabCharging, R.id.tabService, R.id.tabServer, - R.id.tabWifi, R.id.tabLog }; + R.id.tabSettings, R.id.tabLog }; private static final int[] PAGE_TITLES = { R.string.design_overview_title, R.string.consumption_title, R.string.charging_title, - R.string.design_service_title, R.string.design_server_title, R.string.design_wifi_title, + R.string.design_service_title, R.string.design_server_title, R.string.design_settings_title, R.string.design_log_title }; private static final int[] PAGE_SUBTITLES = { R.string.design_overview_subtitle, R.string.consumption_subtitle, R.string.charging_subtitle, - R.string.design_service_subtitle, R.string.design_server_subtitle, R.string.design_wifi_subtitle, + R.string.design_service_subtitle, R.string.design_server_subtitle, R.string.design_settings_subtitle, R.string.design_log_subtitle }; @@ -70,7 +70,8 @@ public class MainActivity extends AppCompatActivity { private View vehiclePane; private View consumptionPane; private View chargingPane; - private View wifiPane; + /** Display, battery, charging price and Wi-Fi. */ + private View settingsPane; /** The pages in navigation order. Parallel to {@link #TAB_IDS}. */ private View[] panes; @@ -241,10 +242,10 @@ public class MainActivity extends AppCompatActivity { vehiclePane = inflatePane(R.layout.pane_vehicle); consumptionPane = inflatePane(R.layout.pane_consumption); chargingPane = inflatePane(R.layout.pane_charging); - wifiPane = inflatePane(R.layout.pane_wifi); + settingsPane = inflatePane(R.layout.pane_settings); logPane = inflatePane(R.layout.pane_log); panes = new View[] { vehiclePane, consumptionPane, chargingPane, servicePane, serverPane, - wifiPane, logPane }; + settingsPane, logPane }; bindVehiclePane(); bindConsumptionPane(); @@ -836,6 +837,14 @@ public class MainActivity extends AppCompatActivity { return false; } } + if (config.abrpApiKey != null || config.abrpToken != null) { + AbrpConfig abrp = AbrpConfig.get(this); + if (!saveAbrpConfig(config.abrpApiKey != null ? config.abrpApiKey : abrp.apiKey, + config.abrpToken != null ? config.abrpToken : abrp.userToken)) { + setConnectionStatus(COLOR_ERROR, getString(R.string.abrp_invalid)); + return false; + } + } SharedPreferences.Editor edit = prefs.edit(); if (config.intervalSec != null) edit.putInt(UploadSettings.KEY_INTERVAL_SEC, config.intervalSec); if (config.boostLowSoc != null) edit.putBoolean(UploadSettings.KEY_BOOST_LOW_SOC, config.boostLowSoc); @@ -850,11 +859,11 @@ public class MainActivity extends AppCompatActivity { // ---------- Upload page ---------- private void bindServicePane() { - voiceKeySwitch = servicePane.findViewById(R.id.voice_key_switch); + voiceKeySwitch = settingsPane.findViewById(R.id.voice_key_switch); voiceKeySwitch.setChecked(UploadSettings.voiceKeyOpensApp(prefs)); voiceKeySwitch.setOnCheckedChangeListener((btn, checked) -> prefs.edit().putBoolean(UploadSettings.KEY_VOICE_KEY_OPENS, checked).apply()); - fullScreenSwitch = servicePane.findViewById(R.id.full_screen_switch); + fullScreenSwitch = settingsPane.findViewById(R.id.full_screen_switch); fullScreenSwitch.setChecked(UploadSettings.fullScreen(prefs)); fullScreenSwitch.setOnCheckedChangeListener((btn, checked) -> { prefs.edit().putBoolean(UploadSettings.KEY_FULL_SCREEN, checked).apply(); @@ -871,6 +880,66 @@ public class MainActivity extends AppCompatActivity { refreshStatus(); }); bindCadenceControls(); + bindAbrpControls(); + } + + // ---------- ABRP, directly from the car ---------- + + private TextInputEditText abrpApiKeyInput, abrpTokenInput; + private TextView abrpStatusText; + + private void bindAbrpControls() { + abrpApiKeyInput = servicePane.findViewById(R.id.abrp_api_key_input); + abrpTokenInput = servicePane.findViewById(R.id.abrp_token_input); + abrpStatusText = servicePane.findViewById(R.id.abrp_status_text); + AbrpConfig current = AbrpConfig.get(this); + abrpApiKeyInput.setText(current.apiKey); + abrpTokenInput.setText(current.userToken); + servicePane.findViewById(R.id.abrp_save_button).setOnClickListener(v -> + saveAbrpConfig(text(abrpApiKeyInput), text(abrpTokenInput))); + servicePane.findViewById(R.id.abrp_test_button).setOnClickListener(v -> testAbrpConfig()); + } + + private static String text(TextInputEditText input) { + CharSequence raw = input.getText(); + return raw == null ? "" : raw.toString().trim(); + } + + /** Both set and plausible, or both empty (direct sending off). Returns whether it saved. */ + private boolean saveAbrpConfig(String apiKey, String token) { + boolean clear = apiKey.isEmpty() && token.isEmpty(); + if (!clear && !(AbrpConfig.isPlausible(apiKey) && AbrpConfig.isPlausible(token))) { + abrpStatusText.setText(R.string.abrp_invalid); + return false; + } + AbrpConfig.save(this, apiKey, token); + abrpApiKeyInput.setText(apiKey); + abrpTokenInput.setText(token); + abrpStatusText.setText(clear ? R.string.abrp_cleared : R.string.abrp_saved); + refreshStatus(); + return true; + } + + /** Read-only check against ABRP; never sends telemetry. Off the main thread. */ + private void testAbrpConfig() { + AbrpConfig config = new AbrpConfig(text(abrpApiKeyInput), text(abrpTokenInput)); + if (!config.isConfigured()) { + abrpStatusText.setText(R.string.abrp_not_set); + return; + } + abrpStatusText.setText(R.string.abrp_testing); + new Thread(() -> { + String message; + try { + ServerHttp.Response response = AbrpApi.verify(config); + message = response.ok() ? getString(R.string.abrp_test_ok) + : getString(R.string.abrp_test_failed, response.status); + } catch (Exception e) { + message = getString(R.string.abrp_test_offline, e.getClass().getSimpleName()); + } + String shown = message; + runOnUiThread(() -> abrpStatusText.setText(shown)); + }, "abrp-test").start(); } /** The service reads these settings on every tick, so a change applies without a restart. */ @@ -946,16 +1015,16 @@ public class MainActivity extends AppCompatActivity { private void bindWifiPane() { wifi = new WifiAutoConnect(getApplicationContext()); - wifiSsid = wifiPane.findViewById(R.id.wifi_ssid); - wifiState = wifiPane.findViewById(R.id.wifi_state); - wifiSavedList = wifiPane.findViewById(R.id.wifi_saved_list); + wifiSsid = settingsPane.findViewById(R.id.wifi_ssid); + wifiState = settingsPane.findViewById(R.id.wifi_state); + wifiSavedList = settingsPane.findViewById(R.id.wifi_saved_list); - SwitchMaterial auto = wifiPane.findViewById(R.id.wifi_auto_switch); + SwitchMaterial auto = settingsPane.findViewById(R.id.wifi_auto_switch); auto.setChecked(prefs.getBoolean(WIFI_AUTO_KEY, true)); auto.setOnCheckedChangeListener((btn, checked) -> prefs.edit().putBoolean(WIFI_AUTO_KEY, checked).apply()); - wifiPane.findViewById(R.id.wifi_connect_button).setOnClickListener(v -> { + settingsPane.findViewById(R.id.wifi_connect_button).setOnClickListener(v -> { // force: the driver is asking now, so the rate limit does not apply. WifiAutoConnect.Result r = wifi.ensureConnected(true); wifiState.setText(r.message); @@ -1099,16 +1168,16 @@ public class MainActivity extends AppCompatActivity { consumptionDateButton.setOnClickListener(v -> showConsumptionDatePicker()); consumptionPane.findViewById(R.id.consumption_refresh).setOnClickListener(v -> refreshFromCloud()); consumptionReset = consumptionPane.findViewById(R.id.consumption_reset); - consumptionSohLayout = consumptionPane.findViewById(R.id.consumption_soh_layout); - consumptionSohInput = consumptionPane.findViewById(R.id.consumption_soh_input); + consumptionSohLayout = settingsPane.findViewById(R.id.consumption_soh_layout); + consumptionSohInput = settingsPane.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()); - usableCapacityLayout = consumptionPane.findViewById(R.id.usable_capacity_layout); - usableCapacityInput = consumptionPane.findViewById(R.id.usable_capacity_input); + settingsPane.findViewById(R.id.consumption_soh_save).setOnClickListener(v -> saveSoh()); + usableCapacityLayout = settingsPane.findViewById(R.id.usable_capacity_layout); + usableCapacityInput = settingsPane.findViewById(R.id.usable_capacity_input); usableCapacityInput.setText(String.format(java.util.Locale.US, "%.1f", usableKwh())); - consumptionPane.findViewById(R.id.usable_capacity_save).setOnClickListener(v -> saveUsableCapacity()); + settingsPane.findViewById(R.id.usable_capacity_save).setOnClickListener(v -> saveUsableCapacity()); bindPeriodButton(R.id.consumption_lifetime, ConsumptionTracker.Period.LIFETIME); bindPeriodButton(R.id.consumption_week, ConsumptionTracker.Period.WEEK); bindPeriodButton(R.id.consumption_month, ConsumptionTracker.Period.MONTH); @@ -1192,11 +1261,11 @@ public class MainActivity extends AppCompatActivity { chargeTotalCost = chargingPane.findViewById(R.id.charge_total_cost); chargeGraph = chargingPane.findViewById(R.id.charge_graph); chargeCurveTable = chargingPane.findViewById(R.id.charge_curve_table); - chargePriceLayout = chargingPane.findViewById(R.id.charge_price_layout); - chargePriceInput = chargingPane.findViewById(R.id.charge_price_input); + chargePriceLayout = settingsPane.findViewById(R.id.charge_price_layout); + chargePriceInput = settingsPane.findViewById(R.id.charge_price_input); chargePriceInput.setText(String.format(java.util.Locale.US, "%.2f", chargeSessionTracker.pricePerKwh())); - chargingPane.findViewById(R.id.charge_price_save).setOnClickListener(v -> saveChargePrice()); + settingsPane.findViewById(R.id.charge_price_save).setOnClickListener(v -> saveChargePrice()); } private void saveChargePrice() { @@ -1446,9 +1515,13 @@ public class MainActivity extends AppCompatActivity { break; case RUNNING: case STARTING: - if (!ServerConfig.get(this).isConfigured()) { + boolean server = ServerConfig.get(this).isConfigured(); + if (!server && !AbrpConfig.get(this).isConfigured()) { statusText.setText(R.string.server_not_configured); statusText.setTextColor(COLOR_ERROR); + } else if (!server && UploadSettings.from(prefs).abrpUploadEnabled) { + statusText.setText(R.string.state_abrp_only); + statusText.setTextColor(COLOR_PENDING); } else if (!UploadSettings.from(prefs).abrpUploadEnabled) { statusText.setText(R.string.state_tracking_locally); statusText.setTextColor(COLOR_PENDING); diff --git a/app/src/main/java/com/evsuite/abrp/ServerHttp.java b/app/src/main/java/com/evsuite/abrp/ServerHttp.java index 1e9b9de..96d8526 100644 --- a/app/src/main/java/com/evsuite/abrp/ServerHttp.java +++ b/app/src/main/java/com/evsuite/abrp/ServerHttp.java @@ -10,7 +10,8 @@ import java.net.URL; import java.nio.charset.StandardCharsets; /** - * One JSON request to the car server, shared by telemetry, consumption sync and pairing. + * One JSON request to the car server, shared by telemetry, consumption sync and pairing (and, + * through {@link #send}, the direct ABRP upload). * Blocking, with short timeouts — call it off the main thread. * * The token goes in the Authorization header, never the URL, and is never logged. The answer @@ -48,17 +49,26 @@ final class ServerHttp { * {@code token} may be null for the unauthenticated pairing calls. */ static Response request(String method, String url, String token, JSONObject json) throws Exception { + byte[] body = json == null ? null : json.toString().getBytes(StandardCharsets.UTF_8); + return send(method, url, token == null ? null : "Bearer " + token, "application/json", body); + } + + /** + * The request underneath, also used for ABRP ({@link AbrpApi}): {@code authorization} is the + * whole header value or null, {@code body} null for none. + */ + static Response send(String method, String url, String authorization, String contentType, byte[] body) + throws Exception { HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection(); try { connection.setRequestMethod(method); connection.setConnectTimeout(TIMEOUT_MS); connection.setReadTimeout(TIMEOUT_MS); - if (token != null) connection.setRequestProperty("Authorization", "Bearer " + token); - if (json != null) { - byte[] body = json.toString().getBytes(StandardCharsets.UTF_8); + if (authorization != null) connection.setRequestProperty("Authorization", authorization); + if (body != null) { connection.setDoOutput(true); connection.setFixedLengthStreamingMode(body.length); - connection.setRequestProperty("Content-Type", "application/json"); + connection.setRequestProperty("Content-Type", contentType); try (OutputStream out = connection.getOutputStream()) { out.write(body); } } int status = connection.getResponseCode(); diff --git a/app/src/main/java/com/evsuite/abrp/TelemetryQueue.java b/app/src/main/java/com/evsuite/abrp/TelemetryQueue.java index 3ce0bf9..50883d7 100644 --- a/app/src/main/java/com/evsuite/abrp/TelemetryQueue.java +++ b/app/src/main/java/com/evsuite/abrp/TelemetryQueue.java @@ -52,6 +52,9 @@ final class TelemetryQueue { for (int i = 0; i < count && !samples.isEmpty(); i++) samples.removeFirst(); } + /** The newest sample, or null when the queue is empty; it stays queued. */ + synchronized String newest() { return samples.peekLast(); } + synchronized int size() { return samples.size(); } /** Samples lost to the capacity limit since the queue was created. */ diff --git a/app/src/main/java/com/evsuite/abrp/TelemetryUploader.java b/app/src/main/java/com/evsuite/abrp/TelemetryUploader.java index 6616910..ae7804d 100644 --- a/app/src/main/java/com/evsuite/abrp/TelemetryUploader.java +++ b/app/src/main/java/com/evsuite/abrp/TelemetryUploader.java @@ -152,9 +152,12 @@ final class TelemetryUploader { // ---------- Upload ---------- private void flush() { + // ABRP first and on its own: live data must not wait for, or fail with, the server. + sendNewestToAbrp(); ServerConfig config = ServerConfig.get(context); if (!config.isConfigured()) { - Log.i(TAG, "Telemetry not sent: server not configured (" + queue.size() + " queued)"); + // Kept for the server once one is set up; the queue is bounded. + Log.i(TAG, "Telemetry not sent to a server: none configured (" + queue.size() + " queued)"); return; } for (int i = 0; i < MAX_BATCHES_PER_TICK && queue.size() > 0; i++) { @@ -190,9 +193,53 @@ final class TelemetryUploader { JSONObject body = new JSONObject() .put("installationId", config.installationId) .put("samples", samples); + // What the car already delivered to ABRP itself; the server forwards only newer samples. + if (lastAbrpUtc > 0) body.put("abrpSentUtc", lastAbrpUtc); return ServerHttp.request("POST", config.url + "/v1/telemetry", config.token, body); } + // ---------- ABRP, directly ---------- + + /** Older than this, a sample is history rather than live state, and ABRP gets nothing. */ + private static final long ABRP_MAX_AGE_S = 5 * 60; + + /** Time of the newest sample ABRP accepted from the car; 0 before the first. */ + private volatile long lastAbrpUtc; + + /** + * Sends the newest queued sample straight to ABRP when ABRP credentials are set: once per + * upload, only if it is newer than the last one sent and still live. It stays queued for + * the server either way. + */ + private void sendNewestToAbrp() { + AbrpConfig abrp = AbrpConfig.get(context); + if (!abrp.isConfigured()) return; + String newest = queue.newest(); + if (newest == null) return; + long utc = utcOf(newest); + if (utc <= lastAbrpUtc || System.currentTimeMillis() / 1000 - utc > ABRP_MAX_AGE_S) return; + String summary = TelemetryPayload.summarize(newest); + try { + ServerHttp.Response response = AbrpApi.send(abrp, newest); + boolean ok = AbrpApi.accepted(response); + if (ok) lastAbrpUtc = utc; + // ABRP's answer is not quoted: it can echo the request, credentials included. + AbrpUploadService.log().record(new UploadLog.Entry(System.currentTimeMillis(), response.status, ok, + ok ? "ABRP: live data sent" : "ABRP rejected: HTTP " + response.status, summary)); + } catch (Exception e) { + AbrpUploadService.log().record(new UploadLog.Entry(System.currentTimeMillis(), 0, false, + "ABRP offline: " + e.getClass().getSimpleName(), summary)); + } + } + + static long utcOf(String sampleJson) { + try { + return new JSONObject(sampleJson).optLong("utc", 0L); + } catch (Exception e) { + return 0L; + } + } + /** ", 2 value(s) dropped by the server" when it left anything out, for the upload log. */ static String droppedNote(ServerHttp.Response response) { try { diff --git a/app/src/main/res/drawable/ic_tab_charging.xml b/app/src/main/res/drawable/ic_tab_charging.xml new file mode 100644 index 0000000..c331058 --- /dev/null +++ b/app/src/main/res/drawable/ic_tab_charging.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_tab_live.xml b/app/src/main/res/drawable/ic_tab_live.xml new file mode 100644 index 0000000..0db73a1 --- /dev/null +++ b/app/src/main/res/drawable/ic_tab_live.xml @@ -0,0 +1,17 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_tab_service.xml b/app/src/main/res/drawable/ic_tab_service.xml index 40bd181..14b498e 100644 --- a/app/src/main/res/drawable/ic_tab_service.xml +++ b/app/src/main/res/drawable/ic_tab_service.xml @@ -1,5 +1,5 @@ - + - - - - diff --git a/app/src/main/res/layout-w1000dp/activity_main.xml b/app/src/main/res/layout-w1000dp/activity_main.xml index bd06d3b..9f322f1 100644 --- a/app/src/main/res/layout-w1000dp/activity_main.xml +++ b/app/src/main/res/layout-w1000dp/activity_main.xml @@ -8,10 +8,10 @@ - - + + - + diff --git a/app/src/main/res/layout-w1000dp/pane_service.xml b/app/src/main/res/layout-w1000dp/pane_service.xml index 2ed63fd..8a580dd 100644 --- a/app/src/main/res/layout-w1000dp/pane_service.xml +++ b/app/src/main/res/layout-w1000dp/pane_service.xml @@ -1,25 +1,38 @@ - - - - - - - - - + + + + + + + + + + + + + + + + + + + + - - - - - - - - - + + + + + + + + + + + diff --git a/app/src/main/res/layout-w1000dp/pane_settings.xml b/app/src/main/res/layout-w1000dp/pane_settings.xml new file mode 100644 index 0000000..22cf8de --- /dev/null +++ b/app/src/main/res/layout-w1000dp/pane_settings.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout-w1000dp/pane_wifi.xml b/app/src/main/res/layout-w1000dp/pane_wifi.xml deleted file mode 100644 index c0461ea..0000000 --- a/app/src/main/res/layout-w1000dp/pane_wifi.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index a0ea4c8..27ba5ae 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -15,10 +15,10 @@ - - + + - + diff --git a/app/src/main/res/layout/pane_charging.xml b/app/src/main/res/layout/pane_charging.xml index e701971..5fab99e 100644 --- a/app/src/main/res/layout/pane_charging.xml +++ b/app/src/main/res/layout/pane_charging.xml @@ -15,13 +15,6 @@ - - - - - - - diff --git a/app/src/main/res/layout/pane_consumption.xml b/app/src/main/res/layout/pane_consumption.xml index 93891bb..791c8fb 100644 --- a/app/src/main/res/layout/pane_consumption.xml +++ b/app/src/main/res/layout/pane_consumption.xml @@ -26,19 +26,6 @@ - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/pane_service.xml b/app/src/main/res/layout/pane_service.xml index fbe693c..8904c1c 100644 --- a/app/src/main/res/layout/pane_service.xml +++ b/app/src/main/res/layout/pane_service.xml @@ -6,13 +6,21 @@ - - - - - - + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/pane_settings.xml b/app/src/main/res/layout/pane_settings.xml new file mode 100644 index 0000000..155fcab --- /dev/null +++ b/app/src/main/res/layout/pane_settings.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/pane_wifi.xml b/app/src/main/res/layout/pane_wifi.xml deleted file mode 100644 index 67796f6..0000000 --- a/app/src/main/res/layout/pane_wifi.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 26b8b1c..6056cf0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2,7 +2,7 @@ Nils\' MG4 - Upload + Live data Overview Consumption Charging @@ -11,17 +11,17 @@ Charging Live battery values and SOC/power curve Server - Wi-Fi + Settings Logs - Wi-Fi Connection + WI-FI Connected network Status Connect Now Auto-connect in the background While the upload service is running, it tries to reconnect to saved networks on its own when the connection drops. - Saved Networks + SAVED NETWORKS Could not read saved networks Not connected @@ -121,8 +121,19 @@ Steering-wheel keys The last key reports the car sent, newest first. If double-pressing the voice key does not open the app, a photo of this list shows how the car reports it. No key reports received yet. Press a steering-wheel key. - Send live telemetry to the server - The server stores it and forwards it to ABRP. Turn this off when another app feeds ABRP; consumption and charging tracking keep running. + Send live data + To your server, which stores it, and to ABRP: straight from the car when the ABRP credentials below are set, otherwise forwarded by the server. Consumption and charging tracking keep running when this is off. + With your ABRP API key and user token here, the car sends live data to ABRP itself, so ABRP keeps getting it while your server is down. The server then forwards only what the car could not send. Leave both empty to let the server forward everything. + ABRP API key + ABRP user token + Saved: the car sends live data to ABRP directly + Cleared: the server forwards live data to ABRP + Enter both, without spaces, or leave both empty + Checking with ABRP… + ABRP accepted these credentials + ABRP refused: HTTP %1$d + ABRP not reachable: %1$s + Set both first @@ -143,6 +154,7 @@ Stopped Starting… Running + Running — live data to ABRP only, no server set up Tracking locally — live telemetry off Tracking locally — live telemetry off In error — last %1$d uploads failed diff --git a/app/src/main/res/values/strings_design.xml b/app/src/main/res/values/strings_design.xml index dbadaea..d21a79c 100644 --- a/app/src/main/res/values/strings_design.xml +++ b/app/src/main/res/values/strings_design.xml @@ -9,10 +9,10 @@ Data from your car, all in one place. Where your data goes. Your own server stores it and passes it on to ABRP. - You focus on the road. - Your data stays up to date in the background. - Stay connected. - The internet connection for your journey. + Live data. + Where your car\'s live data goes, and how often. + Settings. + Display, battery, charging price and Wi-Fi. Every upload, right here. Status and details of recent uploads. BATTERY @@ -22,7 +22,11 @@ Your private connection. The token lets the car write to your server. It is stored encrypted on this device; your ABRP credentials stay on the server. Values that cannot be read are shown as —. - UPLOAD CONTROL + LIVE DATA + ABRP, DIRECTLY FROM THE CAR + DISPLAY AND STEERING WHEEL + BATTERY + CHARGING PRICE UPDATE FREQUENCY Tire pressure positions on the vehicle
    diff --git a/app/src/test/java/com/evsuite/abrp/AbrpApiTest.java b/app/src/test/java/com/evsuite/abrp/AbrpApiTest.java new file mode 100644 index 0000000..e63fd0e --- /dev/null +++ b/app/src/test/java/com/evsuite/abrp/AbrpApiTest.java @@ -0,0 +1,40 @@ +package com.evsuite.abrp; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +/** The direct ABRP request and its answer — pure JVM, nothing is sent. */ +public class AbrpApiTest { + + @Test + public void credentialsGoInTheBodyEncoded() throws Exception { + String body = AbrpApi.formBody("key+1", "tok&en", "{\"utc\":1790000000,\"soc\":80}"); + assertEquals("api_key=key%2B1&token=tok%26en&tlm=%7B%22utc%22%3A1790000000%2C%22soc%22%3A80%7D", body); + assertFalse(AbrpApi.SEND_URL.contains("?")); + } + + @Test + public void onlyAnOkStatusIsAccepted() { + assertTrue(AbrpApi.accepted(new ServerHttp.Response(200, "{\"status\":\"ok\"}"))); + assertFalse(AbrpApi.accepted(new ServerHttp.Response(200, "{\"status\":\"error\",\"errors\":[\"bad token\"]}"))); + assertFalse(AbrpApi.accepted(new ServerHttp.Response(401, "{\"status\":\"ok\"}"))); + assertFalse(AbrpApi.accepted(new ServerHttp.Response(200, "not json"))); + } + + @Test + public void credentialsMustLookLikeCredentials() { + assertTrue(AbrpConfig.isPlausible("0a1b2c3d-4e5f-6789")); + assertFalse(AbrpConfig.isPlausible("short")); + assertFalse(AbrpConfig.isPlausible("has a space in it")); + assertFalse(AbrpConfig.isPlausible(null)); + } + + @Test + public void theSampleTimeIsReadFromItsJson() { + assertEquals(1790000000L, TelemetryUploader.utcOf("{\"utc\":1790000000,\"soc\":5}")); + assertEquals(0L, TelemetryUploader.utcOf("garbage")); + } +} diff --git a/app/src/test/java/com/evsuite/abrp/ConfigImportTest.java b/app/src/test/java/com/evsuite/abrp/ConfigImportTest.java index bde9e65..5144111 100644 --- a/app/src/test/java/com/evsuite/abrp/ConfigImportTest.java +++ b/app/src/test/java/com/evsuite/abrp/ConfigImportTest.java @@ -87,4 +87,13 @@ public class ConfigImportTest { assertEquals("https://k.example", c.serverUrl); assertEquals("t", c.token); } + + @Test + public void abrpCredentialsAreRead() { + ConfigImport c = ConfigImport.parse("abrp_api_key = key-1234567890\nABRP_TOKEN = token-abcdefghij\n"); + assertEquals("key-1234567890", c.abrpApiKey); + assertEquals("token-abcdefghij", c.abrpToken); + assertFalse(c.isEmpty()); + assertEquals("token-2", ConfigImport.parse("abrp_user_token = token-2").abrpToken); + } } diff --git a/k8s/secrets/secrets.example.yaml b/k8s/secrets/secrets.example.yaml index 3064ca0..ee2572e 100644 --- a/k8s/secrets/secrets.example.yaml +++ b/k8s/secrets/secrets.example.yaml @@ -26,7 +26,8 @@ stringData: # Approves a car on your phone when it pairs by QR code (at least 12 characters). Leave it # empty to switch pairing off; then only the INGEST_TOKEN above works. APPROVAL_PASSWORD: CHANGE-ME-at-least-12-characters - # Your ABRP credentials. Leave either empty to switch forwarding off. + # Your ABRP credentials. Leave either empty to switch forwarding off. Keep them set even + # when the app sends to ABRP itself: the server then forwards only what the car could not. ABRP_API_KEY: CHANGE-ME ABRP_USER_TOKEN: CHANGE-ME ABRP_MIN_INTERVAL_SECONDS: "5" diff --git a/k8s/server.yaml b/k8s/server.yaml index 5cb7dc3..a3b2c40 100644 --- a/k8s/server.yaml +++ b/k8s/server.yaml @@ -29,8 +29,8 @@ spec: type: RuntimeDefault containers: - name: server - # 2.2.0, pinned by digest so a re-pushed tag cannot change what runs. - image: git.stinnesbeck.com/nils/mg4-server@sha256:09eb151ff65300b3990a74063cc44a14d24bef810461e4e0e63451cfbcfd8258 + # 2.3.0, pinned by digest so a re-pushed tag cannot change what runs. + image: git.stinnesbeck.com/nils/mg4-server@sha256:dc5eb49cd88a1fd6a8e853c6e2291f2b5d62252e8bb6daf1750e1c3698bc7e0a ports: - name: http containerPort: 8080 diff --git a/server/README.md b/server/README.md index 0f565f3..c8dce01 100644 --- a/server/README.md +++ b/server/README.md @@ -40,7 +40,9 @@ with (403 otherwise); the shared token works for any. a plausible time (before 2001, or more than an hour ahead) is dropped whole. The rest is stored and the answer is `200 {stored, droppedCount, dropped}`, even when nothing was usable, since the car would only send it again. The newest sample is forwarded to ABRP if it is under - 5 minutes old and not ahead of the server's clock. + 5 minutes old and not ahead of the server's clock. A car that sends to ABRP itself adds + `abrpSentUtc`, the newest sample ABRP took from it; only samples newer than that are + forwarded, so ABRP gets each one once. - `PUT /v1/consumption/daily` — one delta chunk of a day's consumption. - `PUT /v1/charging-sessions/{sessionId}` — a finished DC session with its curve. - `GET /v1/consumption/history/{installationId}` — the car reads its own totals back. diff --git a/server/api.go b/server/api.go index 7b7bb83..b1208e1 100644 --- a/server/api.go +++ b/server/api.go @@ -117,6 +117,20 @@ func (a *API) ingest(h handler) http.HandlerFunc { } } +// notYetAtAbrp leaves out the samples the car already sent to ABRP itself. +func notYetAtAbrp(samples []TelemetrySample, sentUTC int64) []TelemetrySample { + if sentUTC == 0 { + return samples + } + out := make([]TelemetrySample, 0, len(samples)) + for _, s := range samples { + if s.UTC > sentUTC { + out = append(out, s) + } + } + return out +} + // maxDroppedListed caps the dropped values named in a telemetry response and log line. const maxDroppedListed = 20 @@ -136,7 +150,7 @@ func (a *API) telemetry(w http.ResponseWriter, r *http.Request, c caller, body [ if err := a.Store.WriteTelemetry(r.Context(), batch.InstallationID, batch.Samples); err != nil { return err } - a.Forwarder.Forward(batch.Samples) + a.Forwarder.Forward(notYetAtAbrp(batch.Samples, batch.AbrpSentUTC)) } dropped := batch.Dropped if len(dropped) > maxDroppedListed { diff --git a/server/api_test.go b/server/api_test.go index 4a69fa5..5b6d0b5 100644 --- a/server/api_test.go +++ b/server/api_test.go @@ -204,3 +204,23 @@ func jsonInt(v int64) string { b, _ := json.Marshal(v) return string(b) } + +func TestSamplesTheCarSentToAbrpAreNotForwardedAgain(t *testing.T) { + samples := []TelemetrySample{{UTC: 100}, {UTC: 115}, {UTC: 130}} + if got := notYetAtAbrp(samples, 0); len(got) != 3 { + t.Fatalf("without abrpSentUtc everything is forwardable: %d", len(got)) + } + if got := notYetAtAbrp(samples, 130); len(got) != 0 { + t.Fatalf("the car sent the newest: %d left", len(got)) + } + if got := notYetAtAbrp(samples, 115); len(got) != 1 || got[0].UTC != 130 { + t.Fatalf("only the newer sample: %+v", got) + } + b, err := validateTelemetryBatch([]byte(`{"installationId":"`+testInstallation+`","abrpSentUtc":1790000000,"samples":[{"utc":1790000000}]}`), testNow) + if err != nil || b.AbrpSentUTC != 1790000000 { + t.Fatalf("abrpSentUtc not read: %v %d", err, b.AbrpSentUTC) + } + if _, err := validateTelemetryBatch([]byte(`{"installationId":"`+testInstallation+`","abrpSentUtc":"x","samples":[{"utc":1790000000}]}`), testNow); !isValidationError(err) { + t.Fatal("a malformed abrpSentUtc was accepted") + } +} diff --git a/server/model.go b/server/model.go index b121c87..ca94b25 100644 --- a/server/model.go +++ b/server/model.go @@ -107,4 +107,7 @@ type TelemetryBatch struct { Samples []TelemetrySample // Dropped names the values and samples validation left out, e.g. "samples[3].speed". Dropped []string + // AbrpSentUTC is the newest sample the car delivered to ABRP itself (0: none). The + // server forwards only samples newer than that, so ABRP gets each one once. + AbrpSentUTC int64 } diff --git a/server/validate.go b/server/validate.go index c82cbe3..4e0cc97 100644 --- a/server/validate.go +++ b/server/validate.go @@ -273,6 +273,11 @@ func validateTelemetryBatch(body []byte, now time.Time) (TelemetryBatch, error) return TelemetryBatch{}, invalid("samples is invalid") } batch := TelemetryBatch{InstallationID: id, Samples: make([]TelemetrySample, 0, len(raw))} + if o.has("abrpSentUtc") { + if batch.AbrpSentUTC, err = integer(o["abrpSentUtc"], "abrpSentUtc"); err != nil { + return TelemetryBatch{}, err + } + } latest := now.Add(maxClockAhead).Unix() for i, entry := range raw { name := fmt.Sprintf("samples[%d]", i) From 9f184362f02c025c5c0e990083481562d1e09c2e Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Sun, 18 Oct 2026 12:49:46 +0200 Subject: [PATCH 51/79] Server 2.4.0, app 2.11.1: ABRP forwarding removed from the server The car sends live data to ABRP itself, and the car and the server go over the same mobile connection: when the car cannot reach ABRP, it cannot reach the server either, so the server's fallback bought nothing. The forwarder, its ABRP_* settings and abrpSentUtc are gone; the server only stores. The app stops sending abrpSentUtc (the server ignores unknown fields, so 2.11.0 keeps working) and its texts no longer promise a server fallback. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki --- README.md | 27 ++-- SPECS.md | 18 ++- app/build.gradle | 4 +- .../java/com/evsuite/abrp/AbrpConfig.java | 5 +- .../com/evsuite/abrp/AbrpUploadService.java | 4 +- .../com/evsuite/abrp/TelemetryUploader.java | 6 +- app/src/main/res/values/strings.xml | 6 +- k8s/README.md | 2 +- k8s/secrets/secrets.example.yaml | 5 - k8s/server.yaml | 11 +- server/README.md | 17 +-- server/abrp.go | 142 ------------------ server/api.go | 24 +-- server/api_test.go | 22 +-- server/logic_test.go | 68 +-------- server/main.go | 60 +++----- server/model.go | 3 - server/validate.go | 5 - 18 files changed, 70 insertions(+), 359 deletions(-) delete mode 100644 server/abrp.go diff --git a/README.md b/README.md index ab1bdb4..144a6ea 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ # Nils' MG4 An Android Automotive app for the **MG4 (SAIC eh32)** head unit, plus a small server: the car -sends its telemetry to my own server at car.k8s.stinnesbeck.com, which stores it in InfluxDB 2, -forwards live data to [A Better Route Planner](https://abetterrouteplanner.com) and feeds the -Grafana dashboards. +sends its telemetry to my own server at car.k8s.stinnesbeck.com, which stores it in InfluxDB 2 +for the Grafana dashboards, and its live data straight to +[A Better Route Planner](https://abetterrouteplanner.com). No OBD dongle and no phone in the loop: the app runs on the head unit and reads the car's own APIs. @@ -47,18 +47,16 @@ vehicle there, so every value shows as a dash). ## How it fits together ``` -car ──live telemetry───▶ car.k8s.stinnesbeck.com (Go) ──▶ ABRP (what the car did not send) +car ──live telemetry───▶ car.k8s.stinnesbeck.com (Go) │ ──daily chunks─────▶ │ │ ──charging sessions▶ ▼ │ InfluxDB 2 ◀── Grafana - └──live telemetry (optional, direct)──────────────────▶ ABRP + └──live telemetry─────▶ ABRP ``` -With ABRP credentials set in the app (Live data tab), the car sends live data to ABRP itself, -so ABRP keeps getting it while the server is down; the server then forwards only samples the -car could not deliver. Without them in the app, the server forwards everything with its own -ABRP credentials. The car authenticates with the server using a token of its own, obtained by -pairing with a phone (below). +The car sends live data to ABRP itself, with the ABRP credentials set on the Live data tab, so +ABRP and the server do not depend on each other. The server only stores. The car authenticates +with the server using a token of its own, obtained by pairing with a phone (below). ## The app @@ -118,8 +116,7 @@ pointed at this repository's releases (see `SPECS.md`). 3. Tap **Pair with phone**. Scan the QR code with a phone (or open the address shown and enter the code), and approve with the server's approval password. The car receives its token. 4. On the **Live data** tab, turn live data on. The service starts with the car from then on. -5. Optional: enter your ABRP API key and user token on the same tab, so the car also sends - live data straight to ABRP. +5. Enter your ABRP API key and user token on the same tab, so the car sends live data to ABRP. ### Config file @@ -141,8 +138,8 @@ installation_id = only to carry over history from an install older than 2.9.0 interval_sec = 60 # 15 / 30 / 60 / 120 / 300 boost_low_soc = true low_soc_percent = 20 -abrp_api_key = optional: your ABRP API key, to send to ABRP directly -abrp_token = optional: your ABRP user token +abrp_api_key = your ABRP API key +abrp_token = your ABRP user token ``` After a successful import the app deletes the file, since it holds the token; credentials live @@ -182,7 +179,7 @@ work for the plain Gradle builds. ## Server and dashboards - **Server** ([`server/`](server/README.md)): Go, API only. Live telemetry, daily consumption - chunks, charging sessions, pairing; forwards live data to ABRP. Image at + chunks, charging sessions, pairing. Image at `git.stinnesbeck.com/nils/mg4-server`. - **Kubernetes** ([`k8s/`](k8s/README.md)): plain manifests for the server, InfluxDB 2 and Grafana, applied by hand with `kubectl apply -k k8s/`. Secrets go in the gitignored diff --git a/SPECS.md b/SPECS.md index 82eff48..b5ca00b 100644 --- a/SPECS.md +++ b/SPECS.md @@ -9,15 +9,16 @@ what has been changed, and what is still to do. ## Architecture: car server at car.k8s.stinnesbeck.com -The car sends everything to our own server. The server stores it in InfluxDB 2 and forwards live -telemetry to ABRP, so the ABRP token lives only on the server, never in the APK. Grafana reads -InfluxDB directly for the dashboards. +The car sends everything to our own server, which stores it in InfluxDB 2, and its live data +straight to ABRP with the ABRP credentials kept (encrypted) in the app. The server does not talk +to ABRP. Grafana reads InfluxDB directly for the dashboards. ``` -car ──live telemetry───▶ car.k8s.stinnesbeck.com (Go) ──▶ ABRP api.iternio.com/1/tlm/send - ──daily chunks─────▶ │ - ──charging sessions▶ ▼ - InfluxDB 2 ◀── Grafana (car-grafana.k8s.stinnesbeck.com) +car ──live telemetry───▶ car.k8s.stinnesbeck.com (Go) + │ ──daily chunks─────▶ │ + │ ──charging sessions▶ ▼ + │ InfluxDB 2 ◀── Grafana (car-grafana.k8s.stinnesbeck.com) + └──live telemetry─────▶ ABRP api.iternio.com/1/tlm/send ``` - **Server:** `server/`, Go 1.26, API only — see `server/README.md` for endpoints, configuration @@ -112,6 +113,9 @@ tyre pressures, GPS (lat, lon, elevation, heading) and the is_* flags. It does * INGEST_TOKEN) into git and pushed it. Commit amended and force-pushed; both values regenerated in the now gitignored `k8s/secrets.yaml` (since moved to `k8s/secrets/secrets.yaml`, outside `kubectl apply -f k8s/`). Filled-in secrets live only there from now on. +- 2026-10-18 — App 2.11.1, server 2.4.0: ABRP forwarding removed from the server. The car and the + server use the same mobile connection, so a server fallback for ABRP bought nothing. The car no + longer sends `abrpSentUtc`; the server ignores unknown fields, so older apps keep working. - 2026-10-18 — App 2.11.0, server 2.3.0: - Live data goes to ABRP straight from the car when ABRP credentials are set in the app (Live data tab or config file: `abrp_api_key`, `abrp_token`), so ABRP keeps getting it diff --git a/app/build.gradle b/app/build.gradle index 61914b1..5cb6663 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -34,8 +34,8 @@ android { applicationId "com.stinnesbeck.nilsmg4" minSdk 27 targetSdk 36 - versionCode 73 - versionName "2.11.0" + versionCode 74 + versionName "2.11.1" // Nothing about the server ships in the APK: its URL and ingest token are entered in // the app's Server tab (ServerConfig), and the database is only reachable by the server. diff --git a/app/src/main/java/com/evsuite/abrp/AbrpConfig.java b/app/src/main/java/com/evsuite/abrp/AbrpConfig.java index 6ffee1b..5d20a5d 100644 --- a/app/src/main/java/com/evsuite/abrp/AbrpConfig.java +++ b/app/src/main/java/com/evsuite/abrp/AbrpConfig.java @@ -8,9 +8,8 @@ import android.content.SharedPreferences; * sending) and the user token (which car in which ABRP account). Kept in {@link SecurePrefs} * next to the server settings, entered on the Live data tab or imported from a config file. * - * With both set, the car sends to ABRP itself, so ABRP keeps getting live data when the car - * server is down; the server then only forwards what the car could not deliver. Without them, - * the server forwards as before. + * The car is the only one that sends to ABRP: the server stores live data but does not forward + * it. Without these, ABRP gets no live data. */ final class AbrpConfig { diff --git a/app/src/main/java/com/evsuite/abrp/AbrpUploadService.java b/app/src/main/java/com/evsuite/abrp/AbrpUploadService.java index c30a27b..5b3e351 100644 --- a/app/src/main/java/com/evsuite/abrp/AbrpUploadService.java +++ b/app/src/main/java/com/evsuite/abrp/AbrpUploadService.java @@ -25,8 +25,8 @@ import java.util.concurrent.TimeUnit; /** * Foreground service that polls vehicle telemetry every 15 s, integrates consumption locally, * syncs it to the car server in 2-minute delta chunks, and sends live telemetry samples that - * the server stores and forwards to ABRP ({@link TelemetryUploader}). The car itself never - * talks to ABRP and holds no ABRP credentials. + * the server stores and, with ABRP credentials set, that go straight to ABRP + * ({@link TelemetryUploader}). * * Always-on by design: started at boot (BootReceiver) and on app open, never gated on * credentials or a toggle. The only thing it needs is a network, and a missing one is diff --git a/app/src/main/java/com/evsuite/abrp/TelemetryUploader.java b/app/src/main/java/com/evsuite/abrp/TelemetryUploader.java index ae7804d..8fbd144 100644 --- a/app/src/main/java/com/evsuite/abrp/TelemetryUploader.java +++ b/app/src/main/java/com/evsuite/abrp/TelemetryUploader.java @@ -19,7 +19,7 @@ import java.util.List; import java.util.Objects; /** - * Live telemetry to the car server, which stores it and forwards the newest sample to ABRP. + * Live telemetry to the car server, which stores it, and the newest sample straight to ABRP. * * Called on every service tick. While the car is in use every tick becomes a sample; the * queue is sent when {@link UploadCadence} says so — every tick on a state change, otherwise @@ -193,8 +193,6 @@ final class TelemetryUploader { JSONObject body = new JSONObject() .put("installationId", config.installationId) .put("samples", samples); - // What the car already delivered to ABRP itself; the server forwards only newer samples. - if (lastAbrpUtc > 0) body.put("abrpSentUtc", lastAbrpUtc); return ServerHttp.request("POST", config.url + "/v1/telemetry", config.token, body); } @@ -209,7 +207,7 @@ final class TelemetryUploader { /** * Sends the newest queued sample straight to ABRP when ABRP credentials are set: once per * upload, only if it is newer than the last one sent and still live. It stays queued for - * the server either way. + * the server either way; the server does not forward to ABRP. */ private void sendNewestToAbrp() { AbrpConfig abrp = AbrpConfig.get(context); diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6056cf0..e365d13 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -122,12 +122,12 @@ The last key reports the car sent, newest first. If double-pressing the voice key does not open the app, a photo of this list shows how the car reports it. No key reports received yet. Press a steering-wheel key. Send live data - To your server, which stores it, and to ABRP: straight from the car when the ABRP credentials below are set, otherwise forwarded by the server. Consumption and charging tracking keep running when this is off. - With your ABRP API key and user token here, the car sends live data to ABRP itself, so ABRP keeps getting it while your server is down. The server then forwards only what the car could not send. Leave both empty to let the server forward everything. + To your server, which stores it, and straight to ABRP when the ABRP credentials below are set. Consumption and charging tracking keep running when this is off. + The car sends live data to ABRP itself with your ABRP API key and user token. Without them, ABRP gets no live data; your server only stores it. ABRP API key ABRP user token Saved: the car sends live data to ABRP directly - Cleared: the server forwards live data to ABRP + Cleared: ABRP gets no live data Enter both, without spaces, or leave both empty Checking with ABRP… ABRP accepted these credentials diff --git a/k8s/README.md b/k8s/README.md index a2f694e..15c87ed 100644 --- a/k8s/README.md +++ b/k8s/README.md @@ -22,7 +22,7 @@ is applied automatically. Apply them yourself. covered by Traefik's default certificate. 2. **Create the Secrets.** `cp k8s/secrets/secrets.example.yaml k8s/secrets/secrets.yaml` (gitignored), replace every `CHANGE-ME` there (`openssl rand -hex 32`): `influxdb-auth`, `mg4-server` (ingest token, - approval password for pairing, your ABRP API key and user token), `grafana-admin` and + approval password for pairing), `grafana-admin` and `grafana-influx` (see below). Keep the template itself on placeholders only. 3. **Registry access.** The `mg4-server` package on git.stinnesbeck.com is public, so no pull secret is needed. If you make it private, add an `imagePullSecrets` entry to `server.yaml`. diff --git a/k8s/secrets/secrets.example.yaml b/k8s/secrets/secrets.example.yaml index ee2572e..9a9e853 100644 --- a/k8s/secrets/secrets.example.yaml +++ b/k8s/secrets/secrets.example.yaml @@ -26,11 +26,6 @@ stringData: # Approves a car on your phone when it pairs by QR code (at least 12 characters). Leave it # empty to switch pairing off; then only the INGEST_TOKEN above works. APPROVAL_PASSWORD: CHANGE-ME-at-least-12-characters - # Your ABRP credentials. Leave either empty to switch forwarding off. Keep them set even - # when the app sends to ABRP itself: the server then forwards only what the car could not. - ABRP_API_KEY: CHANGE-ME - ABRP_USER_TOKEN: CHANGE-ME - ABRP_MIN_INTERVAL_SECONDS: "5" --- apiVersion: v1 kind: Secret diff --git a/k8s/server.yaml b/k8s/server.yaml index a3b2c40..aaf17df 100644 --- a/k8s/server.yaml +++ b/k8s/server.yaml @@ -1,5 +1,5 @@ -# The car server (Go). One replica on purpose: the ABRP rate limit is kept in memory, and -# two replicas would each forward to ABRP at the full rate. +# The car server (Go). One replica on purpose: pending pairing requests and the wrong-password +# counters are kept in memory. apiVersion: apps/v1 kind: Deployment metadata: @@ -29,8 +29,8 @@ spec: type: RuntimeDefault containers: - name: server - # 2.3.0, pinned by digest so a re-pushed tag cannot change what runs. - image: git.stinnesbeck.com/nils/mg4-server@sha256:dc5eb49cd88a1fd6a8e853c6e2291f2b5d62252e8bb6daf1750e1c3698bc7e0a + # 2.4.0, pinned by digest so a re-pushed tag cannot change what runs. + image: git.stinnesbeck.com/nils/mg4-server@sha256:ab243f37812efa7294efc594f65ca65f9d551161a85e7cc641afb168e226a2fe ports: - name: http containerPort: 8080 @@ -49,8 +49,7 @@ spec: secretKeyRef: name: influxdb-auth key: admin-token - # INGEST_TOKEN, APPROVAL_PASSWORD, ABRP_API_KEY, ABRP_USER_TOKEN, - # ABRP_MIN_INTERVAL_SECONDS. + # INGEST_TOKEN, APPROVAL_PASSWORD. envFrom: - secretRef: name: mg4-server diff --git a/server/README.md b/server/README.md index c8dce01..b6fa69f 100644 --- a/server/README.md +++ b/server/README.md @@ -1,11 +1,11 @@ # Nils' MG4 — car server -A small Go service at car.k8s.stinnesbeck.com: the API the car writes to, and the ABRP -forwarder. Data lives in InfluxDB 2; Grafana reads it from there for the dashboards -(`../k8s/grafana/`). +A small Go service at car.k8s.stinnesbeck.com: the API the car writes to. Data lives in +InfluxDB 2; Grafana reads it from there for the dashboards (`../k8s/grafana/`). The car sends +its live data to ABRP itself; the server does not talk to ABRP (removed in 2.4.0). ``` -car ──live telemetry───▶ server ──▶ ABRP api.iternio.com/1/tlm/send +car ──live telemetry───▶ server ──daily chunks─────▶ │ ──charging sessions▶ ▼ InfluxDB 2 ◀── Grafana @@ -20,14 +20,10 @@ Environment only; the server refuses to start when a required value is missing. | `INFLUX_URL`, `INFLUX_TOKEN`, `INFLUX_ORG` | required; the token needs read/write on the bucket | | `INFLUX_BUCKET` | default `mg4` | | `INGEST_TOKEN` | required, at least 24 characters: `openssl rand -hex 32` | -| `ABRP_API_KEY`, `ABRP_USER_TOKEN` | optional; both needed to forward | -| `ABRP_MIN_INTERVAL_SECONDS` | default 5 | | `APPROVAL_PASSWORD` | optional, at least 12 characters; switches pairing on (see below) | | `PUBLIC_URL` | the address a phone uses, for the pairing QR code; derived from the request if empty | | `LISTEN_ADDR` | default `:8080` | -The ABRP credentials never reach the car. - ## Endpoints The ingest endpoints accept `Authorization: Bearer ` with either the shared `INGEST_TOKEN` @@ -39,10 +35,7 @@ with (403 otherwise); the shared token works for any. dropped, known ones bounded: a value out of range is dropped on its own, and a sample without a plausible time (before 2001, or more than an hour ahead) is dropped whole. The rest is stored and the answer is `200 {stored, droppedCount, dropped}`, even when nothing was usable, since - the car would only send it again. The newest sample is forwarded to ABRP if it is under - 5 minutes old and not ahead of the server's clock. A car that sends to ABRP itself adds - `abrpSentUtc`, the newest sample ABRP took from it; only samples newer than that are - forwarded, so ABRP gets each one once. + the car would only send it again. - `PUT /v1/consumption/daily` — one delta chunk of a day's consumption. - `PUT /v1/charging-sessions/{sessionId}` — a finished DC session with its curve. - `GET /v1/consumption/history/{installationId}` — the car reads its own totals back. diff --git a/server/abrp.go b/server/abrp.go deleted file mode 100644 index 50df2d6..0000000 --- a/server/abrp.go +++ /dev/null @@ -1,142 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "fmt" - "log/slog" - "net/http" - "net/url" - "strings" - "sync" - "time" -) - -// Forwarder sends live telemetry on to A Better Route Planner. -// -// The ABRP credentials exist only here, from ABRP_API_KEY and ABRP_USER_TOKEN in the -// server's environment; the car never holds them. Forwarding is off when either is empty. -// -// Only the newest sample of a batch is sent, and at most one per MinInterval. A failed -// forward is logged and dropped, never queued: ABRP plans from the car's state now, and a -// backlog replayed later would describe a car that has moved on. The stored copy in -// InfluxDB is unaffected either way. -type Forwarder struct { - APIKey string - UserToken string - MinInterval time.Duration - Endpoint string - Client *http.Client - - mu sync.Mutex - lastUTC int64 - lastSentAt time.Time -} - -const ( - abrpEndpoint = "https://api.iternio.com/1/tlm/send" - // Older than this, a sample is history rather than live state, and ABRP gets nothing. - abrpMaxAge = 5 * time.Minute - // Newer than now by more than this, a sample's clock is wrong, and ABRP gets nothing. - abrpMaxAhead = 30 * time.Second -) - -func (f *Forwarder) configured() bool { - return f != nil && f.APIKey != "" && f.UserToken != "" -} - -// pick chooses the sample to forward, or false when there is nothing new or it is too -// soon. It records the choice, so two batches arriving together cannot both forward. -func (f *Forwarder) pick(samples []TelemetrySample, now time.Time) (TelemetrySample, bool) { - // A sample stamped ahead of now has a wrong clock. Taking it would also set lastUTC into - // the future, and every real sample after it would look older and never be sent. - latest := now.Add(abrpMaxAhead).Unix() - var newest TelemetrySample - found := false - for _, s := range samples { - if s.UTC <= latest && (!found || s.UTC > newest.UTC) { - newest, found = s, true - } - } - if !found || now.Sub(time.Unix(newest.UTC, 0)) > abrpMaxAge { - return TelemetrySample{}, false - } - f.mu.Lock() - defer f.mu.Unlock() - if f.lastUTC != 0 && newest.UTC <= f.lastUTC { - return TelemetrySample{}, false - } - if !f.lastSentAt.IsZero() && now.Sub(f.lastSentAt) < f.MinInterval { - return TelemetrySample{}, false - } - f.lastUTC = newest.UTC - f.lastSentAt = now - return newest, true -} - -// Forward is fire-and-forget: the car's request is answered without waiting for ABRP. -func (f *Forwarder) Forward(samples []TelemetrySample) { - if !f.configured() { - return - } - sample, ok := f.pick(samples, time.Now()) - if !ok { - return - } - go func() { - if err := f.send(context.Background(), sample); err != nil { - slog.Warn("ABRP forward failed", "error", err) - } - }() -} - -// tlm is the object ABRP expects: the same field names, flags as 0/1. -func tlm(sample TelemetrySample) map[string]any { - out := map[string]any{"utc": sample.UTC} - for field, value := range sample.Numbers { - out[field] = value - } - for field, value := range sample.Flags { - if value { - out[field] = 1 - } else { - out[field] = 0 - } - } - return out -} - -func (f *Forwarder) send(ctx context.Context, sample TelemetrySample) error { - payload, err := json.Marshal(tlm(sample)) - if err != nil { - return err - } - // Credentials go in the form body, never the URL, which ends up in access logs. - form := url.Values{"api_key": {f.APIKey}, "token": {f.UserToken}, "tlm": {string(payload)}} - endpoint := f.Endpoint - if endpoint == "" { - endpoint = abrpEndpoint - } - ctx, cancel := context.WithTimeout(ctx, 8*time.Second) - defer cancel() - req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode())) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - client := f.Client - if client == nil { - client = http.DefaultClient - } - resp, err := client.Do(req) - if err != nil { - // A url.Error from the client quotes the URL, which holds no credentials. - return err - } - defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - // The response body can echo the request; only the status is reported. - return fmt.Errorf("rejected: HTTP %d", resp.StatusCode) - } - return nil -} diff --git a/server/api.go b/server/api.go index b1208e1..5d8b660 100644 --- a/server/api.go +++ b/server/api.go @@ -13,7 +13,7 @@ import ( // API serves the car. Every endpoint but /v1/health needs the ingest token. // -// POST /v1/telemetry live samples; stored, newest forwarded to ABRP +// POST /v1/telemetry live samples, stored // PUT /v1/consumption/daily one delta chunk of a day's consumption // PUT /v1/charging-sessions/{sessionId} one finished DC charging session // GET /v1/consumption/history/{installationId} the car reads its own totals back @@ -24,7 +24,6 @@ import ( type API struct { Store Store IngestToken string - Forwarder *Forwarder Pairing *Pairing // PublicURL is the server's address as a phone reaches it, for the pairing QR code; // derived from the request when empty. @@ -117,27 +116,11 @@ func (a *API) ingest(h handler) http.HandlerFunc { } } -// notYetAtAbrp leaves out the samples the car already sent to ABRP itself. -func notYetAtAbrp(samples []TelemetrySample, sentUTC int64) []TelemetrySample { - if sentUTC == 0 { - return samples - } - out := make([]TelemetrySample, 0, len(samples)) - for _, s := range samples { - if s.UTC > sentUTC { - out = append(out, s) - } - } - return out -} - // maxDroppedListed caps the dropped values named in a telemetry response and log line. const maxDroppedListed = 20 -// telemetry stores the batch first; only once the write succeeded is the newest sample -// handed to ABRP, so the car's retry of a failed request cannot leave ABRP with data the -// database never got. The answer says how many samples were stored and what was dropped; -// a batch with nothing usable is still a 200, since sending it again would not help. +// telemetry stores the batch. The answer says how many samples were stored and what was +// dropped; a batch with nothing usable is still a 200, since sending it again would not help. func (a *API) telemetry(w http.ResponseWriter, r *http.Request, c caller, body []byte) error { batch, err := validateTelemetryBatch(body, time.Now()) if err != nil { @@ -150,7 +133,6 @@ func (a *API) telemetry(w http.ResponseWriter, r *http.Request, c caller, body [ if err := a.Store.WriteTelemetry(r.Context(), batch.InstallationID, batch.Samples); err != nil { return err } - a.Forwarder.Forward(notYetAtAbrp(batch.Samples, batch.AbrpSentUTC)) } dropped := batch.Dropped if len(dropped) > maxDroppedListed { diff --git a/server/api_test.go b/server/api_test.go index 5b6d0b5..7007992 100644 --- a/server/api_test.go +++ b/server/api_test.go @@ -76,7 +76,7 @@ const ( func newTestAPI() (*API, *fakeStore) { store := &fakeStore{} - return &API{Store: store, IngestToken: testToken, Forwarder: &Forwarder{}, + return &API{Store: store, IngestToken: testToken, Pairing: NewPairing(testApprovalPassword, nil), PublicURL: "https://car.example.com"}, store } @@ -204,23 +204,3 @@ func jsonInt(v int64) string { b, _ := json.Marshal(v) return string(b) } - -func TestSamplesTheCarSentToAbrpAreNotForwardedAgain(t *testing.T) { - samples := []TelemetrySample{{UTC: 100}, {UTC: 115}, {UTC: 130}} - if got := notYetAtAbrp(samples, 0); len(got) != 3 { - t.Fatalf("without abrpSentUtc everything is forwardable: %d", len(got)) - } - if got := notYetAtAbrp(samples, 130); len(got) != 0 { - t.Fatalf("the car sent the newest: %d left", len(got)) - } - if got := notYetAtAbrp(samples, 115); len(got) != 1 || got[0].UTC != 130 { - t.Fatalf("only the newer sample: %+v", got) - } - b, err := validateTelemetryBatch([]byte(`{"installationId":"`+testInstallation+`","abrpSentUtc":1790000000,"samples":[{"utc":1790000000}]}`), testNow) - if err != nil || b.AbrpSentUTC != 1790000000 { - t.Fatalf("abrpSentUtc not read: %v %d", err, b.AbrpSentUTC) - } - if _, err := validateTelemetryBatch([]byte(`{"installationId":"`+testInstallation+`","abrpSentUtc":"x","samples":[{"utc":1790000000}]}`), testNow); !isValidationError(err) { - t.Fatal("a malformed abrpSentUtc was accepted") - } -} diff --git a/server/logic_test.go b/server/logic_test.go index 6f8371c..366086e 100644 --- a/server/logic_test.go +++ b/server/logic_test.go @@ -1,10 +1,6 @@ package main import ( - "io" - "net/http" - "net/http/httptest" - "net/url" "testing" "time" ) @@ -49,68 +45,6 @@ func TestAggregateDaysNewestSohWins(t *testing.T) { } } -func TestTlmSendsFlagsAsZeroOrOne(t *testing.T) { - got := tlm(TelemetrySample{UTC: testNowS, Numbers: map[string]float64{"soc": 50}, - Flags: map[string]bool{"is_charging": true, "is_parked": false}}) - if got["utc"] != int64(testNowS) || got["soc"] != 50.0 || got["is_charging"] != 1 || got["is_parked"] != 0 { - t.Fatalf("tlm %+v", got) - } -} - -func TestForwarderPicksOnlyTheNewestFreshSampleOncePerInterval(t *testing.T) { - f := &Forwarder{MinInterval: 5 * time.Second} - now := time.Unix(testNowS, 0) - got, ok := f.pick([]TelemetrySample{{UTC: testNowS - 10}, {UTC: testNowS - 2}, {UTC: testNowS - 5}}, now) - if !ok || got.UTC != testNowS-2 { - t.Fatalf("picked %+v %v", got, ok) - } - if _, ok := f.pick([]TelemetrySample{{UTC: testNowS}}, now.Add(time.Second)); ok { - t.Fatal("forwarded again within the interval") - } - if _, ok := f.pick([]TelemetrySample{{UTC: testNowS - 3}}, now.Add(time.Minute)); ok { - t.Fatal("forwarded an older sample than the last one") - } - // A sample from ten minutes ago describes a car that has moved on. - fresh := &Forwarder{MinInterval: 5 * time.Second} - if _, ok := fresh.pick([]TelemetrySample{{UTC: testNowS - 600}}, now); ok { - t.Fatal("forwarded a stale sample") - } - // A sample from a clock a day ahead is skipped, and does not block the real ones after it. - ahead := &Forwarder{MinInterval: 5 * time.Second} - got, ok = ahead.pick([]TelemetrySample{{UTC: testNowS + 86400}, {UTC: testNowS - 1}}, now) - if !ok || got.UTC != testNowS-1 { - t.Fatalf("picked %+v %v", got, ok) - } - if _, ok := ahead.pick([]TelemetrySample{{UTC: testNowS + 86400}}, now.Add(time.Minute)); ok { - t.Fatal("forwarded a sample from the future") - } - if _, ok := ahead.pick([]TelemetrySample{{UTC: testNowS + 60}}, now.Add(time.Minute)); !ok { - t.Fatal("a real sample after a future one was not forwarded") - } -} - -func TestForwarderPutsCredentialsInTheBodyNotTheURL(t *testing.T) { - received := make(chan *http.Request, 1) - var form url.Values - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, _ := io.ReadAll(r.Body) - form, _ = url.ParseQuery(string(body)) - received <- r - })) - defer srv.Close() - f := &Forwarder{APIKey: "key-123", UserToken: "token-456", Endpoint: srv.URL + "/1/tlm/send"} - if err := f.send(t.Context(), TelemetrySample{UTC: testNowS, Numbers: map[string]float64{"soc": 80}}); err != nil { - t.Fatal(err) - } - r := <-received - if r.URL.RawQuery != "" { - t.Fatalf("credentials leaked into the URL: %s", r.URL.RawQuery) - } - if form.Get("api_key") != "key-123" || form.Get("token") != "token-456" || form.Get("tlm") != `{"soc":80,"utc":1790000000}` { - t.Fatalf("form %v", form) - } -} - func TestLoadConfigFailsClosed(t *testing.T) { env := map[string]string{ "INFLUX_URL": "http://influxdb:8086", "INFLUX_TOKEN": "t", "INFLUX_ORG": "mg4", @@ -118,7 +52,7 @@ func TestLoadConfigFailsClosed(t *testing.T) { } get := func(k string) string { return env[k] } c, err := loadConfig(get) - if err != nil || c.listenAddr != ":8080" || c.influxBucket != "mg4" || c.abrpMinInterval != 5*time.Second { + if err != nil || c.listenAddr != ":8080" || c.influxBucket != "mg4" { t.Fatalf("defaults: %+v %v", c, err) } env["INGEST_TOKEN"] = "short" diff --git a/server/main.go b/server/main.go index 6bc6eb7..1d99853 100644 --- a/server/main.go +++ b/server/main.go @@ -1,5 +1,6 @@ // Command server is the Nils' MG4 car server: the API the car writes its telemetry, -// consumption and charging sessions to, stored in InfluxDB 2 and forwarded live to ABRP. +// consumption and charging sessions to, stored in InfluxDB 2. The car sends live data to ABRP +// itself. // Dashboards are Grafana's job; Grafana reads the same bucket directly. package main @@ -11,7 +12,6 @@ import ( "net/http" "os" "os/signal" - "strconv" "syscall" "time" ) @@ -22,33 +22,28 @@ const ( ) type config struct { - listenAddr string - influxURL string - influxToken string - influxOrg string - influxBucket string - ingestToken string - abrpAPIKey string - abrpUserToken string - abrpMinInterval time.Duration - approvalPass string - publicURL string + listenAddr string + influxURL string + influxToken string + influxOrg string + influxBucket string + ingestToken string + approvalPass string + publicURL string } // loadConfig reads the environment and fails closed: a missing InfluxDB setting or a // short ingest token stops the server from starting rather than serving something broken. func loadConfig(getenv func(string) string) (config, error) { c := config{ - listenAddr: getenv("LISTEN_ADDR"), - influxURL: getenv("INFLUX_URL"), - influxToken: getenv("INFLUX_TOKEN"), - influxOrg: getenv("INFLUX_ORG"), - influxBucket: getenv("INFLUX_BUCKET"), - ingestToken: getenv("INGEST_TOKEN"), - abrpAPIKey: getenv("ABRP_API_KEY"), - abrpUserToken: getenv("ABRP_USER_TOKEN"), - approvalPass: getenv("APPROVAL_PASSWORD"), - publicURL: getenv("PUBLIC_URL"), + listenAddr: getenv("LISTEN_ADDR"), + influxURL: getenv("INFLUX_URL"), + influxToken: getenv("INFLUX_TOKEN"), + influxOrg: getenv("INFLUX_ORG"), + influxBucket: getenv("INFLUX_BUCKET"), + ingestToken: getenv("INGEST_TOKEN"), + approvalPass: getenv("APPROVAL_PASSWORD"), + publicURL: getenv("PUBLIC_URL"), } if c.listenAddr == "" { c.listenAddr = ":8080" @@ -69,14 +64,6 @@ func loadConfig(getenv func(string) string) (config, error) { if c.approvalPass != "" && len(c.approvalPass) < minApprovalPasswordLength { return config{}, fmt.Errorf("APPROVAL_PASSWORD must be at least %d characters", minApprovalPasswordLength) } - c.abrpMinInterval = 5 * time.Second - if raw := getenv("ABRP_MIN_INTERVAL_SECONDS"); raw != "" { - seconds, err := strconv.ParseFloat(raw, 64) - if err != nil || seconds < 0 { - return config{}, errors.New("ABRP_MIN_INTERVAL_SECONDS must be a non-negative number") - } - c.abrpMinInterval = time.Duration(seconds * float64(time.Second)) - } return c, nil } @@ -90,12 +77,6 @@ func main() { store := NewInfluxStore(cfg.influxURL, cfg.influxToken, cfg.influxOrg, cfg.influxBucket) defer store.Close() - forwarder := &Forwarder{ - APIKey: cfg.abrpAPIKey, - UserToken: cfg.abrpUserToken, - MinInterval: cfg.abrpMinInterval, - Client: &http.Client{Timeout: 10 * time.Second}, - } // Paired cars' tokens must be known before the first request; starting without them // would answer every paired car with 401, so a failed load stops the start instead. loadCtx, cancelLoad := context.WithTimeout(context.Background(), 30*time.Second) @@ -106,8 +87,7 @@ func main() { os.Exit(1) } pairing := NewPairing(cfg.approvalPass, devices) - api := &API{Store: store, IngestToken: cfg.ingestToken, Forwarder: forwarder, - Pairing: pairing, PublicURL: cfg.publicURL} + api := &API{Store: store, IngestToken: cfg.ingestToken, Pairing: pairing, PublicURL: cfg.publicURL} srv := &http.Server{ Addr: cfg.listenAddr, @@ -122,7 +102,7 @@ func main() { defer stop() go func() { slog.Info("listening", "addr", cfg.listenAddr, "bucket", cfg.influxBucket, - "abrp", forwarder.configured(), "pairing", pairing.Enabled(), "pairedDevices", len(devices)) + "pairing", pairing.Enabled(), "pairedDevices", len(devices)) if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { slog.Error("server failed", "error", err) os.Exit(1) diff --git a/server/model.go b/server/model.go index ca94b25..b121c87 100644 --- a/server/model.go +++ b/server/model.go @@ -107,7 +107,4 @@ type TelemetryBatch struct { Samples []TelemetrySample // Dropped names the values and samples validation left out, e.g. "samples[3].speed". Dropped []string - // AbrpSentUTC is the newest sample the car delivered to ABRP itself (0: none). The - // server forwards only samples newer than that, so ABRP gets each one once. - AbrpSentUTC int64 } diff --git a/server/validate.go b/server/validate.go index 4e0cc97..c82cbe3 100644 --- a/server/validate.go +++ b/server/validate.go @@ -273,11 +273,6 @@ func validateTelemetryBatch(body []byte, now time.Time) (TelemetryBatch, error) return TelemetryBatch{}, invalid("samples is invalid") } batch := TelemetryBatch{InstallationID: id, Samples: make([]TelemetrySample, 0, len(raw))} - if o.has("abrpSentUtc") { - if batch.AbrpSentUTC, err = integer(o["abrpSentUtc"], "abrpSentUtc"); err != nil { - return TelemetryBatch{}, err - } - } latest := now.Add(maxClockAhead).Unix() for i, entry := range raw { name := fmt.Sprintf("samples[%d]", i) From d711e0cfdab3a14ba6b6da9dfc22ea962d598251 Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Sun, 18 Oct 2026 12:59:03 +0200 Subject: [PATCH 52/79] App 2.12.0, server 2.5.0: enter settings on the phone The ABRP credentials are long to type on the car. "Enter on phone" (Live data tab, and Settings next to the charging price) shows a QR code; it opens a form on the server where the phone sends the ABRP API key and user token, charging price and usable capacity, confirmed with the approval password. The car polls with its own token and collects the values once, then saves them through the same checks as the fields on screen. Same device-flow shape as pairing. The server keeps the values in memory only, at most ten minutes, hands them out once with no-store, and never logs them or puts them back into the page; a paired token only reaches its own installation's request. Pairing's QR dialog is now shared (PhoneQrDialog), and the phone pages share one stylesheet. The config file also takes charging_price and usable_kwh. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki --- README.md | 11 + SPECS.md | 6 + app/build.gradle | 4 +- .../java/com/evsuite/abrp/ConfigImport.java | 36 ++- .../java/com/evsuite/abrp/MainActivity.java | 287 ++++++++++++------ .../com/evsuite/abrp/PhoneSettingsClient.java | 66 ++++ .../main/res/layout-w1000dp/pane_service.xml | 1 + .../main/res/layout-w1000dp/pane_settings.xml | 2 + app/src/main/res/layout/pane_service.xml | 1 + app/src/main/res/layout/pane_settings.xml | 2 + app/src/main/res/values/strings.xml | 11 + .../com/evsuite/abrp/ConfigImportTest.java | 10 + .../evsuite/abrp/PhoneSettingsClientTest.java | 29 ++ k8s/server.yaml | 4 +- server/README.md | 11 + server/api.go | 4 + server/api_test.go | 2 +- server/handoff.go | 132 ++++++++ server/handoff_http.go | 240 +++++++++++++++ server/handoff_test.go | 143 +++++++++ server/main.go | 3 +- server/pairing_http.go | 13 +- 22 files changed, 918 insertions(+), 100 deletions(-) create mode 100644 app/src/main/java/com/evsuite/abrp/PhoneSettingsClient.java create mode 100644 app/src/test/java/com/evsuite/abrp/PhoneSettingsClientTest.java create mode 100644 server/handoff.go create mode 100644 server/handoff_http.go create mode 100644 server/handoff_test.go diff --git a/README.md b/README.md index 144a6ea..fdf9000 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,15 @@ pointed at this repository's releases (see `SPECS.md`). 4. On the **Live data** tab, turn live data on. The service starts with the car from then on. 5. Enter your ABRP API key and user token on the same tab, so the car sends live data to ABRP. +### On your phone + +Once the car is paired, **Enter on phone** (Live data tab, and Settings → Charging price) shows a +QR code. It opens a form on your server where you type the ABRP API key and user token, the +charging price and the usable capacity, and send them with the approval password. The car +collects them within a few seconds and saves them through the same checks as the fields on +screen. The server only passes them on: they are held in memory until the car has them (at most +ten minutes), never stored or logged. + ### Config file Instead of typing, put the settings in a text file and tap **Import file** on the Server tab. @@ -140,6 +149,8 @@ boost_low_soc = true low_soc_percent = 20 abrp_api_key = your ABRP API key abrp_token = your ABRP user token +charging_price = 0.39 # €/kWh +usable_kwh = 50.8 # usable battery capacity, for the range estimates ``` After a successful import the app deletes the file, since it holds the token; credentials live diff --git a/SPECS.md b/SPECS.md index b5ca00b..b2b7357 100644 --- a/SPECS.md +++ b/SPECS.md @@ -113,6 +113,12 @@ tyre pressures, GPS (lat, lon, elevation, heading) and the is_* flags. It does * INGEST_TOKEN) into git and pushed it. Commit amended and force-pushed; both values regenerated in the now gitignored `k8s/secrets.yaml` (since moved to `k8s/secrets/secrets.yaml`, outside `kubectl apply -f k8s/`). Filled-in secrets live only there from now on. +- 2026-10-18 — App 2.12.0, server 2.5.0: settings entered on a phone. "Enter on phone" shows a QR + code; the phone form on the server (approval password) takes the ABRP credentials, charging + price and usable capacity, and the car collects them once (RFC 8628-style, like pairing; values + in server memory only, at most 10 minutes). The config file also takes `charging_price` and + `usable_kwh`. The QR dialog is shared with pairing (`PhoneQrDialog`). Tested end to end with + curl against the server image and InfluxDB; the app side on the emulator only up to the dialog. - 2026-10-18 — App 2.11.1, server 2.4.0: ABRP forwarding removed from the server. The car and the server use the same mobile connection, so a server fallback for ABRP bought nothing. The car no longer sends `abrpSentUtc`; the server ignores unknown fields, so older apps keep working. diff --git a/app/build.gradle b/app/build.gradle index 5cb6663..1ddd708 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -34,8 +34,8 @@ android { applicationId "com.stinnesbeck.nilsmg4" minSdk 27 targetSdk 36 - versionCode 74 - versionName "2.11.1" + versionCode 75 + versionName "2.12.0" // Nothing about the server ships in the APK: its URL and ingest token are entered in // the app's Server tab (ServerConfig), and the database is only reachable by the server. diff --git a/app/src/main/java/com/evsuite/abrp/ConfigImport.java b/app/src/main/java/com/evsuite/abrp/ConfigImport.java index 674bac3..414a65e 100644 --- a/app/src/main/java/com/evsuite/abrp/ConfigImport.java +++ b/app/src/main/java/com/evsuite/abrp/ConfigImport.java @@ -15,6 +15,8 @@ package com.evsuite.abrp; * low_soc_percent = 20 * abrp_api_key = your-abrp-api-key (optional: send live data to ABRP directly) * abrp_token = your-abrp-user-token + * charging_price = 0.39 (€/kWh) + * usable_kwh = 50.8 (usable battery capacity, for the range estimates) * * * Usually only server_url and token are needed. installation_id is for a reinstall: it puts @@ -33,9 +35,12 @@ final class ConfigImport { final Integer lowSocPercent; final String abrpApiKey; final String abrpToken; + final Double chargingPrice; + final Double usableKwh; private ConfigImport(String serverUrl, String token, String installationId, Integer intervalSec, - Boolean boostLowSoc, Integer lowSocPercent, String abrpApiKey, String abrpToken) { + Boolean boostLowSoc, Integer lowSocPercent, String abrpApiKey, String abrpToken, + Double chargingPrice, Double usableKwh) { this.serverUrl = serverUrl; this.token = token; this.installationId = installationId; @@ -44,18 +49,22 @@ final class ConfigImport { this.lowSocPercent = lowSocPercent; this.abrpApiKey = abrpApiKey; this.abrpToken = abrpToken; + this.chargingPrice = chargingPrice; + this.usableKwh = usableKwh; } /** True when the file yielded nothing usable — an empty or malformed file, not a config. */ boolean isEmpty() { return serverUrl == null && token == null && installationId == null && intervalSec == null - && boostLowSoc == null && lowSocPercent == null && abrpApiKey == null && abrpToken == null; + && boostLowSoc == null && lowSocPercent == null && abrpApiKey == null && abrpToken == null + && chargingPrice == null && usableKwh == null; } static ConfigImport parse(String text) { String serverUrl = null, token = null, installationId = null, abrpApiKey = null, abrpToken = null; Integer intervalSec = null, lowSocPercent = null; Boolean boostLowSoc = null; + Double chargingPrice = null, usableKwh = null; for (String line : text.split("\n", -1)) { line = line.trim(); @@ -98,6 +107,14 @@ final class ConfigImport { case "abrp_user_token": abrpToken = value; break; + case "charging_price": + case "price_per_kwh": + chargingPrice = parseDecimal(value); + break; + case "usable_kwh": + case "usable_capacity_kwh": + usableKwh = parseDecimal(value); + break; default: // Unknown keys are ignored rather than rejected, so a file can carry // comments or future keys without breaking an older build. @@ -105,7 +122,7 @@ final class ConfigImport { } } return new ConfigImport(serverUrl, token, installationId, intervalSec, boostLowSoc, lowSocPercent, - abrpApiKey, abrpToken); + abrpApiKey, abrpToken, chargingPrice, usableKwh); } /** Snaps to the nearest offered choice: the UI spinner has no free-form interval. */ @@ -132,6 +149,19 @@ final class ConfigImport { } } + /** + * A decimal with a dot or a comma; null when it is not a number. Ranges are checked where + * the value is saved, the same as for the fields on screen. + */ + private static Double parseDecimal(String value) { + try { + double d = Double.parseDouble(value.replace(',', '.')); + return Double.isFinite(d) ? d : null; + } catch (NumberFormatException e) { + return null; + } + } + private static Boolean parseBool(String value) { value = value.toLowerCase(java.util.Locale.US); if (value.equals("true") || value.equals("on") || value.equals("1")) return Boolean.TRUE; diff --git a/app/src/main/java/com/evsuite/abrp/MainActivity.java b/app/src/main/java/com/evsuite/abrp/MainActivity.java index 4e1646d..9372425 100644 --- a/app/src/main/java/com/evsuite/abrp/MainActivity.java +++ b/app/src/main/java/com/evsuite/abrp/MainActivity.java @@ -531,71 +531,8 @@ public class MainActivity extends AppCompatActivity { String installationId = ServerConfig.isValidInstallationId(typedId) ? typedId : ServerConfig.get(this).installationId; - float density = getResources().getDisplayMetrics().density; - int pad = (int) (24 * density); - // On the head unit's wide, low screen the QR code sits left of the text: stacked, the - // code and status below a 240 dp QR code were cut off. Scrollable either way, so - // nothing can be clipped on any screen. - android.util.DisplayMetrics metrics = getResources().getDisplayMetrics(); - boolean wide = metrics.widthPixels > metrics.heightPixels; - android.widget.LinearLayout panel = new android.widget.LinearLayout(this); - panel.setOrientation(wide ? android.widget.LinearLayout.HORIZONTAL : android.widget.LinearLayout.VERTICAL); - panel.setGravity(wide ? android.view.Gravity.CENTER_VERTICAL : android.view.Gravity.CENTER_HORIZONTAL); - panel.setPadding(pad, pad, pad, pad); - android.widget.LinearLayout column = new android.widget.LinearLayout(this); - column.setOrientation(android.widget.LinearLayout.VERTICAL); - TextView instructions = new TextView(this); - instructions.setTextAppearance(R.style.Text_EV_Secondary); - ImageView qr = new ImageView(this); - qr.setContentDescription(getString(R.string.pair_qr_description)); - int qrSize = (int) (240 * density); - TextView code = new TextView(this); - code.setTextAppearance(R.style.Text_EV_Title); - code.setTypeface(android.graphics.Typeface.MONOSPACE, android.graphics.Typeface.BOLD); - code.setTextSize(36); - code.setLetterSpacing(0.15f); - code.setTextColor(getColor(R.color.ev_cyan)); - TextView status = new TextView(this); - status.setTextAppearance(R.style.Text_EV_Body); - status.setText(R.string.pair_starting); - android.widget.LinearLayout.LayoutParams qrParams = new android.widget.LinearLayout.LayoutParams(qrSize, qrSize); - android.widget.LinearLayout.LayoutParams columnParams; - if (wide) { - qrParams.rightMargin = pad; - columnParams = new android.widget.LinearLayout.LayoutParams( - 0, android.view.ViewGroup.LayoutParams.WRAP_CONTENT, 1f); - panel.addView(qr, qrParams); - panel.addView(column, columnParams); - column.addView(instructions); - } else { - qrParams.topMargin = pad / 2; - qrParams.bottomMargin = pad / 2; - columnParams = new android.widget.LinearLayout.LayoutParams( - android.view.ViewGroup.LayoutParams.MATCH_PARENT, android.view.ViewGroup.LayoutParams.WRAP_CONTENT); - panel.addView(instructions); - panel.addView(qr, qrParams); - panel.addView(column, columnParams); - } - android.widget.LinearLayout.LayoutParams codeParams = new android.widget.LinearLayout.LayoutParams( - android.view.ViewGroup.LayoutParams.WRAP_CONTENT, android.view.ViewGroup.LayoutParams.WRAP_CONTENT); - codeParams.topMargin = pad / 2; - codeParams.bottomMargin = pad / 2; - column.addView(code, codeParams); - column.addView(status); - android.widget.ScrollView scroll = new android.widget.ScrollView(this); - scroll.addView(panel); - - final boolean[] cancelled = {false}; - androidx.appcompat.app.AlertDialog dialog = new MaterialAlertDialogBuilder(this) - .setTitle(R.string.pair_title) - .setView(scroll) - .setNegativeButton(R.string.pair_cancel, null) - .create(); - // Shown only once the server has answered, so a failure leaves no empty QR frame. - qr.setVisibility(View.GONE); - code.setVisibility(View.GONE); - dialog.setOnDismissListener(d -> cancelled[0] = true); - dialog.show(); + PhoneQrDialog ui = new PhoneQrDialog(R.string.pair_title, R.string.pair_starting); + final boolean[] cancelled = ui.cancelled; new Thread(() -> { String outcome; @@ -603,18 +540,7 @@ public class MainActivity extends AppCompatActivity { try { PairingClient.Request request = PairingClient.start(url, installationId); android.graphics.Bitmap bitmap = QrCode.generate(request.verificationUriComplete, 416); - runOnUiThread(() -> { - // The code is in the sentence as well as below it, so it is readable - // wherever the text is. - instructions.setText(getString(R.string.pair_scan, request.verificationUri, request.userCode)); - if (bitmap != null) { - qr.setImageBitmap(bitmap); - qr.setVisibility(View.VISIBLE); - } - code.setText(request.userCode); - code.setVisibility(View.VISIBLE); - status.setText(R.string.pair_waiting); - }); + runOnUiThread(() -> ui.showRequest(request, bitmap, R.string.pair_scan, R.string.pair_waiting)); PairingClient.PollResult result = null; while (!cancelled[0] && System.currentTimeMillis() < request.expiresAtMs) { Thread.sleep(request.intervalMs); @@ -655,23 +581,201 @@ public class MainActivity extends AppCompatActivity { showServerConfig(); serverTokenLayout.setError(null); refreshStatus(); - if (!cancelled[0]) dialog.dismiss(); + if (!cancelled[0]) ui.dialog.dismiss(); return; } - if (cancelled[0]) return; // Keep the dialog up with the reason, instead of closing it on a failure the // driver would otherwise only see as a flicker. - qr.setVisibility(View.GONE); - code.setVisibility(View.GONE); - instructions.setVisibility(View.GONE); - status.setText(message); - status.setTextColor(c); - android.widget.Button close = dialog.getButton(android.content.DialogInterface.BUTTON_NEGATIVE); - if (close != null) close.setText(R.string.pair_close); + if (!cancelled[0]) ui.finish(message, c); }); }, "server-pairing").start(); } + /** + * Settings typed on a phone instead of the car's keyboard: the server opens a request for + * this car, the dialog shows it as a QR code, and the values the phone sends (ABRP + * credentials, charging price, usable capacity) are applied through the same checks as the + * fields here. Needs the server set up, since the request is made with this car's token. + */ + private void startPhoneSettings() { + ServerConfig config = ServerConfig.get(this); + PhoneQrDialog ui = new PhoneQrDialog(R.string.phone_settings_title, R.string.pair_starting); + if (!config.isConfigured()) { + ui.finish(getString(R.string.phone_settings_needs_server), COLOR_ERROR); + return; + } + new Thread(() -> { + String outcome; + int color = COLOR_ERROR; + PhoneSettingsClient.Settings received = null; + try { + PairingClient.Request request = PhoneSettingsClient.start(config); + android.graphics.Bitmap bitmap = QrCode.generate(request.verificationUriComplete, 416); + runOnUiThread(() -> ui.showRequest(request, bitmap, R.string.phone_settings_scan, + R.string.phone_settings_waiting)); + while (!ui.cancelled[0] && System.currentTimeMillis() < request.expiresAtMs) { + Thread.sleep(request.intervalMs); + if (ui.cancelled[0]) return; + try { + received = PhoneSettingsClient.poll(config, request.deviceCode); + } catch (java.io.IOException e) { + continue; // a dropped connection mid-wait is not a failure + } + if (received != null) break; + } + if (ui.cancelled[0]) return; + outcome = received == null ? getString(R.string.pair_expired_settings) : null; + } catch (PairingClient.NotEnabledException e) { + outcome = getString(R.string.phone_settings_not_enabled); + } catch (PairingClient.HttpStatusException e) { + outcome = e.status == 410 ? getString(R.string.pair_expired_settings) + : getString(R.string.err_http, e.status); + } catch (InterruptedException e) { + return; + } catch (Exception e) { + outcome = describeNetworkError(e, config.url); + } + final PhoneSettingsClient.Settings settings = received; + final String failure = outcome; + runOnUiThread(() -> { + if (ui.cancelled[0]) return; + if (settings == null) { + ui.finish(failure, COLOR_ERROR); + return; + } + String applied = applyPhoneSettings(settings); + ui.finish(applied, applied.equals(getString(R.string.phone_settings_applied)) ? COLOR_OK : COLOR_ERROR); + }); + }, "phone-settings").start(); + } + + /** + * Puts what the phone sent into the fields and saves each through its own Save, so the + * same limits apply as when typed here. A credential sent alone is paired with the one + * already stored. Returns the message for the dialog. UI thread. + */ + private String applyPhoneSettings(PhoneSettingsClient.Settings s) { + boolean ok = true; + if (s.abrpApiKey != null || s.abrpToken != null) { + AbrpConfig current = AbrpConfig.get(this); + ok &= saveAbrpConfig(s.abrpApiKey != null ? s.abrpApiKey : current.apiKey, + s.abrpToken != null ? s.abrpToken : current.userToken); + } + if (s.pricePerKwh != null) { + chargePriceInput.setText(String.format(java.util.Locale.US, "%.2f", s.pricePerKwh)); + saveChargePrice(); + ok &= chargePriceLayout.getError() == null; + } + if (s.usableKwh != null) { + usableCapacityInput.setText(String.format(java.util.Locale.US, "%.1f", s.usableKwh)); + saveUsableCapacity(); + ok &= usableCapacityLayout.getError() == null; + } + return getString(ok ? R.string.phone_settings_applied : R.string.phone_settings_partly); + } + + /** + * The dialog of both phone flows (pairing, settings): a QR code beside the code to type + * and a status line. The QR code and code stay hidden until the server has answered. + */ + private final class PhoneQrDialog { + final TextView instructions, code, status; + final ImageView qr; + final androidx.appcompat.app.AlertDialog dialog; + /** Set when the driver closes the dialog; the polling thread then stops. */ + final boolean[] cancelled = {false}; + + PhoneQrDialog(int title, int startingText) { + float density = getResources().getDisplayMetrics().density; + int pad = (int) (24 * density); + // On the head unit's wide, low screen the QR code sits left of the text: stacked, the + // code and status below a 240 dp QR code were cut off. Scrollable either way, so + // nothing can be clipped on any screen. + android.util.DisplayMetrics metrics = getResources().getDisplayMetrics(); + boolean wide = metrics.widthPixels > metrics.heightPixels; + android.widget.LinearLayout panel = new android.widget.LinearLayout(MainActivity.this); + panel.setOrientation(wide ? android.widget.LinearLayout.HORIZONTAL : android.widget.LinearLayout.VERTICAL); + panel.setGravity(wide ? android.view.Gravity.CENTER_VERTICAL : android.view.Gravity.CENTER_HORIZONTAL); + panel.setPadding(pad, pad, pad, pad); + android.widget.LinearLayout column = new android.widget.LinearLayout(MainActivity.this); + column.setOrientation(android.widget.LinearLayout.VERTICAL); + instructions = new TextView(MainActivity.this); + instructions.setTextAppearance(R.style.Text_EV_Secondary); + qr = new ImageView(MainActivity.this); + qr.setContentDescription(getString(R.string.pair_qr_description)); + int qrSize = (int) (240 * density); + code = new TextView(MainActivity.this); + code.setTextAppearance(R.style.Text_EV_Title); + code.setTypeface(android.graphics.Typeface.MONOSPACE, android.graphics.Typeface.BOLD); + code.setTextSize(36); + code.setLetterSpacing(0.15f); + code.setTextColor(getColor(R.color.ev_cyan)); + status = new TextView(MainActivity.this); + status.setTextAppearance(R.style.Text_EV_Body); + status.setText(startingText); + android.widget.LinearLayout.LayoutParams qrParams = new android.widget.LinearLayout.LayoutParams(qrSize, qrSize); + android.widget.LinearLayout.LayoutParams columnParams; + if (wide) { + qrParams.rightMargin = pad; + columnParams = new android.widget.LinearLayout.LayoutParams( + 0, android.view.ViewGroup.LayoutParams.WRAP_CONTENT, 1f); + panel.addView(qr, qrParams); + panel.addView(column, columnParams); + column.addView(instructions); + } else { + qrParams.topMargin = pad / 2; + qrParams.bottomMargin = pad / 2; + columnParams = new android.widget.LinearLayout.LayoutParams( + android.view.ViewGroup.LayoutParams.MATCH_PARENT, android.view.ViewGroup.LayoutParams.WRAP_CONTENT); + panel.addView(instructions); + panel.addView(qr, qrParams); + panel.addView(column, columnParams); + } + android.widget.LinearLayout.LayoutParams codeParams = new android.widget.LinearLayout.LayoutParams( + android.view.ViewGroup.LayoutParams.WRAP_CONTENT, android.view.ViewGroup.LayoutParams.WRAP_CONTENT); + codeParams.topMargin = pad / 2; + codeParams.bottomMargin = pad / 2; + column.addView(code, codeParams); + column.addView(status); + android.widget.ScrollView scroll = new android.widget.ScrollView(MainActivity.this); + scroll.addView(panel); + + dialog = new MaterialAlertDialogBuilder(MainActivity.this) + .setTitle(title) + .setView(scroll) + .setNegativeButton(R.string.pair_cancel, null) + .create(); + // Shown only once the server has answered, so a failure leaves no empty QR frame. + qr.setVisibility(View.GONE); + code.setVisibility(View.GONE); + dialog.setOnDismissListener(d -> cancelled[0] = true); + dialog.show(); + } + + /** Shows the request: QR code, the code in the sentence and below it. UI thread. */ + void showRequest(PairingClient.Request request, android.graphics.Bitmap bitmap, int scanText, int waitingText) { + instructions.setText(getString(scanText, request.verificationUri, request.userCode)); + if (bitmap != null) { + qr.setImageBitmap(bitmap); + qr.setVisibility(View.VISIBLE); + } + code.setText(request.userCode); + code.setVisibility(View.VISIBLE); + status.setText(waitingText); + } + + /** Keeps the dialog up with the outcome instead of closing it. UI thread. */ + void finish(String message, int color) { + qr.setVisibility(View.GONE); + code.setVisibility(View.GONE); + instructions.setVisibility(View.GONE); + status.setText(message); + status.setTextColor(color); + android.widget.Button close = dialog.getButton(android.content.DialogInterface.BUTTON_NEGATIVE); + if (close != null) close.setText(R.string.pair_close); + } + } + /** * A network failure in words the driver can act on: a name that does not resolve, a * server that does not answer, or a status. Shared by Test connection and pairing. @@ -845,6 +949,15 @@ public class MainActivity extends AppCompatActivity { return false; } } + if (config.chargingPrice != null || config.usableKwh != null) { + // Through the same Save as the fields (and the phone form), with the same limits. + String applied = applyPhoneSettings(new PhoneSettingsClient.Settings( + null, null, config.chargingPrice, config.usableKwh)); + if (!applied.equals(getString(R.string.phone_settings_applied))) { + setConnectionStatus(COLOR_ERROR, getString(R.string.import_err_values)); + return false; + } + } SharedPreferences.Editor edit = prefs.edit(); if (config.intervalSec != null) edit.putInt(UploadSettings.KEY_INTERVAL_SEC, config.intervalSec); if (config.boostLowSoc != null) edit.putBoolean(UploadSettings.KEY_BOOST_LOW_SOC, config.boostLowSoc); @@ -898,6 +1011,7 @@ public class MainActivity extends AppCompatActivity { servicePane.findViewById(R.id.abrp_save_button).setOnClickListener(v -> saveAbrpConfig(text(abrpApiKeyInput), text(abrpTokenInput))); servicePane.findViewById(R.id.abrp_test_button).setOnClickListener(v -> testAbrpConfig()); + servicePane.findViewById(R.id.abrp_phone_button).setOnClickListener(v -> startPhoneSettings()); } private static String text(TextInputEditText input) { @@ -1266,6 +1380,7 @@ public class MainActivity extends AppCompatActivity { chargePriceInput.setText(String.format(java.util.Locale.US, "%.2f", chargeSessionTracker.pricePerKwh())); settingsPane.findViewById(R.id.charge_price_save).setOnClickListener(v -> saveChargePrice()); + settingsPane.findViewById(R.id.settings_phone_button).setOnClickListener(v -> startPhoneSettings()); } private void saveChargePrice() { diff --git a/app/src/main/java/com/evsuite/abrp/PhoneSettingsClient.java b/app/src/main/java/com/evsuite/abrp/PhoneSettingsClient.java new file mode 100644 index 0000000..f7eec56 --- /dev/null +++ b/app/src/main/java/com/evsuite/abrp/PhoneSettingsClient.java @@ -0,0 +1,66 @@ +package com.evsuite.abrp; + +import org.json.JSONObject; + +/** + * The car's side of entering settings on a phone: open a request on the server (with this + * car's token), show it as a QR code, then poll until the phone has sent the values, which + * the server hands out once. Same codes and QR flow as pairing ({@link PairingClient}). + * Blocking calls — run them off the main thread. + */ +final class PhoneSettingsClient { + + /** What the phone sent; null fields were left empty there. */ + static final class Settings { + final String abrpApiKey; + final String abrpToken; + final Double pricePerKwh; + final Double usableKwh; + + Settings(String abrpApiKey, String abrpToken, Double pricePerKwh, Double usableKwh) { + this.abrpApiKey = abrpApiKey; + this.abrpToken = abrpToken; + this.pricePerKwh = pricePerKwh; + this.usableKwh = usableKwh; + } + + static Settings parse(String body) throws Exception { + JSONObject json = new JSONObject(body); + return new Settings(text(json, "abrpApiKey"), text(json, "abrpToken"), + json.has("pricePerKwh") ? json.getDouble("pricePerKwh") : null, + json.has("usableKwh") ? json.getDouble("usableKwh") : null); + } + + private static String text(JSONObject json, String key) { + String value = json.optString(key, "").trim(); + return value.isEmpty() ? null : value; + } + } + + private PhoneSettingsClient() { } + + static PairingClient.Request start(ServerConfig config) throws Exception { + ServerHttp.Response r = ServerHttp.request("POST", config.url + "/v1/settings/start", config.token, + new JSONObject().put("installationId", config.installationId)); + if (r.status == 404) throw new PairingClient.NotEnabledException(); + if (r.status != 201) throw new PairingClient.HttpStatusException(r.status); + JSONObject json = new JSONObject(r.body); + long now = System.currentTimeMillis(); + return new PairingClient.Request( + json.getString("deviceCode"), + json.getString("userCode"), + json.getString("verificationUri"), + json.getString("verificationUriComplete"), + now + json.optLong("expiresIn", 600) * 1000L, + Math.max(2, json.optLong("interval", 5)) * 1000L); + } + + /** Null while the phone has not sent anything; throws once the request has expired (410). */ + static Settings poll(ServerConfig config, String deviceCode) throws Exception { + ServerHttp.Response r = ServerHttp.request("POST", config.url + "/v1/settings/poll", config.token, + new JSONObject().put("deviceCode", deviceCode)); + if (r.status == 202) return null; + if (r.status == 200) return Settings.parse(r.body); + throw new PairingClient.HttpStatusException(r.status); + } +} diff --git a/app/src/main/res/layout-w1000dp/pane_service.xml b/app/src/main/res/layout-w1000dp/pane_service.xml index 8a580dd..c4dee5b 100644 --- a/app/src/main/res/layout-w1000dp/pane_service.xml +++ b/app/src/main/res/layout-w1000dp/pane_service.xml @@ -19,6 +19,7 @@ + diff --git a/app/src/main/res/layout-w1000dp/pane_settings.xml b/app/src/main/res/layout-w1000dp/pane_settings.xml index 22cf8de..0732e99 100644 --- a/app/src/main/res/layout-w1000dp/pane_settings.xml +++ b/app/src/main/res/layout-w1000dp/pane_settings.xml @@ -28,6 +28,8 @@ + + diff --git a/app/src/main/res/layout/pane_service.xml b/app/src/main/res/layout/pane_service.xml index 8904c1c..d1b1d05 100644 --- a/app/src/main/res/layout/pane_service.xml +++ b/app/src/main/res/layout/pane_service.xml @@ -18,6 +18,7 @@ + diff --git a/app/src/main/res/layout/pane_settings.xml b/app/src/main/res/layout/pane_settings.xml index 155fcab..4d17a49 100644 --- a/app/src/main/res/layout/pane_settings.xml +++ b/app/src/main/res/layout/pane_settings.xml @@ -27,6 +27,8 @@ + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e365d13..e75f2cf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -100,6 +100,7 @@ Config imported and saved; the file was deleted Config imported and saved, but the file could not be deleted. It holds your token: delete %1$s Could not read that file + The charging price or usable capacity in the file is not plausible No settings found in that file No config file found. Copy it to %1$s @@ -134,6 +135,16 @@ ABRP refused: HTTP %1$d ABRP not reachable: %1$s Set both first + Enter on phone + Type the ABRP credentials, charging price and usable capacity on your phone instead: a QR code opens a form on your server. + Enter settings on your phone + Scan the QR code with your phone, or open %1$s and enter the code %2$s. Fill in what you want to change and send it with the approval password. + Waiting for the phone… + Set up the server first (Server tab): the form for the phone runs on your server. + Not available on the server: it needs version 2.5.0 or newer and an APPROVAL_PASSWORD + The code expired — press Enter on phone again + Settings from the phone saved + Some values from the phone were not plausible and were not saved diff --git a/app/src/test/java/com/evsuite/abrp/ConfigImportTest.java b/app/src/test/java/com/evsuite/abrp/ConfigImportTest.java index 5144111..058ab62 100644 --- a/app/src/test/java/com/evsuite/abrp/ConfigImportTest.java +++ b/app/src/test/java/com/evsuite/abrp/ConfigImportTest.java @@ -96,4 +96,14 @@ public class ConfigImportTest { assertFalse(c.isEmpty()); assertEquals("token-2", ConfigImport.parse("abrp_user_token = token-2").abrpToken); } + + @Test + public void chargingPriceAndUsableCapacityAreRead() { + ConfigImport c = ConfigImport.parse("charging_price = 0,39\nusable_kwh = 50.8\n"); + assertEquals(0.39, c.chargingPrice, 1e-9); + assertEquals(50.8, c.usableKwh, 1e-9); + assertFalse(c.isEmpty()); + assertNull(ConfigImport.parse("charging_price = cheap").chargingPrice); + assertEquals(0.5, ConfigImport.parse("price_per_kwh = 0.5").chargingPrice, 1e-9); + } } diff --git a/app/src/test/java/com/evsuite/abrp/PhoneSettingsClientTest.java b/app/src/test/java/com/evsuite/abrp/PhoneSettingsClientTest.java new file mode 100644 index 0000000..761d5e5 --- /dev/null +++ b/app/src/test/java/com/evsuite/abrp/PhoneSettingsClientTest.java @@ -0,0 +1,29 @@ +package com.evsuite.abrp; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +import org.junit.Test; + +/** Reading what the phone sent, as the server hands it out — pure JVM. */ +public class PhoneSettingsClientTest { + + @Test + public void everythingTheServerSends() throws Exception { + PhoneSettingsClient.Settings s = PhoneSettingsClient.Settings.parse( + "{\"abrpApiKey\":\"api-key-1234\",\"abrpToken\":\" user-token-5678 \",\"pricePerKwh\":0.39,\"usableKwh\":50.8}"); + assertEquals("api-key-1234", s.abrpApiKey); + assertEquals("user-token-5678", s.abrpToken); + assertEquals(0.39, s.pricePerKwh, 1e-9); + assertEquals(50.8, s.usableKwh, 1e-9); + } + + @Test + public void whatThePhoneLeftEmptyStaysUnset() throws Exception { + PhoneSettingsClient.Settings s = PhoneSettingsClient.Settings.parse("{\"abrpToken\":\"user-token-5678\"}"); + assertNull(s.abrpApiKey); + assertNull(s.pricePerKwh); + assertNull(s.usableKwh); + assertEquals("user-token-5678", s.abrpToken); + } +} diff --git a/k8s/server.yaml b/k8s/server.yaml index aaf17df..b848271 100644 --- a/k8s/server.yaml +++ b/k8s/server.yaml @@ -29,8 +29,8 @@ spec: type: RuntimeDefault containers: - name: server - # 2.4.0, pinned by digest so a re-pushed tag cannot change what runs. - image: git.stinnesbeck.com/nils/mg4-server@sha256:ab243f37812efa7294efc594f65ca65f9d551161a85e7cc641afb168e226a2fe + # 2.5.0, pinned by digest so a re-pushed tag cannot change what runs. + image: git.stinnesbeck.com/nils/mg4-server@sha256:93b82b24a649c7b8b6fb7c0d4ef0bfda5694689808991a211e22ddeb257a39e2 ports: - name: http containerPort: 8080 diff --git a/server/README.md b/server/README.md index b6fa69f..5eecc00 100644 --- a/server/README.md +++ b/server/README.md @@ -43,6 +43,17 @@ with (403 otherwise); the shared token works for any. A malformed request is a 400 naming the field; a database failure is a 500 without detail. +## Settings from a phone + +The ABRP credentials are long to type on the car. With pairing on, the car (with its own token) +opens `POST /v1/settings/start` `{installationId}` and shows `verificationUriComplete` as a QR +code. The phone fills in the form at `/settings?code=…` (ABRP API key and user token, charging +price, usable capacity; empty fields are left alone) and sends it with the approval password. +The car polls `POST /v1/settings/poll` `{deviceCode}`: 202 pending, 200 with the values (handed +out once, `Cache-Control: no-store`), 410 expired or unknown. A paired token only reaches its own +installation's request. The values live in memory only, at most ten minutes, and are never +stored, logged or put back into the page. + ## Pairing a car by QR code So nobody types a token in the car. Modelled on the OAuth device authorization grant (RFC 8628); diff --git a/server/api.go b/server/api.go index 5d8b660..0fcdf89 100644 --- a/server/api.go +++ b/server/api.go @@ -18,6 +18,7 @@ import ( // PUT /v1/charging-sessions/{sessionId} one finished DC charging session // GET /v1/consumption/history/{installationId} the car reads its own totals back // GET /v1/health unauthenticated liveness probe +// /v1/settings/…, /settings settings typed on a phone (handoff_http.go) // // A car authenticates with the shared INGEST_TOKEN or with its own token from pairing // (pairing_http.go). @@ -25,6 +26,8 @@ type API struct { Store Store IngestToken string Pairing *Pairing + // Handoff carries settings typed on a phone to the car (handoff.go). + Handoff *Handoff // PublicURL is the server's address as a phone reaches it, for the pairing QR code; // derived from the request when empty. PublicURL string @@ -42,6 +45,7 @@ func (a *API) Routes() http.Handler { mux.HandleFunc("PUT /v1/charging-sessions/{sessionId}", a.ingest(a.chargingSession)) mux.HandleFunc("GET /v1/consumption/history/{installationId}", a.ingest(a.history)) a.pairingRoutes(mux) + a.handoffRoutes(mux) return mux } diff --git a/server/api_test.go b/server/api_test.go index 7007992..fb53862 100644 --- a/server/api_test.go +++ b/server/api_test.go @@ -77,7 +77,7 @@ const ( func newTestAPI() (*API, *fakeStore) { store := &fakeStore{} return &API{Store: store, IngestToken: testToken, - Pairing: NewPairing(testApprovalPassword, nil), PublicURL: "https://car.example.com"}, store + Pairing: NewPairing(testApprovalPassword, nil), Handoff: NewHandoff(), PublicURL: "https://car.example.com"}, store } func do(t *testing.T, h http.Handler, method, path, token, body string) *httptest.ResponseRecorder { diff --git a/server/handoff.go b/server/handoff.go new file mode 100644 index 0000000..9ea46aa --- /dev/null +++ b/server/handoff.go @@ -0,0 +1,132 @@ +package main + +import ( + "errors" + "strings" + "sync" + "time" +) + +// Handoff lets a phone fill in settings the car would otherwise need its on-screen keyboard +// for: the ABRP credentials above all, which are long. The car opens a request and shows it as +// a QR code, the phone fills in a form (with the approval password), and the car, polling, +// collects the values once. +// +// The values only pass through: they are held in memory until the car collects them, or for +// the request's ten minutes, and are never stored, logged or echoed back to the page. +type Handoff struct { + Now func() time.Time + + mu sync.Mutex + byDevice map[string]*handoffRequest + byUser map[string]*handoffRequest +} + +// maxPendingHandoffs bounds memory; only an authenticated car can open one. +const maxPendingHandoffs = 200 + +// PhoneSettings is what the phone may set. A nil or empty field leaves the car's value alone. +type PhoneSettings struct { + AbrpAPIKey string `json:"abrpApiKey,omitempty"` + AbrpToken string `json:"abrpToken,omitempty"` + PricePerKwh *float64 `json:"pricePerKwh,omitempty"` + UsableKwh *float64 `json:"usableKwh,omitempty"` +} + +func (s PhoneSettings) empty() bool { + return s.AbrpAPIKey == "" && s.AbrpToken == "" && s.PricePerKwh == nil && s.UsableKwh == nil +} + +type handoffRequest struct { + deviceCode string + userCode string + installationID string + expires time.Time + settings *PhoneSettings // set by the phone, handed to the car once +} + +var errAlreadyFilled = errors.New("these settings were already sent") + +func NewHandoff() *Handoff { + return &Handoff{Now: time.Now, byDevice: map[string]*handoffRequest{}, byUser: map[string]*handoffRequest{}} +} + +func (h *Handoff) expireLocked(now time.Time) { + for code, r := range h.byDevice { + if now.After(r.expires) { + delete(h.byDevice, code) + delete(h.byUser, r.userCode) + } + } +} + +// Start opens a request for a car; a new one replaces the same installation's open request. +func (h *Handoff) Start(installationID string) (*handoffRequest, error) { + h.mu.Lock() + defer h.mu.Unlock() + now := h.Now() + h.expireLocked(now) + for _, r := range h.byDevice { + if strings.EqualFold(r.installationID, installationID) { + delete(h.byDevice, r.deviceCode) + delete(h.byUser, r.userCode) + } + } + if len(h.byDevice) >= maxPendingHandoffs { + return nil, errTooMany + } + r := &handoffRequest{deviceCode: randomString(32), installationID: installationID, expires: now.Add(pairingTTL)} + for { + r.userCode = newUserCode() + if _, taken := h.byUser[r.userCode]; !taken { + break + } + } + h.byDevice[r.deviceCode] = r + h.byUser[r.userCode] = r + out := *r + return &out, nil +} + +// Open reports whether a user code belongs to a request still waiting for the phone. +func (h *Handoff) Open(userCode string) bool { + h.mu.Lock() + defer h.mu.Unlock() + h.expireLocked(h.Now()) + r, ok := h.byUser[normalizeUserCode(userCode)] + return ok && r.settings == nil +} + +// Fill stores what the phone entered, once. +func (h *Handoff) Fill(userCode string, s PhoneSettings) error { + h.mu.Lock() + defer h.mu.Unlock() + h.expireLocked(h.Now()) + r, ok := h.byUser[normalizeUserCode(userCode)] + if !ok { + return errUnknownCode + } + if r.settings != nil { + return errAlreadyFilled + } + r.settings = &s + return nil +} + +// Poll is the car's side: nil while the phone has not sent anything, the settings exactly +// once, then errUnknownCode. installationID must be the one the request was opened for. +func (h *Handoff) Poll(deviceCode string, mayUse func(installationID string) bool) (*PhoneSettings, error) { + h.mu.Lock() + defer h.mu.Unlock() + h.expireLocked(h.Now()) + r, ok := h.byDevice[deviceCode] + if !ok || !mayUse(r.installationID) { + return nil, errUnknownCode + } + if r.settings == nil { + return nil, nil + } + delete(h.byDevice, r.deviceCode) + delete(h.byUser, r.userCode) + return r.settings, nil +} diff --git a/server/handoff_http.go b/server/handoff_http.go new file mode 100644 index 0000000..cfc8a76 --- /dev/null +++ b/server/handoff_http.go @@ -0,0 +1,240 @@ +package main + +import ( + "errors" + "html/template" + "log/slog" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +// HTTP side of entering settings on the phone (handoff.go). +// +// POST /v1/settings/start car, with its token: open a request {installationId} → codes and URL +// POST /v1/settings/poll car, with its token: collect {deviceCode} → 202 / 200 settings / 410 +// GET /settings?code=… phone: the form +// POST /settings phone: send it (needs the approval password) +// +// Switched on together with pairing, by APPROVAL_PASSWORD: the phone page needs it. + +func (a *API) handoffRoutes(mux *http.ServeMux) { + mux.HandleFunc("POST /v1/settings/start", a.ingest(a.settingsStart)) + mux.HandleFunc("POST /v1/settings/poll", a.ingest(a.settingsPoll)) + mux.HandleFunc("GET /settings", a.settingsPage) + mux.HandleFunc("POST /settings", a.settingsSubmit) +} + +func (a *API) handoffEnabled() bool { return a.Handoff != nil && a.Pairing.Enabled() } + +var errHandoffOff = errors.New("entering settings on the phone is not enabled") + +func (a *API) settingsStart(w http.ResponseWriter, r *http.Request, c caller, body []byte) error { + if !a.handoffEnabled() { + writeJSON(w, http.StatusNotFound, map[string]string{"error": errHandoffOff.Error()}) + return nil + } + o, err := decodeObject(body) + if err != nil { + return err + } + id, err := validUUID(o["installationId"], "installationId") + if err != nil { + return err + } + if !c.mayUse(id) { + return errOtherInstallation + } + req, err := a.Handoff.Start(id) + if err != nil { + writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": err.Error()}) + return nil + } + base := a.publicURL(r) + "/settings" + writeJSON(w, http.StatusCreated, map[string]any{ + "deviceCode": req.deviceCode, + "userCode": req.userCode, + "verificationUri": base, + "verificationUriComplete": base + "?code=" + url.QueryEscape(req.userCode), + "expiresIn": int(pairingTTL / time.Second), + "interval": int(pairingInterval / time.Second), + }) + return nil +} + +func (a *API) settingsPoll(w http.ResponseWriter, r *http.Request, c caller, body []byte) error { + if !a.handoffEnabled() { + writeJSON(w, http.StatusNotFound, map[string]string{"error": errHandoffOff.Error()}) + return nil + } + o, err := decodeObject(body) + if err != nil { + return err + } + code, _ := o["deviceCode"].(string) + settings, err := a.Handoff.Poll(code, c.mayUse) + switch { + case err != nil: + writeJSON(w, http.StatusGone, map[string]string{"error": "expired_token"}) + case settings == nil: + writeJSON(w, http.StatusAccepted, map[string]string{"status": "pending"}) + default: + // The answer carries credentials: never cached anywhere on the way. + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, http.StatusOK, settings) + } + return nil +} + +// ---------- Phone page ---------- + +type settingsPageData struct { + Code string + Price string + Usable string + Error string + Done bool + Message string +} + +var settingsTemplate = template.Must(template.New("settings").Parse(` + +Car settings — Nils' MG4 +
    +

    Car settings

    +{{if .Done}} +

    {{.Message}}

    +{{else}} +

    Fill in what you want to change; empty fields leave the car's value as it is. The car picks the values up within a few seconds.

    +
    +{{if .Code}}
    {{.Code}}
    +{{else}} +{{end}} + + + + +

    The car needs both to send live data to ABRP.

    + + + + + + + +{{if .Error}}

    {{.Error}}

    {{end}} +
    +
    +{{end}} +
    `)) + +func renderSettingsPage(w http.ResponseWriter, status int, data settingsPageData) { + h := w.Header() + h.Set("Content-Type", "text/html; charset=utf-8") + h.Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'") + h.Set("X-Content-Type-Options", "nosniff") + h.Set("Referrer-Policy", "no-referrer") + h.Set("Cache-Control", "no-store") + w.WriteHeader(status) + if err := settingsTemplate.Execute(w, data); err != nil { + slog.Warn("settings page not rendered", "error", err) + } +} + +func (a *API) settingsPage(w http.ResponseWriter, r *http.Request) { + if !a.handoffEnabled() { + http.NotFound(w, r) + return + } + data := settingsPageData{} + if raw := r.URL.Query().Get("code"); raw != "" { + if a.Handoff.Open(raw) { + data.Code = normalizeUserCode(raw) + } else { + data.Error = "This code is unknown or has expired. Start again on the car." + } + } + renderSettingsPage(w, http.StatusOK, data) +} + +// phoneSettingsFromForm checks what the phone sent. Credentials are never put back into the +// page; the numbers are, so a typo is fixed without typing them again. +func phoneSettingsFromForm(form url.Values) (PhoneSettings, error) { + var s PhoneSettings + for _, f := range []struct { + name string + dst *string + }{{"abrpApiKey", &s.AbrpAPIKey}, {"abrpToken", &s.AbrpToken}} { + v := strings.TrimSpace(form.Get(f.name)) + if v == "" { + continue + } + if len(v) < 8 || len(v) > 200 || strings.ContainsAny(v, " \t\r\n") { + return PhoneSettings{}, errors.New("the ABRP API key or user token does not look right") + } + *f.dst = v + } + for _, f := range []struct { + name string + min, max float64 + dst **float64 + label string + }{{"price", 0, 100, &s.PricePerKwh, "charging price"}, {"usable", 10, 150, &s.UsableKwh, "usable capacity"}} { + v := strings.ReplaceAll(strings.TrimSpace(form.Get(f.name)), ",", ".") + if v == "" { + continue + } + n, err := strconv.ParseFloat(v, 64) + if err != nil || n < f.min || n > f.max { + return PhoneSettings{}, errors.New("the " + f.label + " is not a plausible number") + } + *f.dst = &n + } + if s.empty() { + return PhoneSettings{}, errors.New("nothing filled in") + } + return s, nil +} + +func (a *API) settingsSubmit(w http.ResponseWriter, r *http.Request) { + if !a.handoffEnabled() { + http.NotFound(w, r) + return + } + r.Body = http.MaxBytesReader(w, r.Body, 8192) + if err := r.ParseForm(); err != nil { + renderSettingsPage(w, http.StatusBadRequest, settingsPageData{Error: "The form could not be read."}) + return + } + code := normalizeUserCode(r.PostForm.Get("code")) + back := settingsPageData{Code: code, Price: r.PostForm.Get("price"), Usable: r.PostForm.Get("usable")} + switch err := a.Pairing.CheckAdmin(r.PostForm.Get("password"), clientAddress(r)); { + case errors.Is(err, errTooManyFailed): + back.Error = err.Error() + "." + renderSettingsPage(w, http.StatusTooManyRequests, back) + return + case err != nil: + back.Error = "Wrong password." + renderSettingsPage(w, http.StatusUnauthorized, back) + return + } + settings, err := phoneSettingsFromForm(r.PostForm) + if err != nil { + back.Error = strings.ToUpper(err.Error()[:1]) + err.Error()[1:] + "." + renderSettingsPage(w, http.StatusBadRequest, back) + return + } + switch err := a.Handoff.Fill(code, settings); { + case errors.Is(err, errAlreadyFilled): + renderSettingsPage(w, http.StatusConflict, settingsPageData{Error: "These settings were already sent. Start again on the car to send more."}) + return + case err != nil: + renderSettingsPage(w, http.StatusNotFound, settingsPageData{Error: "This code is unknown or has expired. Start again on the car."}) + return + } + slog.Info("settings sent from a phone") + renderSettingsPage(w, http.StatusOK, settingsPageData{Done: true, + Message: "Sent. The car picks the settings up within a few seconds — you can close this page."}) +} diff --git a/server/handoff_test.go b/server/handoff_test.go new file mode 100644 index 0000000..cb94947 --- /dev/null +++ b/server/handoff_test.go @@ -0,0 +1,143 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" +) + +func startHandoff(t *testing.T, h http.Handler, token string) (deviceCode, userCode string) { + t.Helper() + rec := do(t, h, "POST", "/v1/settings/start", token, `{"installationId":"`+testInstallation+`"}`) + if rec.Code != 201 { + t.Fatalf("start: %d %s", rec.Code, rec.Body) + } + var got map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got["verificationUriComplete"] != "https://car.example.com/settings?code="+got["userCode"].(string) { + t.Fatalf("uri %v", got["verificationUriComplete"]) + } + return got["deviceCode"].(string), got["userCode"].(string) +} + +func sendSettings(t *testing.T, h http.Handler, form url.Values) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest("POST", "/settings", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec +} + +func TestSettingsTravelFromThePhoneToTheCarOnce(t *testing.T) { + api, _ := newTestAPI() + h := api.Routes() + if rec := do(t, h, "POST", "/v1/settings/start", "", `{"installationId":"`+testInstallation+`"}`); rec.Code != 401 { + t.Fatalf("start without a token: %d", rec.Code) + } + deviceCode, userCode := startHandoff(t, h, testToken) + poll := func() *httptest.ResponseRecorder { + return do(t, h, "POST", "/v1/settings/poll", testToken, `{"deviceCode":"`+deviceCode+`"}`) + } + if rec := poll(); rec.Code != 202 { + t.Fatalf("before the phone: %d", rec.Code) + } + page := do(t, h, "GET", "/settings?code="+userCode, "", "") + if page.Code != 200 || !strings.Contains(page.Body.String(), userCode) { + t.Fatalf("page %d", page.Code) + } + + form := url.Values{"code": {userCode}, "abrpApiKey": {"api-key-1234"}, "abrpToken": {"user-token-5678"}, + "price": {"0,39"}, "password": {"wrong"}} + if rec := sendSettings(t, h, form); rec.Code != 401 || strings.Contains(rec.Body.String(), "user-token-5678") { + t.Fatalf("wrong password: %d (credentials must not be echoed)", rec.Code) + } + form.Set("password", testApprovalPassword) + if rec := sendSettings(t, h, form); rec.Code != 200 { + t.Fatalf("send: %d %s", rec.Code, rec.Body) + } + if rec := sendSettings(t, h, form); rec.Code != 409 { + t.Fatalf("sent twice: %d", rec.Code) + } + + rec := poll() + var got PhoneSettings + if rec.Code != 200 || json.Unmarshal(rec.Body.Bytes(), &got) != nil { + t.Fatalf("collect: %d %s", rec.Code, rec.Body) + } + if got.AbrpAPIKey != "api-key-1234" || got.AbrpToken != "user-token-5678" || got.PricePerKwh == nil || + *got.PricePerKwh != 0.39 || got.UsableKwh != nil { + t.Fatalf("settings %+v", got) + } + if rec.Header().Get("Cache-Control") != "no-store" { + t.Fatal("credentials cacheable") + } + if rec := poll(); rec.Code != 410 { + t.Fatalf("collected twice: %d", rec.Code) + } +} + +func TestSettingsAreOnlyForTheCarThatAsked(t *testing.T) { + api, _ := newTestAPI() + h := api.Routes() + deviceCode, userCode := startHandoff(t, h, testToken) + _ = api.Handoff.Fill(userCode, PhoneSettings{AbrpToken: "user-token-5678"}) + // A car paired as another installation cannot open a request for this one, nor collect it. + other := caller{installationID: "7e2b3c4d-5e6f-4a70-9b8c-1d2e3f4a5b6c"} + if s, err := api.Handoff.Poll(deviceCode, other.mayUse); s != nil || err == nil { + t.Fatal("another car collected the settings") + } + if s, err := api.Handoff.Poll(deviceCode, caller{}.mayUse); s == nil || err != nil { + t.Fatalf("the car itself could not collect: %v", err) + } +} + +func TestPhoneSettingsAreChecked(t *testing.T) { + for name, form := range map[string]url.Values{ + "nothing": {}, + "short token": {"abrpToken": {"abc"}}, + "spaced key": {"abrpApiKey": {"has a space here"}}, + "price absurd": {"price": {"250"}}, + "usable too low": {"usable": {"3"}}, + "not a number": {"price": {"cheap"}}, + } { + if _, err := phoneSettingsFromForm(form); err == nil { + t.Errorf("%s accepted", name) + } + } + s, err := phoneSettingsFromForm(url.Values{"usable": {" 50,8 "}}) + if err != nil || s.UsableKwh == nil || *s.UsableKwh != 50.8 || s.AbrpToken != "" { + t.Fatalf("%+v %v", s, err) + } +} + +func TestHandoffRequestsExpireAndAreReplaced(t *testing.T) { + h := NewHandoff() + first, _ := h.Start(testInstallation) + second, _ := h.Start(testInstallation) + if _, err := h.Poll(first.deviceCode, caller{}.mayUse); err == nil { + t.Fatal("the replaced request is still open") + } + h.Now = func() time.Time { return time.Now().Add(pairingTTL + time.Second) } + if err := h.Fill(second.userCode, PhoneSettings{AbrpToken: "user-token-5678"}); err == nil { + t.Fatal("an expired request took settings") + } +} + +func TestPhoneSettingsNeedPairingSwitchedOn(t *testing.T) { + api, _ := newTestAPI() + api.Pairing = NewPairing("", nil) + h := api.Routes() + if rec := do(t, h, "POST", "/v1/settings/start", testToken, `{"installationId":"`+testInstallation+`"}`); rec.Code != 404 { + t.Fatalf("start: %d", rec.Code) + } + if rec := do(t, h, "GET", "/settings", "", ""); rec.Code != 404 { + t.Fatalf("page: %d", rec.Code) + } +} diff --git a/server/main.go b/server/main.go index 1d99853..be65222 100644 --- a/server/main.go +++ b/server/main.go @@ -87,7 +87,8 @@ func main() { os.Exit(1) } pairing := NewPairing(cfg.approvalPass, devices) - api := &API{Store: store, IngestToken: cfg.ingestToken, Pairing: pairing, PublicURL: cfg.publicURL} + api := &API{Store: store, IngestToken: cfg.ingestToken, Pairing: pairing, Handoff: NewHandoff(), + PublicURL: cfg.publicURL} srv := &http.Server{ Addr: cfg.listenAddr, diff --git a/server/pairing_http.go b/server/pairing_http.go index 2456969..bece390 100644 --- a/server/pairing_http.go +++ b/server/pairing_http.go @@ -147,10 +147,8 @@ type pageData struct { Done bool } -var pairTemplate = template.Must(template.New("pair").Parse(` - -Pair a car — Nils' MG4 -
    +` + +var pairTemplate = template.Must(template.New("pair").Parse(` + +Pair a car — Nils' MG4 +

    Pair a car

    {{if .Done}}

    {{.Message}}

    From 66fed48aaebccdb708afa5d3806c58aefbef662f Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Sun, 18 Oct 2026 13:12:12 +0200 Subject: [PATCH 53/79] App 2.12.1: no scroll bars; energy since start The pages' scroll bars never faded on the car: a ScrollView shows its bar on every layout, and the live values lay the page out every two seconds. StableScrollView, the root of every page, now has none. A new "Energy since start" tile, left of "Usage since start", shows the net kWh taken from the pack since the trip began, recuperation counted back. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki --- SPECS.md | 3 +++ app/build.gradle | 4 ++-- .../main/java/com/evsuite/abrp/MainActivity.java | 8 +++++++- .../main/java/com/evsuite/abrp/RangeEstimator.java | 5 +++++ .../java/com/evsuite/abrp/StableScrollView.java | 14 ++++++++++++-- app/src/main/res/layout-w1000dp/pane_vehicle.xml | 6 +++++- app/src/main/res/layout/pane_vehicle.xml | 5 +++++ app/src/main/res/values/strings.xml | 4 ++++ .../java/com/evsuite/abrp/RangeEstimatorTest.java | 13 +++++++++++++ 9 files changed, 56 insertions(+), 6 deletions(-) diff --git a/SPECS.md b/SPECS.md index b2b7357..32ba765 100644 --- a/SPECS.md +++ b/SPECS.md @@ -113,6 +113,9 @@ tyre pressures, GPS (lat, lon, elevation, heading) and the is_* flags. It does * INGEST_TOKEN) into git and pushed it. Commit amended and force-pushed; both values regenerated in the now gitignored `k8s/secrets.yaml` (since moved to `k8s/secrets/secrets.yaml`, outside `kubectl apply -f k8s/`). Filled-in secrets live only there from now on. +- 2026-10-18 — App 2.12.1: no scroll bars (the 2-second refresh kept them from ever fading); + "Energy since start" tile (net kWh of the trip, recuperation counted back) left of "Usage since + start". - 2026-10-18 — App 2.12.0, server 2.5.0: settings entered on a phone. "Enter on phone" shows a QR code; the phone form on the server (approval password) takes the ABRP credentials, charging price and usable capacity, and the car collects them once (RFC 8628-style, like pairing; values diff --git a/app/build.gradle b/app/build.gradle index 1ddd708..15b2507 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -34,8 +34,8 @@ android { applicationId "com.stinnesbeck.nilsmg4" minSdk 27 targetSdk 36 - versionCode 75 - versionName "2.12.0" + versionCode 76 + versionName "2.12.1" // Nothing about the server ships in the APK: its URL and ingest token are entered in // the app's Server tab (ServerConfig), and the database is only reachable by the server. diff --git a/app/src/main/java/com/evsuite/abrp/MainActivity.java b/app/src/main/java/com/evsuite/abrp/MainActivity.java index 9372425..883b6fc 100644 --- a/app/src/main/java/com/evsuite/abrp/MainActivity.java +++ b/app/src/main/java/com/evsuite/abrp/MainActivity.java @@ -92,7 +92,8 @@ public class MainActivity extends AppCompatActivity { vehCharging, vehHvac, vehTireFl, vehTireFr, vehTireRl, vehTireRr, vehRangeTrip, vehRangeTripNote, vehRangeAverage, vehRangeAverageNote, - vehUsageTrip, vehUsageTripNote, vehUsageAverage, vehUsageAverageNote; + vehUsageTrip, vehUsageTripNote, vehUsageAverage, vehUsageAverageNote, + vehEnergyTrip, vehEnergyTripNote; /** Usable pack capacity for the range estimates; the car does not publish it. */ static final String USABLE_KWH_KEY = "usable_kwh"; @@ -1187,6 +1188,8 @@ public class MainActivity extends AppCompatActivity { vehUsageTripNote = vehiclePane.findViewById(R.id.vehicle_usage_trip_note); vehUsageAverage = vehiclePane.findViewById(R.id.vehicle_usage_average); vehUsageAverageNote = vehiclePane.findViewById(R.id.vehicle_usage_average_note); + vehEnergyTrip = vehiclePane.findViewById(R.id.vehicle_energy_trip); + vehEnergyTripNote = vehiclePane.findViewById(R.id.vehicle_energy_trip_note); } private double usableKwh() { @@ -1204,6 +1207,9 @@ public class MainActivity extends AppCompatActivity { estimator.add(monotonicMs, s.getBatteryPowerKw(), s.getSpeedKmh()); Double remaining = RangeEstimator.remainingKwh(s.getSocPercent(), usableKwh()); RangeEstimator.Estimate trip = estimator.sinceStart(remaining); + Double energy = estimator.energySinceStart(); + vehEnergyTrip.setText(energy == null ? "—" : getString(R.string.energy_kwh, energy)); + vehEnergyTripNote.setText(energy == null ? R.string.energy_trip_waiting : R.string.energy_trip_note); showEstimate(vehRangeTrip, vehRangeTripNote, vehUsageTrip, vehUsageTripNote, trip, trip == null ? "" : getString(R.string.usage_over_distance, trip.distanceKm), R.string.range_trip_waiting); diff --git a/app/src/main/java/com/evsuite/abrp/RangeEstimator.java b/app/src/main/java/com/evsuite/abrp/RangeEstimator.java index c3aa60d..8d55ea3 100644 --- a/app/src/main/java/com/evsuite/abrp/RangeEstimator.java +++ b/app/src/main/java/com/evsuite/abrp/RangeEstimator.java @@ -110,6 +110,11 @@ final class RangeEstimator { tripLast = s; } + /** Net energy taken from the pack since start, in kWh; null before the trip's first sample. */ + synchronized Double energySinceStart() { + return tripLast == null ? null : tripEnergyKwh; + } + /** Range at the consumption since start; null until the trip has some distance behind it. */ synchronized Estimate sinceStart(Double remainingKwh) { if (remainingKwh == null || tripDistanceKm < MIN_DISTANCE_KM) return null; diff --git a/app/src/main/java/com/evsuite/abrp/StableScrollView.java b/app/src/main/java/com/evsuite/abrp/StableScrollView.java index d760775..bf56944 100644 --- a/app/src/main/java/com/evsuite/abrp/StableScrollView.java +++ b/app/src/main/java/com/evsuite/abrp/StableScrollView.java @@ -16,18 +16,28 @@ import android.widget.ScrollView; * Here only scrolling that is not part of a layout counts as the driver's position, and every * layout restores it. ScrollView.scrollTo clamps against the real content height, so a * position that no longer exists still ends at the bottom rather than past it. + * + * No scroll bar: a ScrollView shows its bar on every layout, and the live values lay the page + * out every two seconds, so on the car it never faded. The pages scroll by touch. */ public class StableScrollView extends ScrollView { private int wantedScrollY; private boolean inLayout; - public StableScrollView(Context context) { super(context); } + public StableScrollView(Context context) { + super(context); + setVerticalScrollBarEnabled(false); + } - public StableScrollView(Context context, AttributeSet attrs) { super(context, attrs); } + public StableScrollView(Context context, AttributeSet attrs) { + super(context, attrs); + setVerticalScrollBarEnabled(false); + } public StableScrollView(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); + setVerticalScrollBarEnabled(false); } @Override diff --git a/app/src/main/res/layout-w1000dp/pane_vehicle.xml b/app/src/main/res/layout-w1000dp/pane_vehicle.xml index 3ec4f97..c5d1457 100644 --- a/app/src/main/res/layout-w1000dp/pane_vehicle.xml +++ b/app/src/main/res/layout-w1000dp/pane_vehicle.xml @@ -32,7 +32,11 @@ - + + + + + diff --git a/app/src/main/res/layout/pane_vehicle.xml b/app/src/main/res/layout/pane_vehicle.xml index 707b73b..c75c93c 100644 --- a/app/src/main/res/layout/pane_vehicle.xml +++ b/app/src/main/res/layout/pane_vehicle.xml @@ -21,6 +21,11 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e75f2cf..8133840 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -34,6 +34,10 @@ Range since start Range at 5-min average Usage since start + Energy since start + %1$.1f kWh + net, after recuperation + once the car is on Usage, 5-min average %1$.1f kWh/100 km > %1$d km diff --git a/app/src/test/java/com/evsuite/abrp/RangeEstimatorTest.java b/app/src/test/java/com/evsuite/abrp/RangeEstimatorTest.java index cb442da..b50d822 100644 --- a/app/src/test/java/com/evsuite/abrp/RangeEstimatorTest.java +++ b/app/src/test/java/com/evsuite/abrp/RangeEstimatorTest.java @@ -115,4 +115,17 @@ public class RangeEstimatorTest { assertEquals(-6.25, regen.kwhPer100Km, 0.01); assertTrue(Double.isInfinite(regen.rangeKm)); } + + @Test + public void energySinceStartIsTheNetEnergyOfTheTrip() { + RangeEstimator r = new RangeEstimator(); + assertNull(r.energySinceStart()); + r.add(0, 3f, 0f); + assertEquals(0.0, r.energySinceStart(), 1e-9); // the trip has begun + for (long t = 15_000; t <= 600_000; t += 15_000) r.add(t, 30f, 100f); + // 10 minutes at 30 kW = 5 kWh, plus the first 15 s ramp from 3 kW. + assertEquals(5.0, r.energySinceStart(), 0.1); + for (long t = 615_000; t <= 735_000; t += 15_000) r.add(t, -20f, 60f); // downhill + assertTrue(r.energySinceStart() < 5.0); // recuperation counts back + } } From 40906ec4a7e9b2a88bb8571b9072cea075657cb2 Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Sun, 18 Oct 2026 13:26:02 +0200 Subject: [PATCH 54/79] App 2.13.0: tyre pressures in bar; 5-minute average power MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tyre pressures show in bar by default; Settings → Display offers kPa and psi as well. Display only: the car reports kPa and the server keeps storing kPa. A "Power, 5-min average" card under Power: the time-weighted mean battery power over the last five minutes, standing included, from the same samples as the 5-minute range. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki --- SPECS.md | 3 ++ app/build.gradle | 4 +- .../java/com/evsuite/abrp/MainActivity.java | 37 ++++++++++++++--- .../java/com/evsuite/abrp/PressureUnit.java | 40 +++++++++++++++++++ .../java/com/evsuite/abrp/RangeEstimator.java | 23 +++++++++++ .../main/res/layout-w1000dp/pane_settings.xml | 2 + .../main/res/layout-w1000dp/pane_vehicle.xml | 6 ++- app/src/main/res/layout/pane_settings.xml | 2 + app/src/main/res/layout/pane_vehicle.xml | 5 +++ app/src/main/res/values/strings.xml | 4 ++ .../com/evsuite/abrp/PressureUnitTest.java | 29 ++++++++++++++ .../com/evsuite/abrp/RangeEstimatorTest.java | 12 ++++++ 12 files changed, 159 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/com/evsuite/abrp/PressureUnit.java create mode 100644 app/src/test/java/com/evsuite/abrp/PressureUnitTest.java diff --git a/SPECS.md b/SPECS.md index 32ba765..4866cf4 100644 --- a/SPECS.md +++ b/SPECS.md @@ -113,6 +113,9 @@ tyre pressures, GPS (lat, lon, elevation, heading) and the is_* flags. It does * INGEST_TOKEN) into git and pushed it. Commit amended and force-pushed; both values regenerated in the now gitignored `k8s/secrets.yaml` (since moved to `k8s/secrets/secrets.yaml`, outside `kubectl apply -f k8s/`). Filled-in secrets live only there from now on. +- 2026-10-18 — App 2.13.0: tyre pressures in bar by default, kPa or psi selectable (Settings → + Display; display only, the car and the server keep kPa). "Power, 5-min average" card under + Power: time-weighted mean battery power, standing included. - 2026-10-18 — App 2.12.1: no scroll bars (the 2-second refresh kept them from ever fading); "Energy since start" tile (net kWh of the trip, recuperation counted back) left of "Usage since start". diff --git a/app/build.gradle b/app/build.gradle index 15b2507..e82f366 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -34,8 +34,8 @@ android { applicationId "com.stinnesbeck.nilsmg4" minSdk 27 targetSdk 36 - versionCode 76 - versionName "2.12.1" + versionCode 77 + versionName "2.13.0" // Nothing about the server ships in the APK: its URL and ingest token are entered in // the app's Server tab (ServerConfig), and the database is only reachable by the server. diff --git a/app/src/main/java/com/evsuite/abrp/MainActivity.java b/app/src/main/java/com/evsuite/abrp/MainActivity.java index 883b6fc..f9eb258 100644 --- a/app/src/main/java/com/evsuite/abrp/MainActivity.java +++ b/app/src/main/java/com/evsuite/abrp/MainActivity.java @@ -93,7 +93,7 @@ public class MainActivity extends AppCompatActivity { vehTireFl, vehTireFr, vehTireRl, vehTireRr, vehRangeTrip, vehRangeTripNote, vehRangeAverage, vehRangeAverageNote, vehUsageTrip, vehUsageTripNote, vehUsageAverage, vehUsageAverageNote, - vehEnergyTrip, vehEnergyTripNote; + vehEnergyTrip, vehEnergyTripNote, vehPowerAverage, vehPowerAverageNote; /** Usable pack capacity for the range estimates; the car does not publish it. */ static final String USABLE_KWH_KEY = "usable_kwh"; @@ -977,6 +977,7 @@ public class MainActivity extends AppCompatActivity { voiceKeySwitch.setChecked(UploadSettings.voiceKeyOpensApp(prefs)); voiceKeySwitch.setOnCheckedChangeListener((btn, checked) -> prefs.edit().putBoolean(UploadSettings.KEY_VOICE_KEY_OPENS, checked).apply()); + bindTireUnit(); fullScreenSwitch = settingsPane.findViewById(R.id.full_screen_switch); fullScreenSwitch.setChecked(UploadSettings.fullScreen(prefs)); fullScreenSwitch.setOnCheckedChangeListener((btn, checked) -> { @@ -1057,6 +1058,26 @@ public class MainActivity extends AppCompatActivity { }, "abrp-test").start(); } + /** Tyre pressure unit: display only, read on every refresh of the Overview. */ + private void bindTireUnit() { + android.widget.Spinner spinner = settingsPane.findViewById(R.id.tire_unit_spinner); + PressureUnit[] units = PressureUnit.values(); + String[] labels = new String[units.length]; + for (int i = 0; i < units.length; i++) labels[i] = units[i].label; + android.widget.ArrayAdapter adapter = new android.widget.ArrayAdapter<>( + this, R.layout.spinner_value, labels); + adapter.setDropDownViewResource(R.layout.spinner_dropdown); + spinner.setAdapter(adapter); + spinner.setSelection(PressureUnit.fromPref(prefs.getString(PressureUnit.PREF_KEY, null)).ordinal()); + spinner.setOnItemSelectedListener(new android.widget.AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(android.widget.AdapterView parent, View view, int position, long id) { + prefs.edit().putString(PressureUnit.PREF_KEY, units[position].name()).apply(); + } + @Override public void onNothingSelected(android.widget.AdapterView parent) { } + }); + } + /** The service reads these settings on every tick, so a change applies without a restart. */ private void bindCadenceControls() { UploadSettings current = UploadSettings.from(prefs); @@ -1190,6 +1211,8 @@ public class MainActivity extends AppCompatActivity { vehUsageAverageNote = vehiclePane.findViewById(R.id.vehicle_usage_average_note); vehEnergyTrip = vehiclePane.findViewById(R.id.vehicle_energy_trip); vehEnergyTripNote = vehiclePane.findViewById(R.id.vehicle_energy_trip_note); + vehPowerAverage = vehiclePane.findViewById(R.id.vehicle_power_average); + vehPowerAverageNote = vehiclePane.findViewById(R.id.vehicle_power_average_note); } private double usableKwh() { @@ -1207,6 +1230,9 @@ public class MainActivity extends AppCompatActivity { estimator.add(monotonicMs, s.getBatteryPowerKw(), s.getSpeedKmh()); Double remaining = RangeEstimator.remainingKwh(s.getSocPercent(), usableKwh()); RangeEstimator.Estimate trip = estimator.sinceStart(remaining); + Double power = estimator.averagePowerKw(monotonicMs); + vehPowerAverage.setText(power == null ? "—" : getString(R.string.power_kw, power)); + vehPowerAverageNote.setText(power == null ? R.string.power_waiting : R.string.usage_last_5_min); Double energy = estimator.energySinceStart(); vehEnergyTrip.setText(energy == null ? "—" : getString(R.string.energy_kwh, energy)); vehEnergyTripNote.setText(energy == null ? R.string.energy_trip_waiting : R.string.energy_trip_note); @@ -1264,10 +1290,11 @@ public class MainActivity extends AppCompatActivity { vehCharging.setText(chargingLabel(s)); refreshRangeEstimates(s); - vehTireFl.setText(fmt(s.getTirePressures().getFrontLeftKpa(), "%.0f kPa")); - vehTireFr.setText(fmt(s.getTirePressures().getFrontRightKpa(), "%.0f kPa")); - vehTireRl.setText(fmt(s.getTirePressures().getRearLeftKpa(), "%.0f kPa")); - vehTireRr.setText(fmt(s.getTirePressures().getRearRightKpa(), "%.0f kPa")); + PressureUnit unit = PressureUnit.fromPref(prefs.getString(PressureUnit.PREF_KEY, null)); + vehTireFl.setText(unit.format(s.getTirePressures().getFrontLeftKpa())); + vehTireFr.setText(unit.format(s.getTirePressures().getFrontRightKpa())); + vehTireRl.setText(unit.format(s.getTirePressures().getRearLeftKpa())); + vehTireRr.setText(unit.format(s.getTirePressures().getRearRightKpa())); } catch (Throwable ignored) { // Leave the last good reading on screen. } diff --git a/app/src/main/java/com/evsuite/abrp/PressureUnit.java b/app/src/main/java/com/evsuite/abrp/PressureUnit.java new file mode 100644 index 0000000..51fd5ea --- /dev/null +++ b/app/src/main/java/com/evsuite/abrp/PressureUnit.java @@ -0,0 +1,40 @@ +package com.evsuite.abrp; + +import java.util.Locale; + +/** + * The unit tyre pressures are shown in. The car reports kPa; this only changes the display. + * Android-free, unit-tested. + */ +enum PressureUnit { + BAR("bar", "%.1f bar", 0.01), + KPA("kPa", "%.0f kPa", 1.0), + PSI("psi", "%.1f psi", 0.145038); + + static final String PREF_KEY = "tire_pressure_unit"; + static final PressureUnit DEFAULT = BAR; + + final String label; + private final String pattern; + private final double perKpa; + + PressureUnit(String label, String pattern, double perKpa) { + this.label = label; + this.pattern = pattern; + this.perKpa = perKpa; + } + + /** A reading in this unit, or a dash for none. */ + String format(Float kpa) { + if (kpa == null || kpa.isNaN() || kpa.isInfinite()) return "—"; + return String.format(Locale.US, pattern, kpa * perKpa); + } + + /** The stored choice; anything unknown falls back to bar. */ + static PressureUnit fromPref(String name) { + for (PressureUnit u : values()) { + if (u.name().equals(name)) return u; + } + return DEFAULT; + } +} diff --git a/app/src/main/java/com/evsuite/abrp/RangeEstimator.java b/app/src/main/java/com/evsuite/abrp/RangeEstimator.java index 8d55ea3..b982760 100644 --- a/app/src/main/java/com/evsuite/abrp/RangeEstimator.java +++ b/app/src/main/java/com/evsuite/abrp/RangeEstimator.java @@ -154,6 +154,29 @@ final class RangeEstimator { return new Estimate(rangeKm, kwhPerKm * 100, distanceKm); } + /** + * Mean battery power over the last five minutes, time-weighted, standing included; null with + * less than a minute of samples. Positive is drawn from the pack, as the Power tile shows it. + */ + synchronized Double averagePowerKw(long nowMs) { + double energyKwh = 0; + long covered = 0; + Sample previous = null; + for (Sample s : samples) { + if (s.timeMs < nowMs - WINDOW_MS) continue; + if (previous != null) { + long dt = s.timeMs - previous.timeMs; + if (dt > 0 && dt <= MAX_GAP_MS) { + energyKwh += (previous.powerKw + s.powerKw) / 2 * (dt / 3_600_000.0); + covered += dt; + } + } + previous = s; + } + if (covered < MIN_WINDOW_MS) return null; + return energyKwh / (covered / 3_600_000.0); + } + synchronized void clear() { samples.clear(); tripEnergyKwh = 0; diff --git a/app/src/main/res/layout-w1000dp/pane_settings.xml b/app/src/main/res/layout-w1000dp/pane_settings.xml index 0732e99..8dfb182 100644 --- a/app/src/main/res/layout-w1000dp/pane_settings.xml +++ b/app/src/main/res/layout-w1000dp/pane_settings.xml @@ -8,6 +8,8 @@ + + diff --git a/app/src/main/res/layout-w1000dp/pane_vehicle.xml b/app/src/main/res/layout-w1000dp/pane_vehicle.xml index c5d1457..44a9930 100644 --- a/app/src/main/res/layout-w1000dp/pane_vehicle.xml +++ b/app/src/main/res/layout-w1000dp/pane_vehicle.xml @@ -47,7 +47,11 @@ - + + + + + diff --git a/app/src/main/res/layout/pane_settings.xml b/app/src/main/res/layout/pane_settings.xml index 4d17a49..534f54b 100644 --- a/app/src/main/res/layout/pane_settings.xml +++ b/app/src/main/res/layout/pane_settings.xml @@ -7,6 +7,8 @@ + + diff --git a/app/src/main/res/layout/pane_vehicle.xml b/app/src/main/res/layout/pane_vehicle.xml index c75c93c..28d6816 100644 --- a/app/src/main/res/layout/pane_vehicle.xml +++ b/app/src/main/res/layout/pane_vehicle.xml @@ -40,6 +40,11 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8133840..e6e7459 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -35,6 +35,10 @@ Range at 5-min average Usage since start Energy since start + Power, 5-min average + %1$.1f kW + needs a minute of data + Tyre pressure unit %1$.1f kWh net, after recuperation once the car is on diff --git a/app/src/test/java/com/evsuite/abrp/PressureUnitTest.java b/app/src/test/java/com/evsuite/abrp/PressureUnitTest.java new file mode 100644 index 0000000..b7d8485 --- /dev/null +++ b/app/src/test/java/com/evsuite/abrp/PressureUnitTest.java @@ -0,0 +1,29 @@ +package com.evsuite.abrp; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +/** Tyre pressure display units — pure JVM. */ +public class PressureUnitTest { + + @Test + public void kpaFromTheCarInEachUnit() { + assertEquals("2.5 bar", PressureUnit.BAR.format(250f)); + assertEquals("250 kPa", PressureUnit.KPA.format(250f)); + assertEquals("36.3 psi", PressureUnit.PSI.format(250f)); + } + + @Test + public void anUnreadPressureStaysADash() { + assertEquals("—", PressureUnit.BAR.format(null)); + assertEquals("—", PressureUnit.PSI.format(Float.NaN)); + } + + @Test + public void barUnlessSomethingElseWasChosen() { + assertEquals(PressureUnit.BAR, PressureUnit.fromPref(null)); + assertEquals(PressureUnit.BAR, PressureUnit.fromPref("nonsense")); + assertEquals(PressureUnit.PSI, PressureUnit.fromPref("PSI")); + } +} diff --git a/app/src/test/java/com/evsuite/abrp/RangeEstimatorTest.java b/app/src/test/java/com/evsuite/abrp/RangeEstimatorTest.java index b50d822..b5f4a62 100644 --- a/app/src/test/java/com/evsuite/abrp/RangeEstimatorTest.java +++ b/app/src/test/java/com/evsuite/abrp/RangeEstimatorTest.java @@ -128,4 +128,16 @@ public class RangeEstimatorTest { for (long t = 615_000; t <= 735_000; t += 15_000) r.add(t, -20f, 60f); // downhill assertTrue(r.energySinceStart() < 5.0); // recuperation counts back } + + @Test + public void averagePowerOverTheLastFiveMinutes() { + RangeEstimator r = new RangeEstimator(); + r.add(0, 20f, 50f); + assertNull(r.averagePowerKw(0)); // too little yet + for (long t = 15_000; t <= 300_000; t += 15_000) r.add(t, 20f, 50f); + assertEquals(20.0, r.averagePowerKw(300_000), 1e-6); + // Standing with the climate on counts: half the window at 2 kW. + for (long t = 315_000; t <= 450_000; t += 15_000) r.add(t, 2f, 0f); + assertEquals(11.0, r.averagePowerKw(450_000), 1.0); + } } From 9b5fcc0734178b1ca9f996b9e9d1142fd3077aaf Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Sun, 18 Oct 2026 13:52:57 +0200 Subject: [PATCH 55/79] App 2.14.0: accent colour after the MG4 paints; car colour probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The accent is selectable in Settings → Display: Dynamic Red (default), Volcano Orange, Holborn Blue, Racing Green, Camden Grey, Monument Silver, Arctic White and Black Pearl. White, silver and black cannot be an accent on the black screen as they are, so they become light greys and a pearl blue-grey. The accent palette is now theme attributes (attrs_accent.xml) with one overlay per paint (accents.xml), applied before the activity inflates; layouts, colour lists, drawables and the views that draw in code use them, including the red tints that were hard-coded. A change recreates the activity on the same page. Android 9 crashes on gradients with theme colours, so the battery card is built from layers. Logs → "Look for the car colour" lists the head unit's system properties that mention colour, paint or car model, to find out whether the paint colour can be read from the car. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki --- AGENTS.md | 4 +- SPECS.md | 8 ++ app/build.gradle | 4 +- .../java/com/evsuite/abrp/AccentColor.java | 51 +++++++++++ .../java/com/evsuite/abrp/CarColorProbe.java | 59 +++++++++++++ .../com/evsuite/abrp/ChargingGraphView.java | 2 +- .../java/com/evsuite/abrp/MainActivity.java | 54 +++++++++++- .../com/evsuite/abrp/PremiumBackdropView.java | 11 ++- app/src/main/res/color/input_stroke.xml | 2 +- app/src/main/res/color/nav_tab_background.xml | 2 +- app/src/main/res/color/nav_tab_foreground.xml | 2 +- app/src/main/res/color/nav_tab_stroke.xml | 2 +- app/src/main/res/color/switch_thumb.xml | 2 +- app/src/main/res/color/switch_track.xml | 2 +- app/src/main/res/drawable/bg_battery_card.xml | 16 +++- app/src/main/res/drawable/bg_input_panel.xml | 2 +- app/src/main/res/drawable/bg_topbar.xml | 2 +- .../main/res/drawable/bg_version_badge.xml | 4 +- app/src/main/res/drawable/ic_chevron_down.xml | 2 +- app/src/main/res/drawable/ic_info.xml | 2 +- .../main/res/drawable/ic_vehicle_outline.xml | 6 +- .../main/res/layout-w1000dp/activity_main.xml | 4 +- .../main/res/layout-w1000dp/pane_settings.xml | 2 + .../main/res/layout-w1000dp/pane_vehicle.xml | 22 ++--- .../main/res/layout-w600dp/dialog_about.xml | 4 +- app/src/main/res/layout/activity_main.xml | 2 +- app/src/main/res/layout/dialog_about.xml | 4 +- app/src/main/res/layout/pane_charging.xml | 4 +- app/src/main/res/layout/pane_consumption.xml | 4 +- app/src/main/res/layout/pane_log.xml | 4 + app/src/main/res/layout/pane_settings.xml | 2 + app/src/main/res/layout/pane_vehicle.xml | 22 ++--- app/src/main/res/values/accents.xml | 87 +++++++++++++++++++ app/src/main/res/values/attrs_accent.xml | 13 +++ app/src/main/res/values/colors.xml | 7 +- app/src/main/res/values/strings.xml | 5 ++ app/src/main/res/values/styles_app.xml | 6 +- app/src/main/res/values/styles_ev.xml | 6 +- app/src/main/res/values/themes.xml | 16 +++- .../com/evsuite/abrp/AccentColorTest.java | 22 +++++ .../com/evsuite/abrp/CarColorProbeTest.java | 24 +++++ 41 files changed, 425 insertions(+), 74 deletions(-) create mode 100644 app/src/main/java/com/evsuite/abrp/AccentColor.java create mode 100644 app/src/main/java/com/evsuite/abrp/CarColorProbe.java create mode 100644 app/src/main/res/values/accents.xml create mode 100644 app/src/main/res/values/attrs_accent.xml create mode 100644 app/src/test/java/com/evsuite/abrp/AccentColorTest.java create mode 100644 app/src/test/java/com/evsuite/abrp/CarColorProbeTest.java diff --git a/AGENTS.md b/AGENTS.md index e6ec9a0..7eb7106 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,9 @@ workspace `DESIGN.md` apply; this file contains only telemetry-specific addition ## App-specific visual direction (2.2.6) The user explicitly requested a permanent OLED-black theme, charcoal glass-like cards, -MG red accents (#E0424C, with a light #FF9A9E and a deep #3A1215 variant; they replaced the earlier muted gold and cyan). These app resources intentionally override the shared +an accent the user picks from the MG4 paint colours (Dynamic Red #E0424C by default; the palette +is theme attributes in `res/values/attrs_accent.xml` and `accents.xml` — use `?attr/evAccent…`, +never a hard-coded accent hex). These app resources intentionally override the shared DESIGN.md palette, DayNight preference and flat-surface rules. Do not overwrite them with the shared token generator. Use static gradients rather than continuous effects; keep large touch targets, readable text and null telemetry shown as a dash. diff --git a/SPECS.md b/SPECS.md index 4866cf4..7692eb7 100644 --- a/SPECS.md +++ b/SPECS.md @@ -113,6 +113,14 @@ tyre pressures, GPS (lat, lon, elevation, heading) and the is_* flags. It does * INGEST_TOKEN) into git and pushed it. Commit amended and force-pushed; both values regenerated in the now gitignored `k8s/secrets.yaml` (since moved to `k8s/secrets/secrets.yaml`, outside `kubectl apply -f k8s/`). Filled-in secrets live only there from now on. +- 2026-10-18 — App 2.14.0: accent colour selectable after the MG4 paints (Settings → Display): + Dynamic Red (default), Volcano Orange, Holborn Blue, Racing Green, Camden Grey, Monument Silver, + Arctic White, Black Pearl; white, silver and black become light greys and a pearl blue-grey, + since they cannot be an accent on black. The accent palette is theme attributes + (`attrs_accent.xml`, overlays in `accents.xml`); a change recreates the activity on the same + page. Android 9 crashes on gradients with theme colours, so the battery card is layered. + Logs tab: "Look for the car colour" lists system properties that mention colour, paint or car + model, to find out whether the paint colour can be read (nothing on the emulator). - 2026-10-18 — App 2.13.0: tyre pressures in bar by default, kPa or psi selectable (Settings → Display; display only, the car and the server keep kPa). "Power, 5-min average" card under Power: time-weighted mean battery power, standing included. diff --git a/app/build.gradle b/app/build.gradle index e82f366..03d0b55 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -34,8 +34,8 @@ android { applicationId "com.stinnesbeck.nilsmg4" minSdk 27 targetSdk 36 - versionCode 77 - versionName "2.13.0" + versionCode 78 + versionName "2.14.0" // Nothing about the server ships in the APK: its URL and ingest token are entered in // the app's Server tab (ServerConfig), and the database is only reachable by the server. diff --git a/app/src/main/java/com/evsuite/abrp/AccentColor.java b/app/src/main/java/com/evsuite/abrp/AccentColor.java new file mode 100644 index 0000000..762c4f2 --- /dev/null +++ b/app/src/main/java/com/evsuite/abrp/AccentColor.java @@ -0,0 +1,51 @@ +package com.evsuite.abrp; + +import android.content.Context; +import android.util.TypedValue; + +/** + * The app's accent colour, after the MG4 paint colours; Dynamic Red unless another is chosen + * (Settings → Display). Each is a theme overlay in res/values/accents.xml that MainActivity lays + * over the app theme before it inflates anything; a change recreates the activity. + */ +enum AccentColor { + DYNAMIC_RED("Dynamic Red", R.style.ThemeOverlay_EV_Accent_DynamicRed), + VOLCANO_ORANGE("Volcano Orange", R.style.ThemeOverlay_EV_Accent_VolcanoOrange), + HOLBORN_BLUE("Holborn Blue", R.style.ThemeOverlay_EV_Accent_HolbornBlue), + RACING_GREEN("Racing Green", R.style.ThemeOverlay_EV_Accent_RacingGreen), + CAMDEN_GREY("Camden Grey", R.style.ThemeOverlay_EV_Accent_CamdenGrey), + MONUMENT_SILVER("Monument Silver", R.style.ThemeOverlay_EV_Accent_MonumentSilver), + ARCTIC_WHITE("Arctic White", R.style.ThemeOverlay_EV_Accent_ArcticWhite), + BLACK_PEARL("Black Pearl", R.style.ThemeOverlay_EV_Accent_BlackPearl); + + static final String PREF_KEY = "accent_color"; + static final AccentColor DEFAULT = DYNAMIC_RED; + + final String label; + final int overlay; + + AccentColor(String label, int overlay) { + this.label = label; + this.overlay = overlay; + } + + /** The stored choice; anything unknown falls back to Dynamic Red. */ + static AccentColor fromPref(String name) { + for (AccentColor c : values()) { + if (c.name().equals(name)) return c; + } + return DEFAULT; + } + + /** A colour of the current accent palette, e.g. {@code R.attr.evAccent}, for code that draws. */ + static int resolve(Context context, int attr) { + TypedValue value = new TypedValue(); + context.getTheme().resolveAttribute(attr, value, true); + return value.data; + } + + /** The colour with its alpha replaced, for glows and grids drawn in code. */ + static int withAlpha(int color, int alpha) { + return (color & 0x00FFFFFF) | (alpha << 24); + } +} diff --git a/app/src/main/java/com/evsuite/abrp/CarColorProbe.java b/app/src/main/java/com/evsuite/abrp/CarColorProbe.java new file mode 100644 index 0000000..3800e07 --- /dev/null +++ b/app/src/main/java/com/evsuite/abrp/CarColorProbe.java @@ -0,0 +1,59 @@ +package com.evsuite.abrp; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Looks for the car's paint colour among the head unit's system properties, for the Logs tab. + * + * The MG launcher draws the car in its own colour, so the head unit knows it somewhere; where is + * not documented. This lists every property whose name or value mentions a colour or paint, so + * a photo of the Logs tab shows whether (and how) it can be read. Read-only: it runs the + * platform's own getprop, which only reads. + */ +final class CarColorProbe { + + private static final Pattern LINE = Pattern.compile("^\\[([^\\]]*)\\]: \\[(.*)\\]$"); + private static final Pattern INTERESTING = Pattern.compile( + "(?i)colou?r|paint|body_?col|exterior|car_?model|vehicle_?(config|type|model)"); + + private CarColorProbe() { } + + /** getprop's output, filtered; at most {@code max} lines. Never throws. */ + static List run(int max) { + try { + Process p = new ProcessBuilder("getprop").redirectErrorStream(true).start(); + StringBuilder out = new StringBuilder(); + try (BufferedReader r = new BufferedReader(new InputStreamReader(p.getInputStream(), StandardCharsets.UTF_8))) { + String line; + while ((line = r.readLine()) != null && out.length() < 512 * 1024) out.append(line).append('\n'); + } + p.waitFor(); + return filter(out.toString(), max); + } catch (Exception e) { + List failed = new ArrayList<>(); + failed.add("getprop failed: " + e.getClass().getSimpleName()); + return failed; + } + } + + /** The "name = value" lines whose name or value looks colour- or model-related. Pure. */ + static List filter(String getpropOutput, int max) { + List found = new ArrayList<>(); + for (String line : getpropOutput.split("\n")) { + Matcher m = LINE.matcher(line.trim()); + if (!m.matches()) continue; + String name = m.group(1), value = m.group(2); + if (INTERESTING.matcher(name).find() || INTERESTING.matcher(value).find()) { + found.add(name + " = " + value); + if (found.size() >= max) break; + } + } + return found; + } +} diff --git a/app/src/main/java/com/evsuite/abrp/ChargingGraphView.java b/app/src/main/java/com/evsuite/abrp/ChargingGraphView.java index be73a5c..a4c7ccc 100644 --- a/app/src/main/java/com/evsuite/abrp/ChargingGraphView.java +++ b/app/src/main/java/com/evsuite/abrp/ChargingGraphView.java @@ -14,7 +14,7 @@ public final class ChargingGraphView extends View { private final Paint grid = new Paint(1), line = new Paint(1), text = new Paint(1); private final List points = new ArrayList<>(); private final float[] socPower = new float[101]; - public ChargingGraphView(Context c, AttributeSet a) { super(c,a); java.util.Arrays.fill(socPower, Float.NaN); grid.setColor(0x55E0858A); line.setColor(0xFFFFC857); line.setStrokeWidth(4); text.setColor(0xFFBDC4C6); text.setTextSize(20); } + public ChargingGraphView(Context c, AttributeSet a) { super(c,a); java.util.Arrays.fill(socPower, Float.NaN); grid.setColor(AccentColor.withAlpha(AccentColor.resolve(c, R.attr.evAccentLight), 0x55)); line.setColor(0xFFFFC857); line.setStrokeWidth(4); text.setColor(0xFFBDC4C6); text.setTextSize(20); } synchronized void add(Float soc, Float power) { if (soc == null || power == null || power >= 0) return; float p = Math.abs(power); diff --git a/app/src/main/java/com/evsuite/abrp/MainActivity.java b/app/src/main/java/com/evsuite/abrp/MainActivity.java index f9eb258..d86d2ce 100644 --- a/app/src/main/java/com/evsuite/abrp/MainActivity.java +++ b/app/src/main/java/com/evsuite/abrp/MainActivity.java @@ -227,11 +227,13 @@ public class MainActivity extends AppCompatActivity { protected void onCreate(Bundle savedInstanceState) { getDelegate().setLocalNightMode(AppCompatDelegate.MODE_NIGHT_YES); super.onCreate(savedInstanceState); + prefs = getSharedPreferences("abrp_prefs", MODE_PRIVATE); + // Before anything is inflated: every ?attr/evAccent… resolves against this overlay. + getTheme().applyStyle(AccentColor.fromPref(prefs.getString(AccentColor.PREF_KEY, null)).overlay, true); setContentView(R.layout.activity_main); ((TextView) findViewById(R.id.version_badge)).setText("v" + BuildConfig.VERSION_NAME); - prefs = getSharedPreferences("abrp_prefs", MODE_PRIVATE); // The pages are inflated here, once, and handed to the pager as fixed, // non-recycled items. That is what lets every widget below be looked up now and @@ -259,9 +261,20 @@ public class MainActivity extends AppCompatActivity { statusText = servicePane.findViewById(R.id.status_text); callLogText = logPane.findViewById(R.id.call_log_text); steeringKeysText = logPane.findViewById(R.id.steering_keys_text); + TextView carColorText = logPane.findViewById(R.id.car_color_text); + logPane.findViewById(R.id.car_color_button).setOnClickListener(v -> new Thread(() -> { + java.util.List found = CarColorProbe.run(60); + String shown = found.isEmpty() ? getString(R.string.car_color_none) + : android.text.TextUtils.join("\n", found); + runOnUiThread(() -> carColorText.setText(shown)); + }, "car-color-probe").start()); logPane.findViewById(R.id.flush_now_button).setOnClickListener(v -> manualFlush()); setUpPager(); + // Back on the page that was open when the activity was recreated (accent colour change). + if (savedInstanceState != null) { + ((ViewPager2) findViewById(R.id.content)).setCurrentItem(savedInstanceState.getInt(STATE_PAGE, 0), false); + } findViewById(R.id.about_button).setOnClickListener(v -> showAbout()); findViewById(R.id.update_button).setOnClickListener(v -> checkForUpdate(v)); @@ -275,6 +288,14 @@ public class MainActivity extends AppCompatActivity { startForegroundService(new Intent(this, AbrpUploadService.class)); } + private static final String STATE_PAGE = "page"; + + @Override + protected void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + outState.putInt(STATE_PAGE, ((ViewPager2) findViewById(R.id.content)).getCurrentItem()); + } + private void showAbout() { String version; try { @@ -710,7 +731,7 @@ public class MainActivity extends AppCompatActivity { code.setTypeface(android.graphics.Typeface.MONOSPACE, android.graphics.Typeface.BOLD); code.setTextSize(36); code.setLetterSpacing(0.15f); - code.setTextColor(getColor(R.color.ev_cyan)); + code.setTextColor(AccentColor.resolve(MainActivity.this, R.attr.evAccentLight)); status = new TextView(MainActivity.this); status.setTextAppearance(R.style.Text_EV_Body); status.setText(startingText); @@ -978,6 +999,7 @@ public class MainActivity extends AppCompatActivity { voiceKeySwitch.setOnCheckedChangeListener((btn, checked) -> prefs.edit().putBoolean(UploadSettings.KEY_VOICE_KEY_OPENS, checked).apply()); bindTireUnit(); + bindAccentColor(); fullScreenSwitch = settingsPane.findViewById(R.id.full_screen_switch); fullScreenSwitch.setChecked(UploadSettings.fullScreen(prefs)); fullScreenSwitch.setOnCheckedChangeListener((btn, checked) -> { @@ -1058,6 +1080,29 @@ public class MainActivity extends AppCompatActivity { }, "abrp-test").start(); } + /** The accent colour; a change recreates the activity so every view takes it. */ + private void bindAccentColor() { + android.widget.Spinner spinner = settingsPane.findViewById(R.id.accent_spinner); + AccentColor[] colors = AccentColor.values(); + String[] labels = new String[colors.length]; + for (int i = 0; i < colors.length; i++) labels[i] = colors[i].label; + android.widget.ArrayAdapter adapter = new android.widget.ArrayAdapter<>( + this, R.layout.spinner_value, labels); + adapter.setDropDownViewResource(R.layout.spinner_dropdown); + spinner.setAdapter(adapter); + AccentColor current = AccentColor.fromPref(prefs.getString(AccentColor.PREF_KEY, null)); + spinner.setSelection(current.ordinal()); + spinner.setOnItemSelectedListener(new android.widget.AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(android.widget.AdapterView parent, View view, int position, long id) { + if (colors[position] == current) return; + prefs.edit().putString(AccentColor.PREF_KEY, colors[position].name()).apply(); + recreate(); + } + @Override public void onNothingSelected(android.widget.AdapterView parent) { } + }); + } + /** Tyre pressure unit: display only, read on every refresh of the Overview. */ private void bindTireUnit() { android.widget.Spinner spinner = settingsPane.findViewById(R.id.tire_unit_spinner); @@ -1388,9 +1433,10 @@ public class MainActivity extends AppCompatActivity { MaterialButton button = consumptionPane.findViewById(id); boolean selected = id == selectedId; button.setSelected(selected); - button.setTextColor(selected ? 0xFFE0424C : COLOR_PENDING); + int accent = AccentColor.resolve(this, R.attr.evAccent); + button.setTextColor(selected ? accent : COLOR_PENDING); button.setStrokeColor(android.content.res.ColorStateList.valueOf( - selected ? 0xFFE0424C : 0xFF3A4143)); + selected ? accent : 0xFF3A4143)); } } diff --git a/app/src/main/java/com/evsuite/abrp/PremiumBackdropView.java b/app/src/main/java/com/evsuite/abrp/PremiumBackdropView.java index fa843b1..b74e90c 100644 --- a/app/src/main/java/com/evsuite/abrp/PremiumBackdropView.java +++ b/app/src/main/java/com/evsuite/abrp/PremiumBackdropView.java @@ -8,7 +8,10 @@ import android.graphics.Shader; import android.util.AttributeSet; import android.view.View; -/** Static, low-opacity light behind the glass panels; no blur passes or animation loop. */ +/** + * Static, low-opacity light behind the glass panels, in the accent colour; no blur passes or + * animation loop. + */ public final class PremiumBackdropView extends View { private final Paint cyan = new Paint(Paint.ANTI_ALIAS_FLAG); private final Paint gold = new Paint(Paint.ANTI_ALIAS_FLAG); @@ -20,10 +23,12 @@ public final class PremiumBackdropView extends View { @Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { float radius = Math.max(1, Math.max(w, h) * 0.65f); + int deep = AccentColor.resolve(getContext(), R.attr.evAccentDeep); + int stroke = AccentColor.resolve(getContext(), R.attr.evAccentStroke); cyan.setShader(new RadialGradient(w * 0.70f, 0, radius, - new int[]{0x245B191D, 0x00000000}, null, Shader.TileMode.CLAMP)); + new int[]{AccentColor.withAlpha(deep, 0x48), 0x00000000}, null, Shader.TileMode.CLAMP)); gold.setShader(new RadialGradient(w, h, radius * 0.65f, - new int[]{0x1C8B3A3F, 0x00000000}, null, Shader.TileMode.CLAMP)); + new int[]{AccentColor.withAlpha(stroke, 0x1C), 0x00000000}, null, Shader.TileMode.CLAMP)); } @Override protected void onDraw(Canvas canvas) { diff --git a/app/src/main/res/color/input_stroke.xml b/app/src/main/res/color/input_stroke.xml index 852e4d5..1928860 100644 --- a/app/src/main/res/color/input_stroke.xml +++ b/app/src/main/res/color/input_stroke.xml @@ -1 +1 @@ - + diff --git a/app/src/main/res/color/nav_tab_background.xml b/app/src/main/res/color/nav_tab_background.xml index 42a552f..efd63c6 100644 --- a/app/src/main/res/color/nav_tab_background.xml +++ b/app/src/main/res/color/nav_tab_background.xml @@ -1 +1 @@ - + diff --git a/app/src/main/res/color/nav_tab_foreground.xml b/app/src/main/res/color/nav_tab_foreground.xml index 64477ea..6abaedc 100644 --- a/app/src/main/res/color/nav_tab_foreground.xml +++ b/app/src/main/res/color/nav_tab_foreground.xml @@ -1 +1 @@ - + diff --git a/app/src/main/res/color/nav_tab_stroke.xml b/app/src/main/res/color/nav_tab_stroke.xml index 9a29683..3c88fab 100644 --- a/app/src/main/res/color/nav_tab_stroke.xml +++ b/app/src/main/res/color/nav_tab_stroke.xml @@ -1 +1 @@ - + diff --git a/app/src/main/res/color/switch_thumb.xml b/app/src/main/res/color/switch_thumb.xml index deef655..656d80c 100644 --- a/app/src/main/res/color/switch_thumb.xml +++ b/app/src/main/res/color/switch_thumb.xml @@ -1,6 +1,6 @@ - + diff --git a/app/src/main/res/color/switch_track.xml b/app/src/main/res/color/switch_track.xml index 145e36c..176f030 100644 --- a/app/src/main/res/color/switch_track.xml +++ b/app/src/main/res/color/switch_track.xml @@ -1,6 +1,6 @@ - + diff --git a/app/src/main/res/drawable/bg_battery_card.xml b/app/src/main/res/drawable/bg_battery_card.xml index 2201dc5..37f6020 100644 --- a/app/src/main/res/drawable/bg_battery_card.xml +++ b/app/src/main/res/drawable/bg_battery_card.xml @@ -1 +1,15 @@ - + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/bg_input_panel.xml b/app/src/main/res/drawable/bg_input_panel.xml index e2b1f97..8eb88fe 100644 --- a/app/src/main/res/drawable/bg_input_panel.xml +++ b/app/src/main/res/drawable/bg_input_panel.xml @@ -1 +1 @@ - + diff --git a/app/src/main/res/drawable/bg_topbar.xml b/app/src/main/res/drawable/bg_topbar.xml index 1d5b3ba..1a26e08 100644 --- a/app/src/main/res/drawable/bg_topbar.xml +++ b/app/src/main/res/drawable/bg_topbar.xml @@ -9,7 +9,7 @@ - + diff --git a/app/src/main/res/drawable/bg_version_badge.xml b/app/src/main/res/drawable/bg_version_badge.xml index 81362e0..d87eb68 100644 --- a/app/src/main/res/drawable/bg_version_badge.xml +++ b/app/src/main/res/drawable/bg_version_badge.xml @@ -1,6 +1,6 @@ - - + + diff --git a/app/src/main/res/drawable/ic_chevron_down.xml b/app/src/main/res/drawable/ic_chevron_down.xml index 8cd2a5d..c290ce9 100644 --- a/app/src/main/res/drawable/ic_chevron_down.xml +++ b/app/src/main/res/drawable/ic_chevron_down.xml @@ -2,5 +2,5 @@ + android:strokeColor="?attr/evAccent" android:strokeWidth="1.8" android:strokeLineCap="round" android:strokeLineJoin="round" /> diff --git a/app/src/main/res/drawable/ic_info.xml b/app/src/main/res/drawable/ic_info.xml index 78f93a3..b61bd65 100644 --- a/app/src/main/res/drawable/ic_info.xml +++ b/app/src/main/res/drawable/ic_info.xml @@ -1 +1 @@ - + diff --git a/app/src/main/res/drawable/ic_vehicle_outline.xml b/app/src/main/res/drawable/ic_vehicle_outline.xml index 365da1d..806b126 100644 --- a/app/src/main/res/drawable/ic_vehicle_outline.xml +++ b/app/src/main/res/drawable/ic_vehicle_outline.xml @@ -1,5 +1,5 @@ - - - + + + diff --git a/app/src/main/res/layout-w1000dp/activity_main.xml b/app/src/main/res/layout-w1000dp/activity_main.xml index 9f322f1..9c564a4 100644 --- a/app/src/main/res/layout-w1000dp/activity_main.xml +++ b/app/src/main/res/layout-w1000dp/activity_main.xml @@ -4,7 +4,7 @@ - + @@ -22,7 +22,7 @@ - + diff --git a/app/src/main/res/layout-w1000dp/pane_settings.xml b/app/src/main/res/layout-w1000dp/pane_settings.xml index 8dfb182..12005cd 100644 --- a/app/src/main/res/layout-w1000dp/pane_settings.xml +++ b/app/src/main/res/layout-w1000dp/pane_settings.xml @@ -10,6 +10,8 @@ + + diff --git a/app/src/main/res/layout-w1000dp/pane_vehicle.xml b/app/src/main/res/layout-w1000dp/pane_vehicle.xml index 44a9930..c588ec7 100644 --- a/app/src/main/res/layout-w1000dp/pane_vehicle.xml +++ b/app/src/main/res/layout-w1000dp/pane_vehicle.xml @@ -5,7 +5,7 @@ - + @@ -14,12 +14,12 @@ - + - + @@ -35,22 +35,22 @@ - + - + - + - + @@ -74,18 +74,18 @@ - + - + - + - + diff --git a/app/src/main/res/layout-w600dp/dialog_about.xml b/app/src/main/res/layout-w600dp/dialog_about.xml index d0994a6..73569f8 100644 --- a/app/src/main/res/layout-w600dp/dialog_about.xml +++ b/app/src/main/res/layout-w600dp/dialog_about.xml @@ -4,14 +4,14 @@ - + - + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 27ba5ae..376a9f8 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -8,7 +8,7 @@ - + diff --git a/app/src/main/res/layout/dialog_about.xml b/app/src/main/res/layout/dialog_about.xml index 5b4a7ac..d552f4c 100644 --- a/app/src/main/res/layout/dialog_about.xml +++ b/app/src/main/res/layout/dialog_about.xml @@ -2,13 +2,13 @@ - + - + diff --git a/app/src/main/res/layout/pane_charging.xml b/app/src/main/res/layout/pane_charging.xml index 5fab99e..43c34d0 100644 --- a/app/src/main/res/layout/pane_charging.xml +++ b/app/src/main/res/layout/pane_charging.xml @@ -2,7 +2,7 @@ - + @@ -18,7 +18,7 @@ - + diff --git a/app/src/main/res/layout/pane_consumption.xml b/app/src/main/res/layout/pane_consumption.xml index 791c8fb..3f19a5a 100644 --- a/app/src/main/res/layout/pane_consumption.xml +++ b/app/src/main/res/layout/pane_consumption.xml @@ -4,7 +4,7 @@ - + @@ -29,7 +29,7 @@ - + diff --git a/app/src/main/res/layout/pane_log.xml b/app/src/main/res/layout/pane_log.xml index 17f220e..b6ba2e1 100644 --- a/app/src/main/res/layout/pane_log.xml +++ b/app/src/main/res/layout/pane_log.xml @@ -7,5 +7,9 @@ + + + + diff --git a/app/src/main/res/layout/pane_settings.xml b/app/src/main/res/layout/pane_settings.xml index 534f54b..943732d 100644 --- a/app/src/main/res/layout/pane_settings.xml +++ b/app/src/main/res/layout/pane_settings.xml @@ -9,6 +9,8 @@ + + diff --git a/app/src/main/res/layout/pane_vehicle.xml b/app/src/main/res/layout/pane_vehicle.xml index 28d6816..3a27579 100644 --- a/app/src/main/res/layout/pane_vehicle.xml +++ b/app/src/main/res/layout/pane_vehicle.xml @@ -5,7 +5,7 @@ - + @@ -14,27 +14,27 @@ - + - + - + - + - + @@ -43,7 +43,7 @@ - + @@ -69,18 +69,18 @@ - + - + - + - + diff --git a/app/src/main/res/values/accents.xml b/app/src/main/res/values/accents.xml new file mode 100644 index 0000000..b781709 --- /dev/null +++ b/app/src/main/res/values/accents.xml @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/attrs_accent.xml b/app/src/main/res/values/attrs_accent.xml new file mode 100644 index 0000000..518aa16 --- /dev/null +++ b/app/src/main/res/values/attrs_accent.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index c5f6be3..6fdc37f 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,8 +1,7 @@ - + #000000 #121212 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e6e7459..15ab486 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -39,6 +39,11 @@ %1$.1f kW needs a minute of data Tyre pressure unit + Accent colour + CAR COLOUR + Lists the head unit\'s settings that mention a colour, paint or car model, to find out whether the car\'s colour can be read. A photo of this list helps. + Look for the car colour + Nothing colour-related found %1$.1f kWh net, after recuperation once the car is on diff --git a/app/src/main/res/values/styles_app.xml b/app/src/main/res/values/styles_app.xml index cfeef15..1b39cf5 100644 --- a/app/src/main/res/values/styles_app.xml +++ b/app/src/main/res/values/styles_app.xml @@ -39,19 +39,19 @@ 16dp 16dp 16dp - @color/ev_accent + ?attr/evAccent @color/ev_text_secondary @style/Text.EV.Secondary diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index d6cc7f8..9432ab5 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -3,15 +3,23 @@ + + + From 79bcfa05f61efa6d5d202b71b16caa32fa337a1c Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Sun, 18 Oct 2026 20:11:25 +0200 Subject: [PATCH 70/79] App 2.16.4: drop-downs that keep full screen Back to drop-downs for upload frequency, tyre pressure unit and accent colour, as Material exposed dropdown menus instead of Spinners. A Spinner's list is a focusable window of its own without the full-screen flags, so the car showed its bars while it was open; an exposed dropdown's list leaves the focus with the activity, so the app stays full screen. Styled like the other input fields, with full-height rows. Replaces the buttons of 2.16.3. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki --- SPECS.md | 5 ++- app/build.gradle | 4 +-- .../java/com/evsuite/abrp/MainActivity.java | 34 +++++++------------ app/src/main/res/color/choice_background.xml | 5 --- app/src/main/res/color/choice_stroke.xml | 5 --- app/src/main/res/color/choice_text.xml | 5 --- .../main/res/layout-w1000dp/pane_service.xml | 4 ++- .../main/res/layout-w1000dp/pane_settings.xml | 8 +++-- app/src/main/res/layout/choice_chip.xml | 8 ----- app/src/main/res/layout/dropdown_item.xml | 13 +++++++ app/src/main/res/layout/pane_service.xml | 4 ++- app/src/main/res/layout/pane_settings.xml | 8 +++-- app/src/main/res/values/styles_app.xml | 13 +++++++ app/src/main/res/values/styles_ev.xml | 15 -------- 14 files changed, 62 insertions(+), 69 deletions(-) delete mode 100644 app/src/main/res/color/choice_background.xml delete mode 100644 app/src/main/res/color/choice_stroke.xml delete mode 100644 app/src/main/res/color/choice_text.xml delete mode 100644 app/src/main/res/layout/choice_chip.xml create mode 100644 app/src/main/res/layout/dropdown_item.xml diff --git a/SPECS.md b/SPECS.md index 410abca..85c5a6f 100644 --- a/SPECS.md +++ b/SPECS.md @@ -112,8 +112,11 @@ tyre pressures, GPS (lat, lon, elevation, heading) and the is_* flags. It does * INGEST_TOKEN) into git and pushed it. Commit amended and force-pushed; both values regenerated in the now gitignored `k8s/secrets.yaml` (since moved to `k8s/secrets/secrets.yaml`, outside `kubectl apply -f k8s/`). Filled-in secrets live only there from now on. +- 2026-10-18 — App 2.16.4: drop-downs again, as Material exposed dropdown menus: their list leaves + the focus with the activity, so full screen stays (a Spinner's list is a focusable window of its + own without the full-screen flags). 2.16.3 had replaced them with buttons. - 2026-10-18 — App 2.16.3: full screen stays while choosing or reading a dialog. Drop-downs (upload - frequency, tyre unit, accent colour) are now buttons on the page: a drop-down is a window of its + frequency, tyre unit, accent colour) became buttons on the page: a drop-down is a window of its own without the full-screen flags, so the car showed its bars while it was open. Dialogs are shown unfocusable, given the activity's full-screen flags, then allowed focus (`showKeepingFullScreen`); none of them takes typing. diff --git a/app/build.gradle b/app/build.gradle index baed5bc..cb04d10 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -34,8 +34,8 @@ android { applicationId "com.stinnesbeck.nilsmg4" minSdk 27 targetSdk 36 - versionCode 87 - versionName "2.16.3" + versionCode 88 + versionName "2.16.4" // Nothing about the server ships in the APK: its URL and ingest token are entered in // the app's Server tab (ServerConfig), and the database is only reachable by the server. diff --git a/app/src/main/java/com/evsuite/abrp/MainActivity.java b/app/src/main/java/com/evsuite/abrp/MainActivity.java index eb48a7b..3ddcdf4 100644 --- a/app/src/main/java/com/evsuite/abrp/MainActivity.java +++ b/app/src/main/java/com/evsuite/abrp/MainActivity.java @@ -126,7 +126,7 @@ public class MainActivity extends AppCompatActivity { /** Upload page: live telemetry switch and cadence. */ private SwitchMaterial liveTelemetrySwitch, boostSwitch, voiceKeySwitch, fullScreenSwitch; private TextView steeringKeysText; - private com.google.android.material.chip.ChipGroup intervalChoices; + private com.google.android.material.textfield.MaterialAutoCompleteTextView intervalChoices; private TextInputLayout lowSocLayout; private TextInputEditText lowSocInput; @@ -1126,28 +1126,18 @@ public class MainActivity extends AppCompatActivity { } /** - * A single choice as buttons on the page (choice_chip.xml), in place of a drop-down: a - * drop-down opens a window of its own that is not full screen, and the car's bars came back - * while it was open. Rebinding replaces the buttons, so it may be called again. + * A single choice as a drop-down (Widget.EV.Dropdown). An exposed dropdown menu rather than + * a Spinner: a Spinner's list is a focusable window of its own without the full-screen + * flags, and the car showed its bars while it was open; this list leaves the focus with + * the activity. setSimpleItems keeps every option listed (a plain adapter would filter the + * list down to the current text). Rebinding is safe. */ - private void bindChoices(com.google.android.material.chip.ChipGroup group, String[] labels, int selected, - java.util.function.IntConsumer onPick) { - group.setOnCheckedStateChangeListener(null); - group.removeAllViews(); - for (int i = 0; i < labels.length; i++) { - com.google.android.material.chip.Chip chip = (com.google.android.material.chip.Chip) - getLayoutInflater().inflate(R.layout.choice_chip, group, false); - chip.setId(View.generateViewId()); - chip.setText(labels[i]); - chip.setTag(i); - group.addView(chip); - if (i == selected) chip.setChecked(true); - } - group.setOnCheckedStateChangeListener((g, checkedIds) -> { - if (checkedIds.isEmpty()) return; - View chosen = g.findViewById(checkedIds.get(0)); - if (chosen != null) onPick.accept((Integer) chosen.getTag()); - }); + private void bindChoices(com.google.android.material.textfield.MaterialAutoCompleteTextView view, + String[] labels, int selected, java.util.function.IntConsumer onPick) { + view.setOnItemClickListener(null); + view.setSimpleItems(labels); + view.setText(labels[selected], false); + view.setOnItemClickListener((parent, item, position, id) -> onPick.accept(position)); } /** The accent colour; a change recreates the activity so every view takes it. */ diff --git a/app/src/main/res/color/choice_background.xml b/app/src/main/res/color/choice_background.xml deleted file mode 100644 index c941703..0000000 --- a/app/src/main/res/color/choice_background.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/app/src/main/res/color/choice_stroke.xml b/app/src/main/res/color/choice_stroke.xml deleted file mode 100644 index c0e21ef..0000000 --- a/app/src/main/res/color/choice_stroke.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/app/src/main/res/color/choice_text.xml b/app/src/main/res/color/choice_text.xml deleted file mode 100644 index 60b46ae..0000000 --- a/app/src/main/res/color/choice_text.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/app/src/main/res/layout-w1000dp/pane_service.xml b/app/src/main/res/layout-w1000dp/pane_service.xml index bdff841..1298c51 100644 --- a/app/src/main/res/layout-w1000dp/pane_service.xml +++ b/app/src/main/res/layout-w1000dp/pane_service.xml @@ -27,7 +27,9 @@ - + + + diff --git a/app/src/main/res/layout-w1000dp/pane_settings.xml b/app/src/main/res/layout-w1000dp/pane_settings.xml index a096c71..113c246 100644 --- a/app/src/main/res/layout-w1000dp/pane_settings.xml +++ b/app/src/main/res/layout-w1000dp/pane_settings.xml @@ -9,9 +9,13 @@ - + + + - + + + diff --git a/app/src/main/res/layout/choice_chip.xml b/app/src/main/res/layout/choice_chip.xml deleted file mode 100644 index f0f8440..0000000 --- a/app/src/main/res/layout/choice_chip.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - diff --git a/app/src/main/res/layout/dropdown_item.xml b/app/src/main/res/layout/dropdown_item.xml new file mode 100644 index 0000000..623a16f --- /dev/null +++ b/app/src/main/res/layout/dropdown_item.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/layout/pane_service.xml b/app/src/main/res/layout/pane_service.xml index d29a516..b53b64f 100644 --- a/app/src/main/res/layout/pane_service.xml +++ b/app/src/main/res/layout/pane_service.xml @@ -24,7 +24,9 @@ - + + + diff --git a/app/src/main/res/layout/pane_settings.xml b/app/src/main/res/layout/pane_settings.xml index 0a5b350..ced79f4 100644 --- a/app/src/main/res/layout/pane_settings.xml +++ b/app/src/main/res/layout/pane_settings.xml @@ -8,9 +8,13 @@ - + + + - + + + diff --git a/app/src/main/res/values/styles_app.xml b/app/src/main/res/values/styles_app.xml index 1b39cf5..7c74ff9 100644 --- a/app/src/main/res/values/styles_app.xml +++ b/app/src/main/res/values/styles_app.xml @@ -43,6 +43,19 @@ @color/ev_text_secondary @style/Text.EV.Secondary + + - - From 8d3eb80f6630abe772ef562df65282c542d15717 Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Sun, 18 Oct 2026 20:26:02 +0200 Subject: [PATCH 71/79] App 2.17.0: speed average, compact top bar, About in Settings - "Speed, 5-min average" card under Speed: time-weighted mean speed, standing included, from the same samples as the other averages. - The top bar is one compact line (title and subtitle side by side). Version, update check and About moved from it to an App card on the Settings tab. - The Overview no longer scrolls in the car's window: checked on the emulator at 1778x640, the layout height the car reports, after trimming the page's padding and the tyre card's height. - The capacity hint says "Overview" instead of "Vehicle tab". Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki --- SPECS.md | 4 ++++ app/build.gradle | 4 ++-- .../java/com/evsuite/abrp/MainActivity.java | 15 ++++++++++---- .../java/com/evsuite/abrp/RangeEstimator.java | 20 +++++++++++++++++++ .../main/res/layout-w1000dp/activity_main.xml | 13 +++++------- .../main/res/layout-w1000dp/pane_settings.xml | 6 ++++++ .../main/res/layout-w1000dp/pane_vehicle.xml | 10 +++++++--- app/src/main/res/layout/activity_main.xml | 13 +++++------- app/src/main/res/layout/pane_settings.xml | 6 ++++++ app/src/main/res/layout/pane_vehicle.xml | 9 +++++++-- app/src/main/res/values/strings.xml | 7 ++++++- app/src/main/res/values/strings_design.xml | 3 ++- .../com/evsuite/abrp/RangeEstimatorTest.java | 12 +++++++++++ 13 files changed, 93 insertions(+), 29 deletions(-) diff --git a/SPECS.md b/SPECS.md index 85c5a6f..0c16d2e 100644 --- a/SPECS.md +++ b/SPECS.md @@ -112,6 +112,10 @@ tyre pressures, GPS (lat, lon, elevation, heading) and the is_* flags. It does * INGEST_TOKEN) into git and pushed it. Commit amended and force-pushed; both values regenerated in the now gitignored `k8s/secrets.yaml` (since moved to `k8s/secrets/secrets.yaml`, outside `kubectl apply -f k8s/`). Filled-in secrets live only there from now on. +- 2026-10-18 — App 2.17.0: "Speed, 5-min average" card under Speed. The top bar is one compact line + (title and subtitle); version, update check and About moved to an App card on the Settings tab. + The Overview fits the car's window without scrolling: checked on the emulator at 1778×640, the + layout height the car reports (less page padding, a lower tyre card). - 2026-10-18 — App 2.16.4: drop-downs again, as Material exposed dropdown menus: their list leaves the focus with the activity, so full screen stays (a Spinner's list is a focusable window of its own without the full-screen flags). 2.16.3 had replaced them with buttons. diff --git a/app/build.gradle b/app/build.gradle index cb04d10..2ffdddb 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -34,8 +34,8 @@ android { applicationId "com.stinnesbeck.nilsmg4" minSdk 27 targetSdk 36 - versionCode 88 - versionName "2.16.4" + versionCode 89 + versionName "2.17.0" // Nothing about the server ships in the APK: its URL and ingest token are entered in // the app's Server tab (ServerConfig), and the database is only reachable by the server. diff --git a/app/src/main/java/com/evsuite/abrp/MainActivity.java b/app/src/main/java/com/evsuite/abrp/MainActivity.java index 3ddcdf4..0d5b314 100644 --- a/app/src/main/java/com/evsuite/abrp/MainActivity.java +++ b/app/src/main/java/com/evsuite/abrp/MainActivity.java @@ -94,7 +94,8 @@ public class MainActivity extends AppCompatActivity { vehTireFl, vehTireFr, vehTireRl, vehTireRr, vehRangeTrip, vehRangeTripNote, vehRangeAverage, vehRangeAverageNote, vehUsageTrip, vehUsageTripNote, vehUsageAverage, vehUsageAverageNote, - vehEnergyTrip, vehEnergyTripNote, vehPowerAverage, vehPowerAverageNote; + vehEnergyTrip, vehEnergyTripNote, vehPowerAverage, vehPowerAverageNote, + vehSpeedAverage, vehSpeedAverageNote; /** Usable pack capacity for the range estimates; the car does not publish it. */ static final String USABLE_KWH_KEY = "usable_kwh"; @@ -233,7 +234,6 @@ public class MainActivity extends AppCompatActivity { getTheme().applyStyle(AccentColor.fromPref(prefs.getString(AccentColor.PREF_KEY, null)).overlay, true); setContentView(R.layout.activity_main); - ((TextView) findViewById(R.id.version_badge)).setText("v" + BuildConfig.VERSION_NAME); // The pages are inflated here, once, and handed to the pager as fixed, @@ -277,8 +277,10 @@ public class MainActivity extends AppCompatActivity { if (savedInstanceState != null) { ((ViewPager2) findViewById(R.id.content)).setCurrentItem(savedInstanceState.getInt(STATE_PAGE, 0), false); } - findViewById(R.id.about_button).setOnClickListener(v -> showAbout()); - findViewById(R.id.update_button).setOnClickListener(v -> checkForUpdate(v)); + ((TextView) settingsPane.findViewById(R.id.settings_version)) + .setText(getString(R.string.settings_version, BuildConfig.VERSION_NAME)); + settingsPane.findViewById(R.id.settings_about_button).setOnClickListener(v -> showAbout()); + settingsPane.findViewById(R.id.settings_update_button).setOnClickListener(v -> checkForUpdate(v)); // Unstable builds check for a newer pre-release; the stable flavor's UpdateHook is // a no-op and does not even contain the updater. @@ -1288,6 +1290,8 @@ public class MainActivity extends AppCompatActivity { vehEnergyTripNote = vehiclePane.findViewById(R.id.vehicle_energy_trip_note); vehPowerAverage = vehiclePane.findViewById(R.id.vehicle_power_average); vehPowerAverageNote = vehiclePane.findViewById(R.id.vehicle_power_average_note); + vehSpeedAverage = vehiclePane.findViewById(R.id.vehicle_speed_average); + vehSpeedAverageNote = vehiclePane.findViewById(R.id.vehicle_speed_average_note); } private double usableKwh() { @@ -1308,6 +1312,9 @@ public class MainActivity extends AppCompatActivity { Double power = estimator.averagePowerKw(monotonicMs); vehPowerAverage.setText(power == null ? "—" : getString(R.string.power_kw, power)); vehPowerAverageNote.setText(power == null ? R.string.power_waiting : R.string.usage_last_5_min); + Double speed = estimator.averageSpeedKmh(monotonicMs); + vehSpeedAverage.setText(speed == null ? "—" : getString(R.string.speed_kmh, speed)); + vehSpeedAverageNote.setText(speed == null ? R.string.power_waiting : R.string.usage_last_5_min); Double energy = estimator.energySinceStart(); vehEnergyTrip.setText(energy == null ? "—" : getString(R.string.energy_kwh, energy)); vehEnergyTripNote.setText(energy == null ? R.string.energy_trip_waiting : R.string.energy_trip_note); diff --git a/app/src/main/java/com/evsuite/abrp/RangeEstimator.java b/app/src/main/java/com/evsuite/abrp/RangeEstimator.java index b982760..956fa97 100644 --- a/app/src/main/java/com/evsuite/abrp/RangeEstimator.java +++ b/app/src/main/java/com/evsuite/abrp/RangeEstimator.java @@ -177,6 +177,26 @@ final class RangeEstimator { return energyKwh / (covered / 3_600_000.0); } + /** Mean speed over the last five minutes, time-weighted, standing included; null below a minute. */ + synchronized Double averageSpeedKmh(long nowMs) { + double distanceKm = 0; + long covered = 0; + Sample previous = null; + for (Sample s : samples) { + if (s.timeMs < nowMs - WINDOW_MS) continue; + if (previous != null) { + long dt = s.timeMs - previous.timeMs; + if (dt > 0 && dt <= MAX_GAP_MS) { + distanceKm += (previous.speedKmh + s.speedKmh) / 2 * (dt / 3_600_000.0); + covered += dt; + } + } + previous = s; + } + if (covered < MIN_WINDOW_MS) return null; + return distanceKm / (covered / 3_600_000.0); + } + synchronized void clear() { samples.clear(); tripEnergyKwh = 0; diff --git a/app/src/main/res/layout-w1000dp/activity_main.xml b/app/src/main/res/layout-w1000dp/activity_main.xml index 9c564a4..ccf2bf9 100644 --- a/app/src/main/res/layout-w1000dp/activity_main.xml +++ b/app/src/main/res/layout-w1000dp/activity_main.xml @@ -16,14 +16,11 @@ - - - - - - - - + + + + diff --git a/app/src/main/res/layout-w1000dp/pane_settings.xml b/app/src/main/res/layout-w1000dp/pane_settings.xml index 113c246..a07fd8e 100644 --- a/app/src/main/res/layout-w1000dp/pane_settings.xml +++ b/app/src/main/res/layout-w1000dp/pane_settings.xml @@ -53,6 +53,12 @@ + + + + + + diff --git a/app/src/main/res/layout-w1000dp/pane_vehicle.xml b/app/src/main/res/layout-w1000dp/pane_vehicle.xml index 24acbed..ea5ac85 100644 --- a/app/src/main/res/layout-w1000dp/pane_vehicle.xml +++ b/app/src/main/res/layout-w1000dp/pane_vehicle.xml @@ -1,6 +1,6 @@ - + @@ -52,7 +52,11 @@ - + + + + + @@ -71,7 +75,7 @@ - + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 376a9f8..1a8270b 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -2,14 +2,11 @@ - - - - - - - - + + + + diff --git a/app/src/main/res/layout/pane_settings.xml b/app/src/main/res/layout/pane_settings.xml index ced79f4..e76fcec 100644 --- a/app/src/main/res/layout/pane_settings.xml +++ b/app/src/main/res/layout/pane_settings.xml @@ -50,5 +50,11 @@ + + + + + + diff --git a/app/src/main/res/layout/pane_vehicle.xml b/app/src/main/res/layout/pane_vehicle.xml index 009564c..5993153 100644 --- a/app/src/main/res/layout/pane_vehicle.xml +++ b/app/src/main/res/layout/pane_vehicle.xml @@ -1,6 +1,6 @@ - + @@ -49,6 +49,11 @@ + + + + + @@ -66,7 +71,7 @@ - + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 567cc44..f54eb17 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -59,7 +59,7 @@ over the last 5 minutes Usable battery capacity Save capacity - Used for the range estimates on the Vehicle tab. The 51 kWh MG4 battery has 50.8 kWh usable, the 64 kWh one 61.7, the 77 kWh one 74.4. + Used for the range estimates on the Overview. The 51 kWh MG4 battery has 50.8 kWh usable, the 64 kWh one 61.7, the 77 kWh one 74.4. Usable capacity saved Enter a value between 10 and 150 kWh Speed @@ -204,6 +204,11 @@ Check for updates Checking for a new OTA version… Software update + Check for updates + About Nils\' MG4 + Version %1$s + Speed, 5-min average + %1$.0f km/h Downloading and verifying… Downloading… %1$d%% About diff --git a/app/src/main/res/values/strings_design.xml b/app/src/main/res/values/strings_design.xml index d21a79c..99507ca 100644 --- a/app/src/main/res/values/strings_design.xml +++ b/app/src/main/res/values/strings_design.xml @@ -12,7 +12,7 @@ Live data. Where your car\'s live data goes, and how often. Settings. - Display, battery, charging price and Wi-Fi. + Display, battery, charging price, Wi-Fi and updates. Every upload, right here. Status and details of recent uploads. BATTERY @@ -26,6 +26,7 @@ ABRP, DIRECTLY FROM THE CAR DISPLAY AND STEERING WHEEL BATTERY + APP CHARGING PRICE UPDATE FREQUENCY Tire pressure positions on the vehicle diff --git a/app/src/test/java/com/evsuite/abrp/RangeEstimatorTest.java b/app/src/test/java/com/evsuite/abrp/RangeEstimatorTest.java index b5f4a62..47834ea 100644 --- a/app/src/test/java/com/evsuite/abrp/RangeEstimatorTest.java +++ b/app/src/test/java/com/evsuite/abrp/RangeEstimatorTest.java @@ -140,4 +140,16 @@ public class RangeEstimatorTest { for (long t = 315_000; t <= 450_000; t += 15_000) r.add(t, 2f, 0f); assertEquals(11.0, r.averagePowerKw(450_000), 1.0); } + + @Test + public void averageSpeedOverTheLastFiveMinutes() { + RangeEstimator r = new RangeEstimator(); + r.add(0, 10f, 60f); + assertNull(r.averageSpeedKmh(0)); + for (long t = 15_000; t <= 300_000; t += 15_000) r.add(t, 10f, 60f); + assertEquals(60.0, r.averageSpeedKmh(300_000), 1e-6); + // Half the window standing at lights halves the average. + for (long t = 315_000; t <= 450_000; t += 15_000) r.add(t, 1f, 0f); + assertEquals(30.0, r.averageSpeedKmh(450_000), 4.0); + } } From 851a4a34d4b70e36980ebfb6507a6301f22fdc85 Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Sun, 18 Oct 2026 20:32:01 +0200 Subject: [PATCH 72/79] App 2.17.1: tidier Settings and Logs - Settings: the Wi-Fi and saved-networks cards are gone, and the rest sits in three columns (display and steering wheel | battery | charging price and app) instead of one tall column. The background Wi-Fi auto-connect stays on, without a switch. - Logs: only the upload log. The steering-key reports, the car-colour probe and the screen line answered their questions and are removed, with their code (CarColorProbe, the key-report log) and strings. - README lists the current tabs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki --- FIRMWARE.md | 2 +- README.md | 6 +- SPECS.md | 4 + app/build.gradle | 4 +- .../java/com/evsuite/abrp/CarColorProbe.java | 59 ------------- .../java/com/evsuite/abrp/MainActivity.java | 85 ++----------------- .../com/evsuite/abrp/SteeringKeyListener.java | 43 +--------- .../com/evsuite/abrp/WifiAutoConnect.java | 28 ------ .../main/res/layout-w1000dp/pane_settings.xml | 23 ++--- app/src/main/res/layout/pane_log.xml | 9 -- app/src/main/res/layout/pane_settings.xml | 12 --- app/src/main/res/values/strings.xml | 21 ----- app/src/main/res/values/strings_design.xml | 2 +- .../com/evsuite/abrp/CarColorProbeTest.java | 24 ------ 14 files changed, 24 insertions(+), 298 deletions(-) delete mode 100644 app/src/main/java/com/evsuite/abrp/CarColorProbe.java delete mode 100644 app/src/test/java/com/evsuite/abrp/CarColorProbeTest.java diff --git a/FIRMWARE.md b/FIRMWARE.md index c74c5a0..ae2cea7 100644 --- a/FIRMWARE.md +++ b/FIRMWARE.md @@ -11,7 +11,7 @@ development. It must not be treated as a vendor specification. | Android | 9 (Pie) — **API 28** | | Chipset | MediaTek **MT2712** | | Device | `mt2712_saic_eh32` (eh32 = MG4) | -| Display | **1920×720 px @ 160 dpi**; the app window is **1778×720 px** (≈1778×640 dp for layouts), the car's own sidebar taking the rest. Confirmed on the car 2026-10-18 (Logs → SCREEN, `SWI68`). | +| Display | **1920×720 px @ 160 dpi**; the app window is **1778×720 px** (≈1778×640 dp for layouts), the car's own sidebar taking the rest. Confirmed on the car 2026-10-18 (read with app 2.16.2, `SWI68`). | | Emulator display profile | **1920×720 @ 160 dpi** | | `ro.build.mtk2712.vehicle_type` | `AS28` (a vehicle type code; the paint colour is not among the system properties) | diff --git a/README.md b/README.md index e222ac5..2c122bd 100644 --- a/README.md +++ b/README.md @@ -63,13 +63,13 @@ with the server using a token of its own, obtained by pairing with a phone (belo | Tab | What it shows | |---|---| -| **Overview** | Battery, range, range since start and at the 5-minute average with their usage, power, speed; odometer, climate setpoint and tyre pressures | +| **Overview** | Battery, range, range since start and at the 5-minute average with their usage, energy since start, power and speed with their 5-minute averages; odometer, climate setpoint and tyre pressures | | **Consumption** | Daily and period consumption, efficiency | | **Charging** | Charging sessions and curves, cost in euro | | **Live data** | Live data on/off, ABRP credentials for sending directly from the car, upload frequency | | **Server** | Server URL, pairing by QR code, ingest token, installation ID, config import | -| **Settings** | Full screen, voice key, battery (usable capacity, manual SoH), charging price, Wi-Fi | -| **Logs** | Upload log and the last steering-wheel key reports | +| **Settings** | Full screen, voice key, tyre pressure unit, accent colour, battery (usable capacity, manual SoH), charging price, version, update check, About | +| **Logs** | Upload log | - **Opening the app:** the MG4 launcher does not list third-party apps. **Double-press the voice key** on the steering wheel and the app comes to the front (switch on the Settings diff --git a/SPECS.md b/SPECS.md index 0c16d2e..7da3875 100644 --- a/SPECS.md +++ b/SPECS.md @@ -112,6 +112,10 @@ tyre pressures, GPS (lat, lon, elevation, heading) and the is_* flags. It does * INGEST_TOKEN) into git and pushed it. Commit amended and force-pushed; both values regenerated in the now gitignored `k8s/secrets.yaml` (since moved to `k8s/secrets/secrets.yaml`, outside `kubectl apply -f k8s/`). Filled-in secrets live only there from now on. +- 2026-10-18 — App 2.17.1: Settings without the Wi-Fi and saved-networks cards, in three columns + (display and steering wheel | battery | charging price and app). The background Wi-Fi + auto-connect stays on, now without a switch. Logs keeps only the upload log: the steering-key + reports, the car-colour probe and the screen line are gone, their questions answered. - 2026-10-18 — App 2.17.0: "Speed, 5-min average" card under Speed. The top bar is one compact line (title and subtitle); version, update check and About moved to an App card on the Settings tab. The Overview fits the car's window without scrolling: checked on the emulator at 1778×640, the diff --git a/app/build.gradle b/app/build.gradle index 2ffdddb..b6db3a4 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -34,8 +34,8 @@ android { applicationId "com.stinnesbeck.nilsmg4" minSdk 27 targetSdk 36 - versionCode 89 - versionName "2.17.0" + versionCode 90 + versionName "2.17.1" // Nothing about the server ships in the APK: its URL and ingest token are entered in // the app's Server tab (ServerConfig), and the database is only reachable by the server. diff --git a/app/src/main/java/com/evsuite/abrp/CarColorProbe.java b/app/src/main/java/com/evsuite/abrp/CarColorProbe.java deleted file mode 100644 index 3800e07..0000000 --- a/app/src/main/java/com/evsuite/abrp/CarColorProbe.java +++ /dev/null @@ -1,59 +0,0 @@ -package com.evsuite.abrp; - -import java.io.BufferedReader; -import java.io.InputStreamReader; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.List; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -/** - * Looks for the car's paint colour among the head unit's system properties, for the Logs tab. - * - * The MG launcher draws the car in its own colour, so the head unit knows it somewhere; where is - * not documented. This lists every property whose name or value mentions a colour or paint, so - * a photo of the Logs tab shows whether (and how) it can be read. Read-only: it runs the - * platform's own getprop, which only reads. - */ -final class CarColorProbe { - - private static final Pattern LINE = Pattern.compile("^\\[([^\\]]*)\\]: \\[(.*)\\]$"); - private static final Pattern INTERESTING = Pattern.compile( - "(?i)colou?r|paint|body_?col|exterior|car_?model|vehicle_?(config|type|model)"); - - private CarColorProbe() { } - - /** getprop's output, filtered; at most {@code max} lines. Never throws. */ - static List run(int max) { - try { - Process p = new ProcessBuilder("getprop").redirectErrorStream(true).start(); - StringBuilder out = new StringBuilder(); - try (BufferedReader r = new BufferedReader(new InputStreamReader(p.getInputStream(), StandardCharsets.UTF_8))) { - String line; - while ((line = r.readLine()) != null && out.length() < 512 * 1024) out.append(line).append('\n'); - } - p.waitFor(); - return filter(out.toString(), max); - } catch (Exception e) { - List failed = new ArrayList<>(); - failed.add("getprop failed: " + e.getClass().getSimpleName()); - return failed; - } - } - - /** The "name = value" lines whose name or value looks colour- or model-related. Pure. */ - static List filter(String getpropOutput, int max) { - List found = new ArrayList<>(); - for (String line : getpropOutput.split("\n")) { - Matcher m = LINE.matcher(line.trim()); - if (!m.matches()) continue; - String name = m.group(1), value = m.group(2); - if (INTERESTING.matcher(name).find() || INTERESTING.matcher(value).find()) { - found.add(name + " = " + value); - if (found.size() >= max) break; - } - } - return found; - } -} diff --git a/app/src/main/java/com/evsuite/abrp/MainActivity.java b/app/src/main/java/com/evsuite/abrp/MainActivity.java index 0d5b314..a532bb6 100644 --- a/app/src/main/java/com/evsuite/abrp/MainActivity.java +++ b/app/src/main/java/com/evsuite/abrp/MainActivity.java @@ -35,7 +35,11 @@ import com.evsuite.hardware.telemetry.EnergyTelemetryReader; public class MainActivity extends AppCompatActivity { - /** Shared with {@link AbrpUploadService}, which reads it on every tick. */ + /** + * Rejoin a saved network (the phone hotspot) in the background; read by + * {@link AbrpUploadService} on every tick. On by default; the Wi-Fi card with its switch was + * removed from Settings (2.17.1), so it now stays on. + */ static final String WIFI_AUTO_KEY = "wifi_auto_connect"; private static final String REPOSITORY_URL = "https://git.stinnesbeck.com/nils/mg4"; @@ -76,9 +80,6 @@ public class MainActivity extends AppCompatActivity { /** The pages in navigation order. Parallel to {@link #TAB_IDS}. */ private View[] panes; - /** Wi-Fi page. The helper is the same one the upload service uses on its own tick. */ - private WifiAutoConnect wifi; - private TextView wifiSsid, wifiState, wifiSavedList; /** * Value cells of the Vehicle page, looked up once and refreshed on the UI tick. @@ -126,7 +127,6 @@ public class MainActivity extends AppCompatActivity { /** Upload page: live telemetry switch and cadence. */ private SwitchMaterial liveTelemetrySwitch, boostSwitch, voiceKeySwitch, fullScreenSwitch; - private TextView steeringKeysText; private com.google.android.material.textfield.MaterialAutoCompleteTextView intervalChoices; private TextInputLayout lowSocLayout; private TextInputEditText lowSocInput; @@ -215,10 +215,8 @@ public class MainActivity extends AppCompatActivity { @Override public void run() { refreshStatus(); refreshCallLog(); - refreshSteeringKeys(); sampleTrackers(); refreshConsumption(); - refreshWifi(); uiHandler.postDelayed(this, 2_000L); } }; @@ -254,22 +252,12 @@ public class MainActivity extends AppCompatActivity { bindVehiclePane(); bindConsumptionPane(); bindChargingPane(); - bindWifiPane(); bindServerPane(); bindServicePane(); requestLocationPermissionIfNeeded(); statusText = servicePane.findViewById(R.id.status_text); callLogText = logPane.findViewById(R.id.call_log_text); - steeringKeysText = logPane.findViewById(R.id.steering_keys_text); - TextView carColorText = logPane.findViewById(R.id.car_color_text); - showScreenInfo(logPane.findViewById(R.id.screen_text)); - logPane.findViewById(R.id.car_color_button).setOnClickListener(v -> new Thread(() -> { - java.util.List found = CarColorProbe.run(60); - String shown = found.isEmpty() ? getString(R.string.car_color_none) - : android.text.TextUtils.join("\n", found); - runOnUiThread(() -> carColorText.setText(shown)); - }, "car-color-probe").start()); logPane.findViewById(R.id.flush_now_button).setOnClickListener(v -> manualFlush()); setUpPager(); @@ -292,21 +280,6 @@ public class MainActivity extends AppCompatActivity { startForegroundService(new Intent(this, AbrpUploadService.class)); } - /** - * What the head unit reports about its screen, for the Logs tab: the panel in pixels and its - * density, and this app's window in pixels and in dp, which is what layouts are chosen by. - * Sources disagree on the MG4's numbers (1920×720, 1280×480, a 1778×720 window). - */ - @SuppressWarnings("deprecation") // getRealMetrics: the WindowMetrics API starts at 30; this is 28 - private void showScreenInfo(TextView view) { - android.util.DisplayMetrics panel = new android.util.DisplayMetrics(); - getWindowManager().getDefaultDisplay().getRealMetrics(panel); - android.util.DisplayMetrics window = getResources().getDisplayMetrics(); - android.content.res.Configuration config = getResources().getConfiguration(); - view.setText(getString(R.string.screen_info, panel.widthPixels, panel.heightPixels, panel.densityDpi, - window.widthPixels, window.heightPixels, config.screenWidthDp, config.screenHeightDp)); - } - /** * Shows a dialog without bringing the car's bars back. A dialog is a window of its own, and * the system shows the bars when a window without the full-screen flags takes focus. So it @@ -1224,45 +1197,6 @@ public class MainActivity extends AppCompatActivity { } } - // ---------- Wi-Fi page ---------- - - private void bindWifiPane() { - wifi = new WifiAutoConnect(getApplicationContext()); - wifiSsid = settingsPane.findViewById(R.id.wifi_ssid); - wifiState = settingsPane.findViewById(R.id.wifi_state); - wifiSavedList = settingsPane.findViewById(R.id.wifi_saved_list); - - SwitchMaterial auto = settingsPane.findViewById(R.id.wifi_auto_switch); - auto.setChecked(prefs.getBoolean(WIFI_AUTO_KEY, true)); - auto.setOnCheckedChangeListener((btn, checked) -> - prefs.edit().putBoolean(WIFI_AUTO_KEY, checked).apply()); - - settingsPane.findViewById(R.id.wifi_connect_button).setOnClickListener(v -> { - // force: the driver is asking now, so the rate limit does not apply. - WifiAutoConnect.Result r = wifi.ensureConnected(true); - wifiState.setText(r.message); - refreshWifi(); - }); - } - - private void refreshWifi() { - if (wifi == null) return; - String ssid = wifi.currentSsid(); - wifiSsid.setText(ssid == null ? getString(R.string.wifi_not_connected) : ssid); - if (ssid != null) { - wifiState.setText(R.string.wifi_connected); - } else if (!wifi.isWifiEnabled()) { - wifiState.setText(R.string.wifi_off); - } - // Left alone otherwise: a message the button just set says more about what is - // happening than "not connected" would. - - java.util.List saved = wifi.savedNetworkNames(); - wifiSavedList.setText(saved.isEmpty() - ? getString(R.string.wifi_saved_empty) - : android.text.TextUtils.join("\n", saved)); - } - // ---------- Vehicle page ---------- /** Looks up the value cells once; the labels are static and never touched from code. */ @@ -1752,15 +1686,6 @@ public class MainActivity extends AppCompatActivity { }, "cloud-refresh").start(); } - /** What the car's steering-wheel key broadcasts looked like, newest first. */ - private void refreshSteeringKeys() { - if (steeringKeysText == null) return; - java.util.List keys = SteeringKeyListener.recent(); - String text = keys.isEmpty() ? getString(R.string.steering_keys_empty) - : android.text.TextUtils.join("\n", keys); - if (!text.contentEquals(steeringKeysText.getText())) steeringKeysText.setText(text); - } - private void refreshCallLog() { java.util.List entries = AbrpUploadService.log().recent(); if (entries.isEmpty()) { diff --git a/app/src/main/java/com/evsuite/abrp/SteeringKeyListener.java b/app/src/main/java/com/evsuite/abrp/SteeringKeyListener.java index ec377f6..080955b 100644 --- a/app/src/main/java/com/evsuite/abrp/SteeringKeyListener.java +++ b/app/src/main/java/com/evsuite/abrp/SteeringKeyListener.java @@ -9,20 +9,12 @@ import android.os.Bundle; import android.util.Log; import android.view.KeyEvent; -import java.text.SimpleDateFormat; -import java.util.ArrayDeque; -import java.util.ArrayList; -import java.util.Date; -import java.util.List; -import java.util.Locale; - /** * Opens the app on a double press of the steering wheel's voice key. * * The head unit reports steering-wheel keys as the broadcast {@link #ACTION}; code 287 is the * voice key (see EVHardware's PhysicalButtonEventDecoder). The extras' format was read off the - * car (see {@link SteeringKeyReport}); every report is still kept raw for the Logs tab, in case - * another firmware sends them differently. + * car (see {@link SteeringKeyReport}). * * Registered by the always-on service on the main thread; never throws into the head unit. */ @@ -31,9 +23,6 @@ final class SteeringKeyListener { static final String ACTION = "com.saic.keyevent.hardkey.report"; static final int VOICE_KEY = 287; private static final String TAG = "NilsMG4.SteeringKey"; - private static final int MAX_LOG = 20; - - private static final ArrayDeque RECENT = new ArrayDeque<>(); private final Context context; private final SharedPreferences prefs; @@ -79,18 +68,6 @@ final class SteeringKeyListener { registered = false; } - /** The last broadcasts, newest first, for the Logs tab. */ - static synchronized List recent() { - List out = new ArrayList<>(RECENT); - java.util.Collections.reverse(out); - return out; - } - - private static synchronized void remember(String line) { - if (RECENT.size() >= MAX_LOG) RECENT.removeFirst(); - RECENT.addLast(line); - } - private void handle(Intent intent) { Bundle extras = intent.getExtras(); SteeringKeyReport key = read(extras); @@ -98,11 +75,6 @@ final class SteeringKeyListener { if (key.code != null && UploadSettings.voiceKeyOpensApp(prefs)) { opens = detector.accept(key.code, key.down, key.longPress, android.os.SystemClock.elapsedRealtime()); } - remember(new SimpleDateFormat("HH:mm:ss.SSS", Locale.US).format(new Date()) - + " " + describe(extras) - + "\n -> code " + (key.code == null ? "?" : key.code) - + ", down " + (key.down == null ? "?" : key.down) - + ", long " + key.longPress + (opens ? " OPENS APP" : "")); if (opens) { try { context.startActivity(new Intent(context, MainActivity.class) @@ -129,17 +101,4 @@ final class SteeringKeyListener { } return SteeringKeyReport.parse(values); } - - /** All extras as name=value(type), so the Logs tab shows the real format. */ - static String describe(Bundle extras) { - if (extras == null || extras.isEmpty()) return "(no extras)"; - StringBuilder sb = new StringBuilder(); - for (String k : extras.keySet()) { - Object v = extras.get(k); - if (sb.length() > 0) sb.append(", "); - sb.append(k).append('=').append(v).append(" (") - .append(v == null ? "null" : v.getClass().getSimpleName()).append(')'); - } - return sb.toString(); - } } diff --git a/app/src/main/java/com/evsuite/abrp/WifiAutoConnect.java b/app/src/main/java/com/evsuite/abrp/WifiAutoConnect.java index ea2bfcc..2661d36 100644 --- a/app/src/main/java/com/evsuite/abrp/WifiAutoConnect.java +++ b/app/src/main/java/com/evsuite/abrp/WifiAutoConnect.java @@ -58,34 +58,6 @@ final class WifiAutoConnect { } } - boolean isWifiEnabled() { - try { - return wifi != null && wifi.isWifiEnabled(); - } catch (Throwable t) { - return false; - } - } - - /** Saved network names, for the UI to show what it can reach. Empty if the OS refuses. */ - List savedNetworkNames() { - java.util.List names = new java.util.ArrayList<>(); - try { - if (wifi == null) return names; - List saved = wifi.getConfiguredNetworks(); - if (saved == null) return names; - for (WifiConfiguration c : saved) { - if (c == null || c.SSID == null) continue; - String name = c.SSID.replace("\"", "").trim(); - if (!name.isEmpty() && !names.contains(name)) names.add(name); - } - } catch (SecurityException e) { - Log.w(TAG, "Saved networks unavailable: permission denied"); - } catch (Throwable t) { - Log.w(TAG, "getConfiguredNetworks refused: " + t.getMessage()); - } - return names; - } - /** * One attempt to get onto a saved network, rate-limited. * diff --git a/app/src/main/res/layout-w1000dp/pane_settings.xml b/app/src/main/res/layout-w1000dp/pane_settings.xml index a07fd8e..2c54b3c 100644 --- a/app/src/main/res/layout-w1000dp/pane_settings.xml +++ b/app/src/main/res/layout-w1000dp/pane_settings.xml @@ -1,5 +1,6 @@ + @@ -17,7 +18,9 @@ - + + + @@ -30,7 +33,9 @@ - + + + @@ -39,20 +44,6 @@ - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/pane_log.xml b/app/src/main/res/layout/pane_log.xml index 95f2da8..7bfc7fc 100644 --- a/app/src/main/res/layout/pane_log.xml +++ b/app/src/main/res/layout/pane_log.xml @@ -4,14 +4,5 @@ - - - - - - - - - diff --git a/app/src/main/res/layout/pane_settings.xml b/app/src/main/res/layout/pane_settings.xml index e76fcec..eea34d5 100644 --- a/app/src/main/res/layout/pane_settings.xml +++ b/app/src/main/res/layout/pane_settings.xml @@ -38,18 +38,6 @@ - - - - - - - - - - - - diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f54eb17..6380df6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -14,16 +14,6 @@ Settings Logs - - WI-FI - Connected network - Status - Connect Now - Auto-connect in the background - While the upload service is running, it tries to reconnect to saved networks on its own when the connection drops. - SAVED NETWORKS - Could not read saved networks - Not connected Live Vehicle Data @@ -40,12 +30,6 @@ needs a minute of data Tyre pressure unit Accent colour - SCREEN - Panel %1$d × %2$d px at %3$d dpi\nApp window %4$d × %5$d px = %6$d × %7$d dp - CAR COLOUR - Lists the head unit\'s settings that mention a colour, paint or car model, to find out whether the car\'s colour can be read. A photo of this list helps. - Look for the car colour - Nothing colour-related found %1$.1f kWh net, after recuperation once the car is on @@ -134,9 +118,6 @@ Hides the car\'s top and bottom bars while this app is open. Swipe in from the top or bottom edge to show them briefly, e.g. for the climate controls. Double-press the voice key to open this app Press the voice key on the steering wheel twice quickly. The voice assistant may still react to the presses. - Steering-wheel keys - The last key reports the car sent, newest first. If double-pressing the voice key does not open the app, a photo of this list shows how the car reports it. - No key reports received yet. Press a steering-wheel key. Send live data To your server, which stores it, and straight to ABRP when the ABRP credentials below are set. Consumption and charging tracking keep running when this is off. The car sends live data to ABRP itself with your ABRP API key and user token. Without them, ABRP gets no live data; your server only stores it. @@ -260,8 +241,6 @@ Unit price €/kWh Save price - Connected ♥ - Wi-Fi off Reset %1$s? This counter\'s recorded consumption history will be permanently deleted. Reset diff --git a/app/src/main/res/values/strings_design.xml b/app/src/main/res/values/strings_design.xml index 99507ca..22d31ec 100644 --- a/app/src/main/res/values/strings_design.xml +++ b/app/src/main/res/values/strings_design.xml @@ -12,7 +12,7 @@ Live data. Where your car\'s live data goes, and how often. Settings. - Display, battery, charging price, Wi-Fi and updates. + Display, battery, charging price and updates. Every upload, right here. Status and details of recent uploads. BATTERY diff --git a/app/src/test/java/com/evsuite/abrp/CarColorProbeTest.java b/app/src/test/java/com/evsuite/abrp/CarColorProbeTest.java deleted file mode 100644 index 7ac9479..0000000 --- a/app/src/test/java/com/evsuite/abrp/CarColorProbeTest.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.evsuite.abrp; - -import static org.junit.Assert.assertEquals; - -import java.util.Arrays; - -import org.junit.Test; - -/** Picking colour-related system properties out of getprop's output — pure JVM. */ -public class CarColorProbeTest { - - @Test - public void onlyColourOrModelRelatedPropertiesAreListed() { - String getprop = "[ro.build.version.sdk]: [28]\n" - + "[persist.saic.vehicle.color]: [3]\n" - + "[ro.product.model]: [EH32]\n" - + "[persist.car.bodyCol]: [RED]\n" - + "[sys.launcher.car_model]: [mg4_red.glb]\n" - + "garbage line\n"; - assertEquals(Arrays.asList("persist.saic.vehicle.color = 3", "persist.car.bodyCol = RED", - "sys.launcher.car_model = mg4_red.glb"), CarColorProbe.filter(getprop, 50)); - assertEquals(1, CarColorProbe.filter(getprop, 1).size()); - } -} From d4bb502026a1b5657aeb635f6ef904be19f7e5cd Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Sun, 18 Oct 2026 20:34:05 +0200 Subject: [PATCH 73/79] App 2.17.2: Wi-Fi auto-connect removed Upstream kept the head unit on the driver's phone hotspot by re-joining a saved network every few minutes. This car's head unit has its own connection and the Wi-Fi is not used, so the feature goes entirely: WifiAutoConnect, its call on the service tick, the preference key, and the ACCESS_WIFI_STATE / CHANGE_WIFI_STATE permissions. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki --- SPECS.md | 6 +- app/build.gradle | 4 +- app/src/main/AndroidManifest.xml | 6 - .../com/evsuite/abrp/AbrpUploadService.java | 8 -- .../java/com/evsuite/abrp/MainActivity.java | 6 - .../com/evsuite/abrp/WifiAutoConnect.java | 135 ------------------ mise.toml | 2 +- 7 files changed, 7 insertions(+), 160 deletions(-) delete mode 100644 app/src/main/java/com/evsuite/abrp/WifiAutoConnect.java diff --git a/SPECS.md b/SPECS.md index 7da3875..ac42c20 100644 --- a/SPECS.md +++ b/SPECS.md @@ -112,9 +112,11 @@ tyre pressures, GPS (lat, lon, elevation, heading) and the is_* flags. It does * INGEST_TOKEN) into git and pushed it. Commit amended and force-pushed; both values regenerated in the now gitignored `k8s/secrets.yaml` (since moved to `k8s/secrets/secrets.yaml`, outside `kubectl apply -f k8s/`). Filled-in secrets live only there from now on. +- 2026-10-18 — App 2.17.2: Wi-Fi auto-connect removed with its two permissions (ACCESS_ and + CHANGE_WIFI_STATE). Upstream used it to keep the head unit on a phone hotspot; this car's head + unit has its own connection. - 2026-10-18 — App 2.17.1: Settings without the Wi-Fi and saved-networks cards, in three columns - (display and steering wheel | battery | charging price and app). The background Wi-Fi - auto-connect stays on, now without a switch. Logs keeps only the upload log: the steering-key + (display and steering wheel | battery | charging price and app). Logs keeps only the upload log: the steering-key reports, the car-colour probe and the screen line are gone, their questions answered. - 2026-10-18 — App 2.17.0: "Speed, 5-min average" card under Speed. The top bar is one compact line (title and subtitle); version, update check and About moved to an App card on the Settings tab. diff --git a/app/build.gradle b/app/build.gradle index b6db3a4..fa4b9b8 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -34,8 +34,8 @@ android { applicationId "com.stinnesbeck.nilsmg4" minSdk 27 targetSdk 36 - versionCode 90 - versionName "2.17.1" + versionCode 91 + versionName "2.17.2" // Nothing about the server ships in the APK: its URL and ingest token are entered in // the app's Server tab (ServerConfig), and the database is only reachable by the server. diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 36b8413..68f3033 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -14,12 +14,6 @@ - - - diff --git a/app/src/main/java/com/evsuite/abrp/AbrpUploadService.java b/app/src/main/java/com/evsuite/abrp/AbrpUploadService.java index 63892dc..78da357 100644 --- a/app/src/main/java/com/evsuite/abrp/AbrpUploadService.java +++ b/app/src/main/java/com/evsuite/abrp/AbrpUploadService.java @@ -68,7 +68,6 @@ public class AbrpUploadService extends Service { private ChargeSessionTracker chargeSessionTracker; private TelemetryUploader telemetryUploader; private SteeringKeyListener steeringKeys; - private WifiAutoConnect wifi; private ScheduledExecutorService scheduler; private SharedPreferences prefs; @@ -97,7 +96,6 @@ public class AbrpUploadService extends Service { consumptionTracker = ConsumptionTracker.get(this); consumptionCloudClient = new ConsumptionCloudClient(getApplicationContext()); chargeSessionTracker = ChargeSessionTracker.get(getApplicationContext()); - wifi = new WifiAutoConnect(getApplicationContext()); telemetryUploader = new TelemetryUploader(getApplicationContext()); steeringKeys = new SteeringKeyListener(this, prefs); steeringKeys.register(); @@ -169,12 +167,6 @@ public class AbrpUploadService extends Service { chargeSessionTracker.sample(vehicle); consumptionCloudClient.syncIfDue(consumptionTracker, chargeSessionTracker, sampleMs); - // Keep the head unit on the phone hotspot — the sync needs a network, and this is - // exactly when being offline costs something. Rate-limited internally. - if (wifi != null && prefs.getBoolean(MainActivity.WIFI_AUTO_KEY, true)) { - wifi.ensureConnected(false); - } - UploadSettings settings = UploadSettings.from(prefs); // Live data goes to the server, to ABRP directly, or both; with neither set up there is // nowhere to send it. diff --git a/app/src/main/java/com/evsuite/abrp/MainActivity.java b/app/src/main/java/com/evsuite/abrp/MainActivity.java index a532bb6..00efca0 100644 --- a/app/src/main/java/com/evsuite/abrp/MainActivity.java +++ b/app/src/main/java/com/evsuite/abrp/MainActivity.java @@ -35,12 +35,6 @@ import com.evsuite.hardware.telemetry.EnergyTelemetryReader; public class MainActivity extends AppCompatActivity { - /** - * Rejoin a saved network (the phone hotspot) in the background; read by - * {@link AbrpUploadService} on every tick. On by default; the Wi-Fi card with its switch was - * removed from Settings (2.17.1), so it now stays on. - */ - static final String WIFI_AUTO_KEY = "wifi_auto_connect"; private static final String REPOSITORY_URL = "https://git.stinnesbeck.com/nils/mg4"; /** Navigation destinations in page order. Parallel to {@link #panes}. */ diff --git a/app/src/main/java/com/evsuite/abrp/WifiAutoConnect.java b/app/src/main/java/com/evsuite/abrp/WifiAutoConnect.java deleted file mode 100644 index 2661d36..0000000 --- a/app/src/main/java/com/evsuite/abrp/WifiAutoConnect.java +++ /dev/null @@ -1,135 +0,0 @@ -package com.evsuite.abrp; - -import android.content.Context; -import android.net.wifi.WifiConfiguration; -import android.net.wifi.WifiInfo; -import android.net.wifi.WifiManager; -import android.util.Log; - -import java.util.List; - -/** - * Brings Wi-Fi up and joins a network the head unit already knows. - * - * The point is the phone hotspot: the car has the credentials saved, but the driver still - * has to walk into Settings and tap the network every time. This does that tap. - * - * Why this works here and would not on a newer phone. {@code getConfiguredNetworks} - * and {@code enableNetwork} were deprecated in API 29 and, from that release, return an - * empty list and refuse the call for ordinary apps — Android handed network choice to the - * user through a system picker. This head unit runs Android 9 (API 28), where both are - * still honoured, and the build is platform-signed, so it is the same UID family the - * Settings app itself uses. Neither fact is portable: on a Pixel this class would find - * nothing and change nothing. It is deliberately written to fail quietly if so. - * - * Nothing here creates or edits a network. It only enables one the unit was already - * configured with, which is why no password ever passes through this app. - */ -final class WifiAutoConnect { - - private static final String TAG = "EVABRP.Wifi"; - - /** Re-checking faster than this achieves nothing: a join takes seconds to settle. */ - private static final long MIN_ATTEMPT_INTERVAL_MS = 30_000L; - - private final WifiManager wifi; - private long lastAttemptMs; - - WifiAutoConnect(Context context) { - this.wifi = (WifiManager) context.getApplicationContext() - .getSystemService(Context.WIFI_SERVICE); - } - - /** The SSID the unit is associated with, or null when it is not on Wi-Fi. */ - String currentSsid() { - try { - if (wifi == null || !wifi.isWifiEnabled()) return null; - WifiInfo info = wifi.getConnectionInfo(); - if (info == null) return null; - String ssid = info.getSSID(); - if (ssid == null) return null; - // The framework quotes the SSID and reports this placeholder when it will not - // say — usually because location is off. Neither is a network name. - ssid = ssid.replace("\"", "").trim(); - if (ssid.isEmpty() || "".equals(ssid) || "0x".equals(ssid)) return null; - return ssid; - } catch (Throwable t) { - return null; - } - } - - /** - * One attempt to get onto a saved network, rate-limited. - * - * @param force ignore the rate limit — for the button, which a driver only presses - * because the automatic attempt has not worked yet. - * @return what happened, for the log and the UI. Never null. - */ - Result ensureConnected(boolean force) { - if (wifi == null) return Result.UNSUPPORTED; - - String ssid = currentSsid(); - if (ssid != null) return Result.alreadyOn(ssid); - - long now = System.currentTimeMillis(); - if (!force && now - lastAttemptMs < MIN_ATTEMPT_INTERVAL_MS) return Result.WAITING; - lastAttemptMs = now; - - try { - if (!wifi.isWifiEnabled()) { - // Also gone for ordinary apps from API 29, and also still honoured here. - if (!wifi.setWifiEnabled(true)) return Result.CANNOT_ENABLE; - // The radio needs a moment before it will associate; the next tick retries. - return Result.ENABLING; - } - - List saved = wifi.getConfiguredNetworks(); - if (saved == null || saved.isEmpty()) return Result.NO_SAVED_NETWORKS; - - // A scan would let us prefer a network that is actually in range, but the results - // are themselves throttled and location-gated. Asking the supplicant to pick from - // what it knows is both simpler and what the Settings screen effectively does. - wifi.reconnect(); - for (WifiConfiguration c : saved) { - if (c == null) continue; - // Second argument true: disable the others for this attempt, so the - // supplicant commits to this one rather than drifting between them. - if (wifi.enableNetwork(c.networkId, true)) { - String name = c.SSID == null ? "?" : c.SSID.replace("\"", "").trim(); - Log.i(TAG, "enableNetwork accepted for " + name); - return Result.attempting(name); - } - } - return Result.REFUSED; - } catch (SecurityException e) { - Log.w(TAG, "Wi-Fi connection refused: permission denied"); - return Result.REFUSED; - } catch (Throwable t) { - Log.w(TAG, "connect attempt failed: " + t.getMessage()); - return Result.REFUSED; - } - } - - /** Outcome of one attempt. {@link #connected} is the only one that means we are online. */ - static final class Result { - final boolean connected; - final String ssid; - final String message; - - private Result(boolean connected, String ssid, String message) { - this.connected = connected; - this.ssid = ssid; - this.message = message; - } - - static Result alreadyOn(String ssid) { return new Result(true, ssid, "Connected: " + ssid); } - static Result attempting(String ssid) { return new Result(false, ssid, "Connecting: " + ssid); } - - static final Result UNSUPPORTED = new Result(false, null, "Wi-Fi cannot be managed"); - static final Result WAITING = new Result(false, null, "Waiting…"); - static final Result ENABLING = new Result(false, null, "Turning Wi-Fi on…"); - static final Result CANNOT_ENABLE = new Result(false, null, "Could not turn Wi-Fi on"); - static final Result NO_SAVED_NETWORKS = new Result(false, null, "No saved networks"); - static final Result REFUSED = new Result(false, null, "The system refused"); - } -} diff --git a/mise.toml b/mise.toml index b9e8889..9e312ff 100644 --- a/mise.toml +++ b/mise.toml @@ -119,7 +119,7 @@ run = './gradlew -Dorg.gradle.java.home=$JAVA_HOME installStableDebug' [tasks.logs] description = "Follows the app's logs on the car" -run = "adb logcat -s AbrpUploadService:V CarPropertyAdapter:V VhalProbe:V SecurePrefs:V OtaUpdater:V UpdateHook:V NilsMG4.Telemetry:V NilsMG4.Mongo:V NilsMG4.SteeringKey:V EVABRP.Boot:V EVABRP.Wifi:V" +run = "adb logcat -s AbrpUploadService:V CarPropertyAdapter:V VhalProbe:V SecurePrefs:V OtaUpdater:V UpdateHook:V NilsMG4.Telemetry:V NilsMG4.Mongo:V NilsMG4.SteeringKey:V EVABRP.Boot:V" [tasks.release] description = "Release APKs of both channels (stable without OTA, unstable with OTA)" From 224840bde23aacd5f9a263112f108bc218ced038 Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Sun, 18 Oct 2026 20:35:43 +0200 Subject: [PATCH 74/79] App 2.17.3: "Odometer and climate" instead of "Energy and temperature" The Overview panel's title came from upstream, where it also showed pack energy and temperatures. The MG4 publishes none of those, so the panel only holds the odometer and the climate setpoint; its title now says so. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki --- SPECS.md | 2 ++ app/build.gradle | 4 ++-- app/src/main/res/values/strings_design.xml | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/SPECS.md b/SPECS.md index ac42c20..f32c099 100644 --- a/SPECS.md +++ b/SPECS.md @@ -112,6 +112,8 @@ tyre pressures, GPS (lat, lon, elevation, heading) and the is_* flags. It does * INGEST_TOKEN) into git and pushed it. Commit amended and force-pushed; both values regenerated in the now gitignored `k8s/secrets.yaml` (since moved to `k8s/secrets/secrets.yaml`, outside `kubectl apply -f k8s/`). Filled-in secrets live only there from now on. +- 2026-10-18 — App 2.17.3: the Overview panel "Energy and temperature" is "Odometer and climate": + it only holds those two, since the car publishes no pack energy or temperatures. - 2026-10-18 — App 2.17.2: Wi-Fi auto-connect removed with its two permissions (ACCESS_ and CHANGE_WIFI_STATE). Upstream used it to keep the head unit on a phone hotspot; this car's head unit has its own connection. diff --git a/app/build.gradle b/app/build.gradle index fa4b9b8..a0715a5 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -34,8 +34,8 @@ android { applicationId "com.stinnesbeck.nilsmg4" minSdk 27 targetSdk 36 - versionCode 91 - versionName "2.17.2" + versionCode 92 + versionName "2.17.3" // Nothing about the server ships in the APK: its URL and ingest token are entered in // the app's Server tab (ServerConfig), and the database is only reachable by the server. diff --git a/app/src/main/res/values/strings_design.xml b/app/src/main/res/values/strings_design.xml index 22d31ec..47c4210 100644 --- a/app/src/main/res/values/strings_design.xml +++ b/app/src/main/res/values/strings_design.xml @@ -16,7 +16,7 @@ Every upload, right here. Status and details of recent uploads. BATTERY - ENERGY AND TEMPERATURE + ODOMETER AND CLIMATE TIRE PRESSURES SERVER LINK Your private connection. From 5ead905e5fb9beb09d4c48506bac7d4c244e1d6e Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Sun, 18 Oct 2026 20:37:16 +0200 Subject: [PATCH 75/79] App 2.17.4: the panel is called "Vehicle info" Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki --- SPECS.md | 2 +- app/build.gradle | 4 ++-- app/src/main/res/values/strings_design.xml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/SPECS.md b/SPECS.md index f32c099..9e292e1 100644 --- a/SPECS.md +++ b/SPECS.md @@ -112,7 +112,7 @@ tyre pressures, GPS (lat, lon, elevation, heading) and the is_* flags. It does * INGEST_TOKEN) into git and pushed it. Commit amended and force-pushed; both values regenerated in the now gitignored `k8s/secrets.yaml` (since moved to `k8s/secrets/secrets.yaml`, outside `kubectl apply -f k8s/`). Filled-in secrets live only there from now on. -- 2026-10-18 — App 2.17.3: the Overview panel "Energy and temperature" is "Odometer and climate": +- 2026-10-18 — App 2.17.3: the Overview panel "Energy and temperature" is "Vehicle info" (2.17.4; "Odometer and climate" in 2.17.3): it only holds those two, since the car publishes no pack energy or temperatures. - 2026-10-18 — App 2.17.2: Wi-Fi auto-connect removed with its two permissions (ACCESS_ and CHANGE_WIFI_STATE). Upstream used it to keep the head unit on a phone hotspot; this car's head diff --git a/app/build.gradle b/app/build.gradle index a0715a5..c54e60a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -34,8 +34,8 @@ android { applicationId "com.stinnesbeck.nilsmg4" minSdk 27 targetSdk 36 - versionCode 92 - versionName "2.17.3" + versionCode 93 + versionName "2.17.4" // Nothing about the server ships in the APK: its URL and ingest token are entered in // the app's Server tab (ServerConfig), and the database is only reachable by the server. diff --git a/app/src/main/res/values/strings_design.xml b/app/src/main/res/values/strings_design.xml index 47c4210..9f4d9e0 100644 --- a/app/src/main/res/values/strings_design.xml +++ b/app/src/main/res/values/strings_design.xml @@ -16,7 +16,7 @@ Every upload, right here. Status and details of recent uploads. BATTERY - ODOMETER AND CLIMATE + VEHICLE INFO TIRE PRESSURES SERVER LINK Your private connection. From 5a82fe3dc1d338454e5597a936fc2df54690fef9 Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Sun, 18 Oct 2026 20:53:05 +0200 Subject: [PATCH 76/79] App 2.17.5: Server tab fits; installation ID is read-only - The "Your private connection" card is gone; its text was out of date (the ABRP credentials are no longer on the server). - Import from file moved next to Save and Test, in one row. - The installation ID is shown as text instead of an editable field: it is derived from the head unit, and changing it by hand only risked splitting the history. A config file can still carry an older one over, as before. - The page fits the car's 1778x640 window without scrolling (checked on the emulator), and the subtitle no longer says the server passes data on to ABRP. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki --- app/build.gradle | 4 +-- .../java/com/evsuite/abrp/MainActivity.java | 36 +++++++++---------- app/src/main/res/layout/pane_server.xml | 20 ++++------- app/src/main/res/values/strings.xml | 4 +-- app/src/main/res/values/strings_design.xml | 4 +-- 5 files changed, 27 insertions(+), 41 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index c54e60a..396a0e6 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -34,8 +34,8 @@ android { applicationId "com.stinnesbeck.nilsmg4" minSdk 27 targetSdk 36 - versionCode 93 - versionName "2.17.4" + versionCode 94 + versionName "2.17.5" // Nothing about the server ships in the APK: its URL and ingest token are entered in // the app's Server tab (ServerConfig), and the database is only reachable by the server. diff --git a/app/src/main/java/com/evsuite/abrp/MainActivity.java b/app/src/main/java/com/evsuite/abrp/MainActivity.java index 00efca0..afffebc 100644 --- a/app/src/main/java/com/evsuite/abrp/MainActivity.java +++ b/app/src/main/java/com/evsuite/abrp/MainActivity.java @@ -114,8 +114,10 @@ public class MainActivity extends AppCompatActivity { private ChargingGraphView chargeGraph; /** Server page. */ - private TextInputLayout serverUrlLayout, serverTokenLayout, installationIdLayout; - private TextInputEditText serverUrlInput, serverTokenInput, installationIdInput; + private TextInputLayout serverUrlLayout, serverTokenLayout; + private TextInputEditText serverUrlInput, serverTokenInput; + /** Shown, not edited: derived from the head unit (ServerConfig.installationIdFor). */ + private TextView installationIdText; private View connectionStatusRow, connectionIndicator; private TextView connectionStatusText; @@ -460,19 +462,17 @@ public class MainActivity extends AppCompatActivity { private void bindServerPane() { serverUrlLayout = serverPane.findViewById(R.id.server_url_layout); serverTokenLayout = serverPane.findViewById(R.id.server_token_layout); - installationIdLayout = serverPane.findViewById(R.id.installation_id_layout); serverUrlInput = serverPane.findViewById(R.id.server_url_input); serverTokenInput = serverPane.findViewById(R.id.server_token_input); - installationIdInput = serverPane.findViewById(R.id.installation_id_input); + installationIdText = serverPane.findViewById(R.id.installation_id_text); connectionStatusRow = serverPane.findViewById(R.id.connection_status_row); connectionIndicator = serverPane.findViewById(R.id.connection_indicator); connectionStatusText = serverPane.findViewById(R.id.connection_status_text); showServerConfig(); - ((TextView) serverPane.findViewById(R.id.import_hint)) - .setText(getString(R.string.import_hint, configDirHint())); serverPane.findViewById(R.id.server_save_button).setOnClickListener(v -> { - if (saveServerConfig(textOf(serverUrlInput), textOf(serverTokenInput), textOf(installationIdInput))) { + if (saveServerConfig(textOf(serverUrlInput), textOf(serverTokenInput), + ServerConfig.get(this).installationId)) { setConnectionStatus(COLOR_OK, getString(R.string.server_saved)); } }); @@ -485,7 +485,7 @@ public class MainActivity extends AppCompatActivity { ServerConfig config = ServerConfig.get(this); serverUrlInput.setText(config.url); serverTokenInput.setText(config.token); - installationIdInput.setText(config.installationId); + installationIdText.setText(getString(R.string.installation_id_info, config.installationId)); } /** Validates, marks the bad fields, and saves only when all three are good. */ @@ -495,9 +495,12 @@ public class MainActivity extends AppCompatActivity { String id = rawId == null ? "" : rawId.trim().toLowerCase(java.util.Locale.US); serverUrlLayout.setError(url == null ? getString(R.string.server_err_url) : null); serverTokenLayout.setError(ServerConfig.isValidToken(token) ? null : getString(R.string.server_err_token)); - installationIdLayout.setError(ServerConfig.isValidInstallationId(id) - ? null : getString(R.string.server_err_installation_id)); - if (url == null || !ServerConfig.isValidToken(token) || !ServerConfig.isValidInstallationId(id)) return false; + // Only an imported config file can bring a different id; the field is not editable. + if (!ServerConfig.isValidInstallationId(id)) { + setConnectionStatus(COLOR_ERROR, getString(R.string.server_err_installation_id)); + return false; + } + if (url == null || !ServerConfig.isValidToken(token)) return false; ServerConfig.save(this, url, token, id); if (consumptionCloudClient != null) consumptionCloudClient.invalidateHistory(); showServerConfig(); @@ -513,15 +516,11 @@ public class MainActivity extends AppCompatActivity { private void testConnection() { String url = ServerConfig.normalizeUrl(textOf(serverUrlInput)); String token = textOf(serverTokenInput); - String id = textOf(installationIdInput).toLowerCase(java.util.Locale.US); + String id = ServerConfig.get(this).installationId; if (url == null) { serverUrlLayout.setError(getString(R.string.server_err_url)); return; } - if (!ServerConfig.isValidInstallationId(id)) { - installationIdLayout.setError(getString(R.string.server_err_installation_id)); - return; - } setConnectionStatus(COLOR_PENDING, getString(R.string.conn_testing)); new Thread(() -> { String message; @@ -563,9 +562,7 @@ public class MainActivity extends AppCompatActivity { return; } serverUrlLayout.setError(null); - String typedId = textOf(installationIdInput).toLowerCase(java.util.Locale.US); - String installationId = ServerConfig.isValidInstallationId(typedId) - ? typedId : ServerConfig.get(this).installationId; + String installationId = ServerConfig.get(this).installationId; PhoneQrDialog ui = new PhoneQrDialog(R.string.pair_title, R.string.pair_starting); final boolean[] cancelled = ui.cancelled; @@ -973,7 +970,6 @@ public class MainActivity extends AppCompatActivity { // Show what the file said, so the marked field is the one to fix in the file. if (config.serverUrl != null) serverUrlInput.setText(config.serverUrl); if (config.token != null) serverTokenInput.setText(config.token); - if (config.installationId != null) installationIdInput.setText(config.installationId); return false; } } diff --git a/app/src/main/res/layout/pane_server.xml b/app/src/main/res/layout/pane_server.xml index 850ed83..51b5762 100644 --- a/app/src/main/res/layout/pane_server.xml +++ b/app/src/main/res/layout/pane_server.xml @@ -1,6 +1,6 @@ - + @@ -11,22 +11,16 @@ - - - - - + + + + + + - - - - - - - diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6380df6..0bf61cb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -60,6 +60,7 @@ Save Test connection Import from file + Installation ID %1$s · derived from the head unit; your data is stored under it Car server @@ -67,8 +68,6 @@ For example https://car.k8s.stinnesbeck.com Ingest token The server\'s INGEST_TOKEN, at least 24 characters - Installation ID - Your data is stored under this ID. Keep it when reinstalling. Enter an https:// URL Pair with phone Shows a QR code: scan it with your phone and approve with the approval password. The token below is then filled in for you. @@ -92,7 +91,6 @@ Not a valid ID (UUID) Server settings saved Server not configured — open the Server tab - Put a text file with server_url and token in %1$s (on the head unit or a USB stick in the same folder), then press Import. Config imported and saved — press Test to verify diff --git a/app/src/main/res/values/strings_design.xml b/app/src/main/res/values/strings_design.xml index 9f4d9e0..1c8dc16 100644 --- a/app/src/main/res/values/strings_design.xml +++ b/app/src/main/res/values/strings_design.xml @@ -8,7 +8,7 @@ Your drive at a glance. Data from your car, all in one place. Where your data goes. - Your own server stores it and passes it on to ABRP. + Your own server stores it; the car sends live data to ABRP itself. Live data. Where your car\'s live data goes, and how often. Settings. @@ -19,8 +19,6 @@ VEHICLE INFO TIRE PRESSURES SERVER LINK - Your private connection. - The token lets the car write to your server. It is stored encrypted on this device; your ABRP credentials stay on the server. Values that cannot be read are shown as —. LIVE DATA ABRP, DIRECTLY FROM THE CAR From 0fd024cc2e6e46e83b2751d7bcfae5ba84c5f912 Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Sun, 18 Oct 2026 20:53:21 +0200 Subject: [PATCH 77/79] SPECS: done-log entry for app 2.17.5 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki --- SPECS.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/SPECS.md b/SPECS.md index 9e292e1..73bd8c5 100644 --- a/SPECS.md +++ b/SPECS.md @@ -112,6 +112,10 @@ tyre pressures, GPS (lat, lon, elevation, heading) and the is_* flags. It does * INGEST_TOKEN) into git and pushed it. Commit amended and force-pushed; both values regenerated in the now gitignored `k8s/secrets.yaml` (since moved to `k8s/secrets/secrets.yaml`, outside `kubectl apply -f k8s/`). Filled-in secrets live only there from now on. +- 2026-10-18 — App 2.17.5: Server tab fits the car's window. The "Your private connection" card is + gone (its text was out of date); Import from file sits with Save and Test in one row; the + installation ID is shown as text, not edited, since it is derived from the head unit (a config + file can still carry another one over). The subtitle no longer says the server forwards to ABRP. - 2026-10-18 — App 2.17.3: the Overview panel "Energy and temperature" is "Vehicle info" (2.17.4; "Odometer and climate" in 2.17.3): it only holds those two, since the car publishes no pack energy or temperatures. - 2026-10-18 — App 2.17.2: Wi-Fi auto-connect removed with its two permissions (ACCESS_ and From 69d284f81bb3fe1be3f8cf923fecefca079186f5 Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Sun, 18 Oct 2026 20:59:45 +0200 Subject: [PATCH 78/79] App 2.18.0: install from a USB stick Settings -> App -> "Install from USB stick" (car build only). It looks in the top folder of every mounted volume (found from the app's own folder on it, since the head unit has no file picker) for NilsMG4-unstable-.apk, takes the newest one newer than the installed version, checks it against its .sha256 sidecar and the package name, and installs it with the same PackageInstaller session as the over-the-air update. The stable build gets a stub and no button. Tested on the emulator: 2.18.0 installed a 2.18.1 from the storage's top folder without a confirmation, as the system user, so the OTA install step should not need one either. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki --- OTA.md | 11 +++ SPECS.md | 5 ++ app/build.gradle | 4 +- .../java/com/evsuite/abrp/MainActivity.java | 19 ++++- .../main/res/layout-w1000dp/pane_settings.xml | 1 + app/src/main/res/layout/pane_settings.xml | 1 + app/src/main/res/values/strings.xml | 2 + .../java/com/evsuite/abrp/UpdateHook.java | 5 ++ .../java/com/evsuite/abrp/UsbUpdateTest.java | 38 ++++++++++ .../java/com/evsuite/abrp/UpdateHook.java | 75 +++++++++++++++++++ .../java/com/evsuite/abrp/UsbUpdate.java | 52 +++++++++++++ 11 files changed, 209 insertions(+), 4 deletions(-) create mode 100644 app/src/testUnstable/java/com/evsuite/abrp/UsbUpdateTest.java create mode 100644 app/src/unstable/java/com/evsuite/abrp/UsbUpdate.java diff --git a/OTA.md b/OTA.md index cb2de3e..6a0c41f 100644 --- a/OTA.md +++ b/OTA.md @@ -62,6 +62,17 @@ At start the app only checks, and shows "Update available" when there is one. It downloads or installs on its own, and the upload service never checks: installing restarts the app, which must not happen mid-drive on its own. +## Installing from a USB stick + +For development, without the forge: copy `NilsMG4-unstable-.apk` **and** its `.sha256` +from `build/car/` to the top folder of a USB stick, plug it in, and press **Install from USB +stick** on the Settings tab. The app takes the newest such file that is newer than the installed +version, checks it against the `.sha256`, checks the package, and installs it with the same +installer session as an over-the-air update. The car build only; the stable build has no button. + +On the Android 9 emulator (car build, system user) this installed without asking, from the +storage's top folder; on the car the stick's top folder is used the same way. + ## When it says something else | Message | Meaning | diff --git a/SPECS.md b/SPECS.md index 73bd8c5..3234c6c 100644 --- a/SPECS.md +++ b/SPECS.md @@ -112,6 +112,11 @@ tyre pressures, GPS (lat, lon, elevation, heading) and the is_* flags. It does * INGEST_TOKEN) into git and pushed it. Commit amended and force-pushed; both values regenerated in the now gitignored `k8s/secrets.yaml` (since moved to `k8s/secrets/secrets.yaml`, outside `kubectl apply -f k8s/`). Filled-in secrets live only there from now on. +- 2026-10-18 — App 2.18.0: Settings → App → "Install from USB stick" (car build only): the newest + `NilsMG4-unstable-.apk` newer than the installed one in a stick's top folder, checked + against its `.sha256` and the package, installed with the OTA installer session. Tested on the + emulator: 2.18.0 installed a 2.18.1 from the storage's top folder silently, as the system user — + so the OTA install step should not need a confirmation either. - 2026-10-18 — App 2.17.5: Server tab fits the car's window. The "Your private connection" card is gone (its text was out of date); Import from file sits with Save and Test in one row; the installation ID is shown as text, not edited, since it is derived from the head unit (a config diff --git a/app/build.gradle b/app/build.gradle index 396a0e6..1e21cdd 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -34,8 +34,8 @@ android { applicationId "com.stinnesbeck.nilsmg4" minSdk 27 targetSdk 36 - versionCode 94 - versionName "2.17.5" + versionCode 95 + versionName "2.18.0" // Nothing about the server ships in the APK: its URL and ingest token are entered in // the app's Server tab (ServerConfig), and the database is only reachable by the server. diff --git a/app/src/main/java/com/evsuite/abrp/MainActivity.java b/app/src/main/java/com/evsuite/abrp/MainActivity.java index afffebc..4034672 100644 --- a/app/src/main/java/com/evsuite/abrp/MainActivity.java +++ b/app/src/main/java/com/evsuite/abrp/MainActivity.java @@ -265,6 +265,10 @@ public class MainActivity extends AppCompatActivity { .setText(getString(R.string.settings_version, BuildConfig.VERSION_NAME)); settingsPane.findViewById(R.id.settings_about_button).setOnClickListener(v -> showAbout()); settingsPane.findViewById(R.id.settings_update_button).setOnClickListener(v -> checkForUpdate(v)); + View usbButton = settingsPane.findViewById(R.id.settings_usb_button); + usbButton.setOnClickListener(v -> installFromUsb(v)); + // The stable build installs nothing itself, so it has no use for the button. + if (!UpdateHook.isSupported()) usbButton.setVisibility(View.GONE); // Unstable builds check for a newer pre-release; the stable flavor's UpdateHook is // a no-op and does not even contain the updater. @@ -408,13 +412,24 @@ public class MainActivity extends AppCompatActivity { * SHA-256 verified; the final result remains visible until dismissed. */ private void checkForUpdate(View button) { + runUpdate(button, R.string.update_checking, listener -> UpdateHook.checkInBackground(this, listener)); + } + + /** Installs the newest car build from the top folder of a USB stick (unstable builds). */ + private void installFromUsb(View button) { + runUpdate(button, R.string.update_usb_looking, listener -> UpdateHook.installFromUsbInBackground(this, listener)); + } + + /** The update dialog: progress while [start] runs, then its result. */ + private void runUpdate(View button, int startText, + java.util.function.Consumer start) { button.setEnabled(false); android.widget.LinearLayout panel = new android.widget.LinearLayout(this); panel.setOrientation(android.widget.LinearLayout.VERTICAL); int padding = (int) (24 * getResources().getDisplayMetrics().density); panel.setPadding(padding, padding, padding, padding); TextView status = new TextView(this); - status.setText(R.string.update_checking); + status.setText(startText); status.setTextAppearance(R.style.Text_EV_Body); android.widget.ProgressBar progress = new android.widget.ProgressBar(this, null, android.R.attr.progressBarStyleHorizontal); @@ -432,7 +447,7 @@ public class MainActivity extends AppCompatActivity { .setCancelable(false) .create(); showKeepingFullScreen(dialog); - UpdateHook.checkInBackground(this, new UpdateHook.Listener() { + start.accept(new UpdateHook.Listener() { @Override public void onProgress(int percent) { if (percent < 0) { progress.setIndeterminate(true); diff --git a/app/src/main/res/layout-w1000dp/pane_settings.xml b/app/src/main/res/layout-w1000dp/pane_settings.xml index 2c54b3c..92822d7 100644 --- a/app/src/main/res/layout-w1000dp/pane_settings.xml +++ b/app/src/main/res/layout-w1000dp/pane_settings.xml @@ -48,6 +48,7 @@ + diff --git a/app/src/main/res/layout/pane_settings.xml b/app/src/main/res/layout/pane_settings.xml index eea34d5..437f3e5 100644 --- a/app/src/main/res/layout/pane_settings.xml +++ b/app/src/main/res/layout/pane_settings.xml @@ -42,6 +42,7 @@ + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0bf61cb..5ea54dc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -182,6 +182,8 @@ Installation failed: %1$s. The APK is in Downloads. Check for updates Checking for a new OTA version… + Looking on the USB stick… + Install from USB stick Software update Check for updates About Nils\' MG4 diff --git a/app/src/stable/java/com/evsuite/abrp/UpdateHook.java b/app/src/stable/java/com/evsuite/abrp/UpdateHook.java index c6745a2..67824a6 100644 --- a/app/src/stable/java/com/evsuite/abrp/UpdateHook.java +++ b/app/src/stable/java/com/evsuite/abrp/UpdateHook.java @@ -30,5 +30,10 @@ final class UpdateHook { if (listener != null) listener.onResult("This build has no automatic updates"); } + /** Answers immediately that this channel does not install updates. */ + static void installFromUsbInBackground(Context context, Listener listener) { + if (listener != null) listener.onResult("This build does not install updates"); + } + static boolean isSupported() { return false; } } diff --git a/app/src/testUnstable/java/com/evsuite/abrp/UsbUpdateTest.java b/app/src/testUnstable/java/com/evsuite/abrp/UsbUpdateTest.java new file mode 100644 index 0000000..abef740 --- /dev/null +++ b/app/src/testUnstable/java/com/evsuite/abrp/UsbUpdateTest.java @@ -0,0 +1,38 @@ +package com.evsuite.abrp; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +import java.io.File; + +import org.junit.Test; + +/** Finding a car build on a USB stick — pure JVM. */ +public class UsbUpdateTest { + + @Test + public void theStickRootIsFourLevelsAboveTheAppFolder() { + assertEquals(new File("/storage/1234-ABCD"), UsbUpdate.volumeRoot( + new File("/storage/1234-ABCD/Android/data/com.stinnesbeck.nilsmg4.unstable/files"))); + assertEquals(new File("/storage/emulated/0"), UsbUpdate.volumeRoot( + new File("/storage/emulated/0/Android/data/com.stinnesbeck.nilsmg4.unstable/files"))); + assertNull(UsbUpdate.volumeRoot(new File("/data/user/0/com.stinnesbeck.nilsmg4.unstable/files"))); + assertNull(UsbUpdate.volumeRoot(null)); + } + + @Test + public void theNewestNewerCarBuildIsPicked() { + String[] names = {"NilsMG4-unstable-2.17.4.apk", "NilsMG4-unstable-2.18.0.apk", "NilsMG4-unstable-2.18.0.apk.sha256", + "NilsMG4-unstable-2.17.10.apk", "NilsMG4-stable-9.0.0.apk", "FatihsMG4-unstable-9.9.9.apk", "notes.txt"}; + assertEquals("NilsMG4-unstable-2.18.0.apk", UsbUpdate.pickNewest(names, "2.17.5-unstable")); + assertEquals("NilsMG4-unstable-2.17.10.apk", UsbUpdate.pickNewest( + new String[]{"NilsMG4-unstable-2.17.4.apk", "NilsMG4-unstable-2.17.10.apk"}, "2.17.5-unstable")); + } + + @Test + public void nothingNewerMeansNothing() { + assertNull(UsbUpdate.pickNewest(new String[]{"NilsMG4-unstable-2.17.5.apk"}, "2.17.5-unstable")); + assertNull(UsbUpdate.pickNewest(new String[0], "2.17.5-unstable")); + assertNull(UsbUpdate.pickNewest(null, "2.17.5-unstable")); + } +} diff --git a/app/src/unstable/java/com/evsuite/abrp/UpdateHook.java b/app/src/unstable/java/com/evsuite/abrp/UpdateHook.java index e57833c..f4ba4b4 100644 --- a/app/src/unstable/java/com/evsuite/abrp/UpdateHook.java +++ b/app/src/unstable/java/com/evsuite/abrp/UpdateHook.java @@ -94,6 +94,81 @@ final class UpdateHook { }, "ota-check").start(); } + /** + * Installs the newest car build from the top folder of a USB stick (UsbUpdate), with the + * same checks as the over-the-air update except the server. Background thread; [listener] + * is called on the main thread. + */ + static void installFromUsbInBackground(Context context, Listener listener) { + Context app = context.getApplicationContext(); + android.os.Handler main = new android.os.Handler(android.os.Looper.getMainLooper()); + if (!checking.compareAndSet(false, true)) { + if (listener != null) main.post(() -> listener.onResult("Update check already in progress")); + return; + } + new Thread(() -> { + String message; + try { + message = runUsbInstall(app); + } catch (Throwable t) { + Log.w(TAG, "USB install threw", t); + message = "Installing from USB failed"; + } finally { + checking.set(false); + } + if (listener != null) { + final String result = message; + main.post(() -> listener.onResult(result)); + } + }, "usb-install").start(); + } + + private static String runUsbInstall(Context app) throws Exception { + String current = app.getPackageManager().getPackageInfo(app.getPackageName(), 0).versionName; + File best = null; + String bestName = null; + for (File dir : app.getExternalFilesDirs(null)) { + File root = UsbUpdate.volumeRoot(dir); + if (root == null) continue; + String[] names = root.list(); + String name = UsbUpdate.pickNewest(names, bestName == null ? current + : OtaUpdater.versionFromAssetName(bestName)); + if (name != null) { + best = new File(root, name); + bestName = name; + } + } + if (best == null) { + return "No newer NilsMG4-unstable-.apk on a USB stick (installed: " + current + ")"; + } + File sidecar = new File(best.getPath() + ".sha256"); + String expected = sidecar.isFile() && sidecar.length() < 16 * 1024 + ? OtaUpdater.parseExpectedSha256(new String(java.nio.file.Files.readAllBytes(sidecar.toPath()), + java.nio.charset.StandardCharsets.UTF_8), best.getName()) + : null; + if (expected == null) return "Not installed: " + best.getName() + ".sha256 is missing or unreadable"; + java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-256"); + try (java.io.InputStream in = new java.io.FileInputStream(best)) { + byte[] buffer = new byte[64 * 1024]; + int count; + while ((count = in.read(buffer)) != -1) digest.update(buffer, 0, count); + } + StringBuilder hex = new StringBuilder(); + for (byte b : digest.digest()) hex.append(String.format(java.util.Locale.US, "%02x", b)); + if (!hex.toString().equalsIgnoreCase(expected)) return "Not installed: " + best.getName() + " does not match its SHA-256"; + String version = OtaUpdater.versionFromAssetName(best.getName()); + switch (OtaUpdater.install(app, best)) { + case SESSION_STARTED: + return "Installing " + version + " from USB… the app restarts when it is done"; + case INSTALL_UI_STARTED: + return "Confirm the installation of " + version + " on the screen"; + case UNREADABLE_ARCHIVE: + return "Not installed: " + best.getName() + " is not this app"; + default: + return "Not installed: the installer could not start"; + } + } + /** The blocking part. Returns a line describing what happened, for the UI. */ private static String runCheck(Context app, Listener listener, android.os.Handler main) throws IOException { diff --git a/app/src/unstable/java/com/evsuite/abrp/UsbUpdate.java b/app/src/unstable/java/com/evsuite/abrp/UsbUpdate.java new file mode 100644 index 0000000..5697180 --- /dev/null +++ b/app/src/unstable/java/com/evsuite/abrp/UsbUpdate.java @@ -0,0 +1,52 @@ +package com.evsuite.abrp; + +import java.io.File; +import java.util.Locale; + +/** + * Installing a car build from a USB stick — UNSTABLE BUILDS ONLY. For development: copy + * {@code NilsMG4-unstable-.apk} and its {@code .sha256} (both from build-car) to the top + * folder of a stick, plug it in, press the button on the Settings tab. + * + * The same checks as the over-the-air update apply, minus the server: the newest such file that + * is newer than the installed version, a matching SHA-256 sidecar, this app's package, and the + * installer's own signature check. The pure parts are here, unit-tested; UpdateHook does the IO. + */ +final class UsbUpdate { + + private UsbUpdate() { } + + /** + * The top folder of the volume an app-specific folder lives on: + * /storage/1234-ABCD/Android/data//files → /storage/1234-ABCD. Null when the path + * does not have that shape. This is how a stick is found without a file picker, which the + * head unit does not have. + */ + static File volumeRoot(File appFilesDir) { + if (appFilesDir == null) return null; + File data = appFilesDir.getParentFile() == null ? null : appFilesDir.getParentFile().getParentFile(); + File android = data == null ? null : data.getParentFile(); + if (!"files".equals(appFilesDir.getName()) || data == null || !"data".equals(data.getName()) + || android == null || !"Android".equals(android.getName())) return null; + return android.getParentFile(); + } + + /** + * The car-build APK to install among [names]: the highest version that is newer than + * [currentVersion], or null. Only names build-car writes count. + */ + static String pickNewest(String[] names, String currentVersion) { + String best = null, bestVersion = null; + if (names == null) return null; + for (String name : names) { + if (name == null || !name.toLowerCase(Locale.US).startsWith("nilsmg4-unstable-")) continue; + String version = OtaUpdater.versionFromAssetName(name); + if (version == null || !OtaUpdater.isNewer(version, currentVersion)) continue; + if (bestVersion == null || OtaUpdater.isNewer(version, bestVersion)) { + best = name; + bestVersion = version; + } + } + return best; + } +} From 253c7687819a9d11594511f23f988ee3960703c2 Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Sun, 18 Oct 2026 21:12:41 +0200 Subject: [PATCH 79/79] AGENTS.md: an overview for agents; car-emulator in the flake AGENTS.md still described upstream's EVABRPUploader (GitHub updates, the old architecture and tabs). It is now the entry point for agents: what the project is (app, Go server, k8s, EVHardware), the rules (nothing touches the cluster, secrets, read-only telemetry, null is not zero, fail closed, honesty), the facts confirmed on the car, the app's structure and UI conventions, how to build, test, publish and install, and which document holds what. SPECS.md stays the journal. The Android 9 test emulator, so far a file under the ignored build/, is now reproducible: nix run .#car-emulator creates an AVD at 1920x720/160 dpi with the AOSP image that shares the car's platform key and starts it on port 5560. Checked from scratch: the car build installs as the system user. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki --- AGENTS.md | 209 ++++++++++++++++++++++++++++-------------------------- SPECS.md | 4 ++ flake.nix | 53 ++++++++++++++ 3 files changed, 164 insertions(+), 102 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 07ecc5f..36401e6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,125 +1,130 @@ -# AGENTS.md — EVABRPUploader +# AGENTS.md — Nils' MG4 -Context for AI agents and new contributors working in this repository. - -EVABRPUploader is part of **EVSuite**. The workspace `AGENTS.md` and normative -workspace `DESIGN.md` apply; this file contains only telemetry-specific additions. - -## App-specific visual direction (2.2.6) - -The user explicitly requested a permanent OLED-black theme, charcoal glass-like cards, -an accent the user picks from the MG4 paint colours (Dynamic Red #E0424C by default; the palette -is theme attributes in `res/values/attrs_accent.xml` and `accents.xml` — use `?attr/evAccent…`, -never a hard-coded accent hex). These app resources intentionally override the shared -DESIGN.md palette, DayNight preference and flat-surface rules. Do not overwrite them -with the shared token generator. Use static gradients rather than continuous effects; -keep large touch targets, readable text and null telemetry shown as a dash. -Wide layouts start at 1000dp; narrower windows use a bottom navigation bar. +Start here. What this repository is, how its parts fit, how to build, test and ship, and the +rules for changing it. Details live in the documents linked below; `SPECS.md` is the journal +(status, to-dos, a dated log of every change) and is updated with each change. ## What this is -An Android Automotive app for the **MG4 (SAIC eh32)** that reads vehicle telemetry and -pushes it to A Better Route Planner. It runs on the car's head unit (AAOS 9, MT2712). - -Fork of Leon Kernan's `ABRP_Uploader` (MIT, like every fork after it). See `LICENSE.md`: the -repository is MIT except `EVHardware/`, which is PolyForm Noncommercial — so the built APK may -only be used and shared non-commercially. Keep every copyright line in `LICENSE`. - -## Non-negotiables - -1. **Telemetry is read-only.** No vehicle settings may be written. Window automation - was removed in 2.2.5 at the user's request, including its service and door listener. -2. **A failed read is not a zero.** Getters return `Integer`/`Float`/`Boolean` and `null` - means "could not read". Null fields are omitted from the payload. Sending 0 for SOC - tells ABRP the battery is empty and wrecks a live route plan. -3. **Fail closed.** Update origin, APK signature, unknown vehicle state — refuse rather - than assume. -4. **Never let a credential reach a URL, a log line, or a crash report.** -5. **AAOS 9 must stay stable.** A third-party app crashing the head unit is unacceptable. - Every car call goes through reflection with try/catch; keep it that way. -6. **Power matters.** The head unit is awake whenever the car is on. No wake locks, no - alarms. The GPS subscription and the upload tick both cost power — keep them in step. - -## Architecture +An app for the **MG4 (SAIC eh32)** head unit plus the backend it talks to: ``` -AbrpUploadService foreground service; scheduler thread does all IPC and HTTP -CarPropertyAdapter reflection bridge to android.car (not on the compile classpath) -TelemetryPayload pure JSON building — no Android types, unit-tested -UploadCadence when a tick actually uploads — pure, unit-tested -UploadSettings user cadence configuration -UploadLog last 20 attempts + derived service state — pure, unit-tested -SecurePrefs credentials in EncryptedSharedPreferences, migrates plaintext -AbrpApi HTTP; credentials in the POST body, never the query string -MainActivity configuration, service state, upload log +MG4 head unit (Android 9, AAOS, MT2712) + app/ "Nils' MG4" ──live data, consumption, charging──▶ server/ (Go) ──▶ InfluxDB 2 ◀── Grafana + │ │ relays app releases from the forge + └──live data, straight, with the driver's own key──▶ ABRP (A Better Route Planner) ``` -**Put logic in the pure classes.** Anything Android-free can be unit-tested on the JVM; -anything inside the service effectively cannot be tested without a car. +- **app/** — Android app (Java, minSdk 27, runs on API 28). Reads the car through the + `EVHardware/` library, shows it (Overview, Consumption, Charging), sends live data to the + server and to ABRP, and updates itself. +- **server/** — Go 1.26 API at `https://car.k8s.stinnesbeck.com`: stores telemetry, consumption + and charging in InfluxDB, pairs cars by QR code, carries settings typed on a phone, relays + app releases from the private forge. It does **not** talk to ABRP. See `server/README.md`. +- **k8s/** — plain Kubernetes manifests (InfluxDB, server, Grafana with three provisioned + dashboards, Traefik routes). See `k8s/README.md`. +- **EVHardware/** — vendored shared vehicle layer (PolyForm Noncommercial, see `LICENSE.md`). -## Source sets +Repository: `ssh://git@git.stinnesbeck.com:2222/nils/mg4.git` (remote `origin`), work branch +`english-translation`. Upstream fork: `fatih` → github.com/fatihdonmezdev/FatihsMG4. -| Set | Contents | Ships in | -|---|---|---| -| `main` | everything shared | all builds | -| `stable` | `UpdateHook` no-op | stable channel | -| `unstable` | active GitHub pre-release updater and PackageInstaller result receiver | unstable channel | -| `debug` | `VhalProbe` — enumerates the whole VHAL | debug builds only | -| `testUnstable` | OTA policy tests | test only | +## Rules -The stable channel does not merely disable self-update — the updater is **not in the -APK**. +1. **Nothing touches the Kubernetes cluster.** Write and validate manifests only; the owner + applies them. No `kubectl` against the cluster, not even read-only, unless asked. +2. **Secrets.** Filled-in values only in `k8s/secrets/secrets.yaml` (gitignored); + `secrets.example.yaml` holds `CHANGE-ME` placeholders only. Stage files explicitly, check the + index for changes that are not yours (the owner works in parallel), and scan the staged diff + for secrets before every commit. Never print a token. +3. **Telemetry is read-only.** The app never writes a vehicle setting. +4. **A failed read is not a zero.** Unreadable values are `null`, omitted from payloads and + shown as "—". Sending 0 for SOC tells ABRP the battery is empty. +5. **Fail closed.** Updates (origin, SHA-256, package, signature), credentials, unknown state. +6. **No credential in a URL, a log line or a crash report.** Tokens go in headers or bodies. +7. **The head unit must stay stable.** Every car call is reflection with try/catch; no + blocking car reads on the main thread. +8. **Power.** No wake locks or alarms; GPS pauses while parked and idle. +9. **Honesty.** Most behaviour cannot be verified off the car. Say what was tested (unit tests, + emulator, podman) and what was not. "Not tried on the car" is an acceptable answer. -## Firmware generations +## The car (confirmed on the owner's car, firmware `SWI68-29958-1300R69`) -MG4 infotainment ships as SWI68 / SWI69 / SWI131 / SWI132 / SWI133 / SWI165, and they -differ. Property IDs confirmed on one generation are not universal. Anything vehicle-facing -must tolerate a property simply not existing. +- Display 1920×720 px at **160 dpi**; the app window is **1778×720 px** (the car's own sidebar), + about **1778×640 dp** for layouts. Pages must fit that without scrolling where possible. +- Signed with the public **AOSP test platform key**; the car build runs as `android.uid.system`. +- Steering-wheel keys arrive as the broadcast `com.saic.keyevent.hardkey.report` + (`android.intent.extra.hardkey.keycode` / `.down` / `.longpress`); the voice key is **287**. +- Not published by the car: battery, cabin and outside temperature, pack energy and capacity, + paint colour (only `ro.build.mtk2712.vehicle_type = AS28`). See `SPECS.md`, `FIRMWARE.md`. +- No third-party apps in the MG launcher: the app opens by a double press of the voice key. +- Its internet comes from the car itself (no Wi-Fi needed). -**Two read paths, split by whether the signal is firmware-agnostic:** +## The app -| Signal | Read path | Firmwares | -|---|---|---| -| Speed, outside temp, park | `EVHardware` getters (`getVehicleSpeedKmh`, `getOutsideTempCelsius`, `isVehicleInPark`) | all six — EVHardware branches per generation internally | -| SOC, range | `CarPropertyAdapter` vendor IDs (`0x2160F404`, `0x2140F41C`) | **SWI68 only** | -| Charge rate/port, cabin temp | `CarPropertyAdapter` standard-AAOS IDs | wherever the VHAL implements them | +| Tab | Contents | +|---|---| +| Overview | value tiles (battery, range, range/usage/energy since start, 5-min averages of range, usage, power, speed), vehicle info, tyre pressures; read every 0.5 s on a background thread | +| Consumption / Charging | period totals, daily log, charging sessions and curve | +| Live data | on/off, ABRP API key and user token, upload frequency | +| Server | URL, pairing by QR, ingest token, installation ID (read-only), config import | +| Settings | full screen, voice key, tyre unit, accent colour, battery, charging price, version, update check, install from USB, About | +| Logs | upload log | -`EVHardware.init()` is called in `AbrpUploadService.onCreate()` (idempotent); it detects -the generation and picks the right underlying API. Fields it covers therefore work on every -supported firmware. +Key classes (`app/src/main/java/com/evsuite/abrp/`): `AbrpUploadService` (foreground service, +15 s tick), `TelemetryUploader` (+ `TelemetryQueue`, persisted), `TelemetryPayload` (server +limits), `AbrpApi`/`AbrpConfig`, `ServerHttp`/`ServerConfig`/`PairingClient`/ +`PhoneSettingsClient`, `ConsumptionTracker`/`ConsumptionCloudClient`, `ChargeSessionTracker`, +`RangeEstimator`, `SecurePrefs`, `ConfigImport`, `SteeringKeyListener`, `MainActivity` (UI). +Updater (car build only): `app/src/unstable/` `OtaUpdater`, `UpdateHook`, `UsbUpdate`. -**Reference firmware: `SWI68-29958-1300R69`.** The vendor EV IDs still in -`CarPropertyAdapter` are supported only on SWI68 firmware. EVHardware -has **no EV-battery abstraction for any generation**, so SOC and range cannot go through it; -until those vendor IDs are confirmed on the other generations they stay SWI68-only, and on -another firmware they may silently return nothing (handled safely — the field is omitted, but -SOC/range would be missing). Re-confirm with `VhalProbe` before trusting the vendor IDs on any -other generation. +**Put logic in pure, unit-tested classes** (no Android types); the service and activity are +hard to test without a car. -## Working here +**UI conventions:** OLED black, glass cards, an accent the user picks from the MG4 paints — +always `?attr/evAccent…` (`res/values/attrs_accent.xml`, overlays in `accents.xml`), never a +hard-coded accent hex. All UI text in `strings.xml`. 72 dp touch targets. Values right-aligned +with tabular digits. Drop-downs are Material exposed dropdown menus and dialogs go through +`showKeepingFullScreen` — a Spinner or a plain dialog brings the car's bars back. Android 9 +crashes on gradients with theme colours: use layers. -```bash -mise run check # what CI runs: permission gate + lint + unit tests -mise run build -mise run logs # adb logcat, filtered to this app +**Flavours:** `stable` (no updater at all) and `unstable` (the car build: system UID, OTA and USB +install). The car always gets `unstable`. + +## Build, test, ship + +```sh +nix develop # JDK 21, Android SDK, Go +./gradlew testStableDebugUnitTest testUnstableDebugUnitTest lintStableDebug +build-car # build/car/NilsMG4-unstable-.apk + .sha256 +publish-car # pre-release car- on the forge (needs a token) +cd server && go vet ./... && go test ./... && govulncheck ./... ``` -Gradle needs JDK 17. If a build fails in `JdkImageTransform` complaining about `jlink`, -a stale Gradle daemon started by VS Code's Java extension is being reused — -`pkill -f GradleDaemon` and retry. +- **Versions:** three-part `versionName` in `app/build.gradle`; raise `versionCode` by one with + every build that goes to the car (a lower code is refused as a downgrade). +- **Getting it to the car:** in-app update (Settings → Check for updates, via the server), or + Install from USB stick (APK + `.sha256` in the stick's top folder), or the first time through + the Bluetooth-settings file manager (`README.md`). `OTA.md` has the whole flow. +- **Server image:** `podman build` in `server/`, push to `git.stinnesbeck.com/nils/mg4-server:`, + pin the pushed digest in `k8s/server.yaml`. Validate manifests offline with + `kustomize build k8s | kubeconform` (see `k8s/README.md`). +- **Emulator:** `nix run .#car-emulator` (add `-no-window` for headless) starts an Android 9 AOSP + emulator at 1920×720, 160 dpi on port 5560 (state in `build/emulator/`). Its image shares the + car's platform key, so the car build installs as the system user. `adb shell wm size 1778x640` + gives the car's app window (taps then use those coordinates; screenshots stay 1920×720). It has + no car data (every value is "—"). While the app is open, `uiautomator dump` never sees an idle + screen; use screenshots. +- **After each change:** a `SPECS.md` done-log entry, docs that the change affects, explicit + staging, commit, push. -## Things that will bite you +## Documents -- **`android.car` is not on the compile classpath.** Everything car-related is reflection. - R8 cannot see those uses, so `proguard-rules.pro` keeps them by name. -- **Unit tests get a stub `org.json`** from `android.jar` that returns null. The real - implementation is added as a test dependency; do not remove it. -- **`service_running` in preferences goes stale-true** after a force-kill. Use - `AbrpUploadService.isRunning()`. -- **Permission changes fail CI** until allowlisted with a justification. - -## Honesty requirements - -Most of this cannot be verified off a vehicle. When reporting work, state plainly what was -tested and what was not. "Unit tests pass, not tried on the car" is the expected and -acceptable answer. Do not imply road-testing that did not happen. +| File | What | +|---|---| +| `SPECS.md` | journal: architecture, status, signals the car lacks, to-dos, dated done-log | +| `README.md` | user-facing overview, install, setup, config file | +| `OTA.md` | releases, the updater's checks, USB install | +| `FIRMWARE.md` | the head unit, firmware generations, display | +| `server/README.md`, `k8s/README.md` | API, configuration, deployment | +| `LICENSE.md` | MIT, EVHardware's PolyForm Noncommercial, the fork chain | +| `DESIGN.md`, `CHANGELOG.md` | upstream's design system and release history | diff --git a/SPECS.md b/SPECS.md index 3234c6c..aa47a41 100644 --- a/SPECS.md +++ b/SPECS.md @@ -112,6 +112,10 @@ tyre pressures, GPS (lat, lon, elevation, heading) and the is_* flags. It does * INGEST_TOKEN) into git and pushed it. Commit amended and force-pushed; both values regenerated in the now gitignored `k8s/secrets.yaml` (since moved to `k8s/secrets/secrets.yaml`, outside `kubectl apply -f k8s/`). Filled-in secrets live only there from now on. +- 2026-10-18 — AGENTS.md rewritten as the entry point for agents: what the project is, rules, + confirmed car facts, app structure, UI conventions, build/test/ship, documents. It replaces + upstream's EVABRPUploader text. The test emulator is now in the flake: `nix run .#car-emulator` + (Android 9 AOSP at 1920×720, 160 dpi; the car build installs as the system user — checked). - 2026-10-18 — App 2.18.0: Settings → App → "Install from USB stick" (car build only): the newest `NilsMG4-unstable-.apk` newer than the installed one in a stick's top folder, checked against its `.sha256` and the package, installed with the OTA installer session. Tested on the diff --git a/flake.nix b/flake.nix index 1b7b9d5..6a9d01d 100644 --- a/flake.nix +++ b/flake.nix @@ -182,5 +182,58 @@ ''; }; }); + + # An Android 9 emulator shaped like the head unit, for testing the car build: the AOSP + # "default" image is signed with the same public platform key, so the car APK installs + # there as the system user too. 1920x720 at 160 dpi; `adb shell wm size 1778x640` gives + # the app window the car leaves. No vehicle data: every value shows "—". Not in the dev + # shell, because the emulator and its image are a large download. + # nix run .#car-emulator (from the checkout; state in build/emulator/) + packages.x86_64-linux.car-emulator = + let + pkgs = import nixpkgs { + system = "x86_64-linux"; + config = { allowUnfree = true; android_sdk.accept_license = true; }; + }; + emu = (pkgs.androidenv.composeAndroidPackages { + platformVersions = [ "28" ]; + includeEmulator = true; + includeSystemImages = true; + systemImageTypes = [ "default" ]; + abiVersions = [ "x86_64" ]; + includeNDK = false; + }).androidsdk; + in + pkgs.writeShellApplication { + name = "car-emulator"; + runtimeInputs = [ pkgs.coreutils pkgs.gnugrep ]; + text = '' + root=$(git rev-parse --show-toplevel 2>/dev/null || pwd) + state="$root/build/emulator" + mkdir -p "$state/avd" "$state/home" + export ANDROID_HOME=${emu}/libexec/android-sdk ANDROID_SDK_ROOT=${emu}/libexec/android-sdk + export ANDROID_AVD_HOME="$state/avd" ANDROID_EMULATOR_HOME="$state/home" ANDROID_USER_HOME="$state/home" + avd="$state/avd/mg4test.avd" + if [ ! -d "$avd" ]; then + echo no | ${emu}/bin/avdmanager create avd -n mg4test \ + -k "system-images;android-28;default;x86_64" --force -p "$avd" + { + echo "hw.lcd.width=1920" + echo "hw.lcd.height=720" + echo "hw.lcd.density=160" + echo "hw.ramSize=2G" + echo "disk.dataPartition.size=10G" + } >> "$avd/config.ini" + fi + echo "Starting the MG4-shaped emulator on port 5560 (adb: emulator-5560); Ctrl-C stops it." + echo "adb: ${emu}/bin/adb -s emulator-5560" + exec "$ANDROID_HOME/emulator/emulator" -avd mg4test -no-audio -no-boot-anim \ + -gpu swiftshader_indirect -no-snapshot -port 5560 "$@" + ''; + }; + apps.x86_64-linux.car-emulator = { + type = "app"; + program = "${self.packages.x86_64-linux.car-emulator}/bin/car-emulator"; + }; }; }