From ca2f899403f7a44fa8b8983b69d98c07bb24a28e Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Sun, 18 Oct 2026 12:15:21 +0200 Subject: [PATCH] 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)