Code review fixes: app 2.9.0, server 2.2.0

Telemetry no longer loses queued batches. 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 inside
the server's ranges (speed as a magnitude, so reversing is fine) and no
samples before the head unit's clock is set.

Server:
- Pairing hands out and accepts a token only after the device is saved;
  a failed save leaves the request pending.
- A paired token serves only the installation id it was paired with
  (403 otherwise); the shared INGEST_TOKEN still serves any.
- Wrong-password lockouts and open pairing requests are limited per
  client address (last X-Forwarded-For entry), so a stranger cannot
  lock the owner out; a car starting again replaces its own request.
- ABRP forwarding skips samples stamped ahead of the server's clock,
  which used to stop it until a restart.

App:
- Settings written to plaintext during a keystore failure move back
  into encrypted storage; the stored installation id wins.
- The installation id is derived from ANDROID_ID on first start, so a
  reinstall finds its history; a stored or imported id still wins.
- The scanned config file is deleted after import (it holds the token).
- GPS stops while live telemetry is off or the server unconfigured, and
  pauses while parked and idle, keeping the fix the car parked with.
- Range estimates use the monotonic clock.
- One HTTP helper (ServerHttp) for telemetry, sync and pairing.
- Last Turkish strings translated; mise logs shows the NilsMG4 tags.

Grafana reads InfluxDB with its own read-only token (new grafana-influx
Secret, created by hand as described in k8s/README.md).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
This commit is contained in:
2026-10-18 00:36:19 +02:00
co-authored by Claude Opus 5.5
parent 9bebf56b3a
commit a5db5604d3
35 changed files with 962 additions and 323 deletions
@@ -160,7 +160,8 @@ public class AbrpUploadService extends Service {
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());
RangeEstimator.shared().add(android.os.SystemClock.elapsedRealtime(),
vehicle.getBatteryPowerKw(), vehicle.getSpeedKmh());
consumptionTracker.sample(vehicle);
chargeSessionTracker.sample(vehicle);
consumptionCloudClient.syncIfDue(consumptionTracker, chargeSessionTracker, sampleMs);
@@ -173,8 +174,10 @@ public class AbrpUploadService extends Service {
UploadSettings settings = UploadSettings.from(prefs);
if (!ServerConfig.get(this).isConfigured()) {
telemetryUploader.stop(); // no live telemetry, so no GPS either
updateNotification(getString(R.string.server_not_configured));
} else if (!settings.abrpUploadEnabled) {
telemetryUploader.stop();
updateNotification(getString(R.string.notif_tracking_locally));
} else {
telemetryUploader.tick(vehicle, chargeSessionTracker.snapshot(), settings);
@@ -7,13 +7,6 @@ import android.util.Log;
import org.json.JSONArray;
import org.json.JSONObject;
import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Collections;
@@ -23,8 +16,9 @@ import java.util.List;
* Best-effort sync to the car server. Never blocks local persistence.
*
* Server URL, token and installation id come from {@link ServerConfig}, read on every use so
* a change in the Server tab applies at once. The installation id is kept across updates; a
* reinstall gets it back through the config file, so the history is not orphaned under a new
* a change in the Server tab applies at once. The installation id is kept across updates, and
* a reinstall derives the same one from the head unit ({@link ServerConfig#installationIdFor})
* or gets it back through the config file, so the history is not orphaned under a new
* identity. The consumption UI reads back its own uploaded totals via
* {@link #fetchHistoryIfDue}, so a reinstall picks up where the old one left off.
*/
@@ -33,7 +27,6 @@ final class ConsumptionCloudClient {
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;
private static final int TIMEOUT_MS = 8_000;
private static final String PREFS = "consumption_cloud";
private static final String CHUNK_CACHE = "chunk_cache"; // JSON array of pending chunks
private static final int MAX_CHARGES_PER_SYNC = 10;
@@ -244,36 +237,20 @@ final class ConsumptionCloudClient {
}
private boolean send(String path, String method, JSONObject body) throws Exception {
HttpURLConnection connection = null;
String url = trimSlash(config().url) + path;
Log.i(TAG, "Sending " + method + " request to " + url);
try {
byte[] bytes = body.toString().getBytes(StandardCharsets.UTF_8);
String url = trimSlash(config().url) + path;
Log.i(TAG, "Sending " + method + " request to " + url + " (" + bytes.length + " bytes)");
Log.i(TAG, "Body: " + body.toString());
connection = (HttpURLConnection) new URL(url).openConnection();
connection.setRequestMethod(method);
connection.setConnectTimeout(TIMEOUT_MS);
connection.setReadTimeout(TIMEOUT_MS);
connection.setDoOutput(true);
connection.setFixedLengthStreamingMode(bytes.length);
connection.setRequestProperty("Content-Type", "application/json");
connection.setRequestProperty("Authorization", "Bearer " + config().token);
try (OutputStream out = connection.getOutputStream()) { out.write(bytes); }
int status = connection.getResponseCode();
Log.i(TAG, "Response: HTTP " + status);
if (status < 200 || status >= 300) {
String errBody = readErrorBody(connection);
Log.w(TAG, "Sync rejected with HTTP " + status + (errBody.isEmpty() ? "" : ": " + errBody));
ServerHttp.Response response = ServerHttp.request(method, url, config().token, body);
Log.i(TAG, "Response: HTTP " + response.status);
if (!response.ok()) {
String err = response.excerpt();
Log.w(TAG, "Sync rejected with HTTP " + response.status + (err.isEmpty() ? "" : ": " + err));
return false;
}
InputStream response = connection.getInputStream();
if (response != null) try (InputStream ignored = response) { }
return true;
} catch (Exception e) {
Log.w(TAG, "Network error: " + e.getMessage());
throw e;
} finally {
if (connection != null) connection.disconnect();
}
}
@@ -352,43 +329,32 @@ final class ConsumptionCloudClient {
}
private List<DayRecord> fetchHistory() throws Exception {
HttpURLConnection connection = null;
try {
String url = trimSlash(config().url) + "/v1/consumption/history/" + config().installationId;
connection = (HttpURLConnection) new URL(url).openConnection();
connection.setRequestMethod("GET");
connection.setConnectTimeout(TIMEOUT_MS);
connection.setReadTimeout(TIMEOUT_MS);
connection.setRequestProperty("Authorization", "Bearer " + config().token);
int status = connection.getResponseCode();
Log.i(TAG, "History GET response: HTTP " + status);
if (status < 200 || status >= 300) {
String err = readErrorBody(connection);
Log.w(TAG, "History fetch rejected with HTTP " + status + (err.isEmpty() ? "" : ": " + err));
logEvent(false, status, "History GET HTTP " + status + (err.isEmpty() ? "" : " — " + err));
return cachedHistory;
}
String body = readBody(connection);
JSONObject json = new JSONObject(body);
JSONArray records = json.optJSONArray("records");
if (records == null) return Collections.emptyList();
// Lifetime is derived by the backend (sum of all days), sent once at the response
// level. Each DayRecord carries it so the UI's LIFETIME period can read it.
ConsumptionTracker.Totals lifetime = json.has("lifetime") && !json.isNull("lifetime")
? parseTotals(json.getJSONObject("lifetime")) : null;
List<DayRecord> result = new ArrayList<>(records.length());
for (int i = 0; i < records.length(); i++) {
JSONObject r = records.getJSONObject(i);
LocalDate date = LocalDate.parse(r.getString("date"));
ConsumptionTracker.Totals day = parseTotals(r.getJSONObject("day"));
Float soh = r.has("sohPercent") && !r.isNull("sohPercent")
? (float) r.getDouble("sohPercent") : null;
result.add(new DayRecord(date, day, lifetime, soh));
}
return result;
} finally {
if (connection != null) connection.disconnect();
String url = trimSlash(config().url) + "/v1/consumption/history/" + config().installationId;
ServerHttp.Response response = ServerHttp.request("GET", url, config().token, null);
Log.i(TAG, "History GET response: HTTP " + response.status);
if (!response.ok()) {
String err = response.excerpt();
Log.w(TAG, "History fetch rejected with HTTP " + response.status + (err.isEmpty() ? "" : ": " + err));
logEvent(false, response.status, "History GET HTTP " + response.status + (err.isEmpty() ? "" : " — " + err));
return cachedHistory;
}
JSONObject json = new JSONObject(response.body);
JSONArray records = json.optJSONArray("records");
if (records == null) return Collections.emptyList();
// Lifetime is derived by the backend (sum of all days), sent once at the response
// level. Each DayRecord carries it so the UI's LIFETIME period can read it.
ConsumptionTracker.Totals lifetime = json.has("lifetime") && !json.isNull("lifetime")
? parseTotals(json.getJSONObject("lifetime")) : null;
List<DayRecord> result = new ArrayList<>(records.length());
for (int i = 0; i < records.length(); i++) {
JSONObject r = records.getJSONObject(i);
LocalDate date = LocalDate.parse(r.getString("date"));
ConsumptionTracker.Totals day = parseTotals(r.getJSONObject("day"));
Float soh = r.has("sohPercent") && !r.isNull("sohPercent")
? (float) r.getDouble("sohPercent") : null;
result.add(new DayRecord(date, day, lifetime, soh));
}
return result;
}
private static ConsumptionTracker.Totals parseTotals(JSONObject o) {
@@ -397,35 +363,6 @@ final class ConsumptionCloudClient {
o.optDouble("hours", 0d), o.optDouble("socDrop", 0d));
}
private static String readBody(HttpURLConnection connection) throws Exception {
InputStream stream = connection.getInputStream();
try (BufferedReader br = new BufferedReader(
new InputStreamReader(stream, StandardCharsets.UTF_8))) {
StringBuilder sb = new StringBuilder();
String line;
while ((line = br.readLine()) != null) sb.append(line);
return sb.toString();
}
}
/** Reads the error response body (truncated) for logging — never throws. */
private static String readErrorBody(HttpURLConnection connection) {
try {
InputStream stream = connection.getErrorStream();
if (stream == null) return "";
try (BufferedReader br = new BufferedReader(
new InputStreamReader(stream, StandardCharsets.UTF_8))) {
StringBuilder sb = new StringBuilder();
String line;
while ((line = br.readLine()) != null) sb.append(line);
String body = sb.toString();
return body.length() > 300 ? body.substring(0, 300) + "…" : body;
}
} catch (Exception e) {
return "";
}
}
private static String trimSlash(String value) {
int end = value.length();
while (end > 0 && value.charAt(end - 1) == '/') end--;
@@ -774,13 +774,28 @@ public class MainActivity extends AppCompatActivity {
return dirs.length > 0 && dirs[0] != null ? dirs[0].getAbsolutePath() : "";
}
/**
* Imports a scanned file and then deletes it: it holds the ingest token in plaintext, maybe
* on a USB stick, and the settings live in encrypted storage from now on.
*/
private void importConfig(java.io.File file) {
String text = readText(file);
if (text.isEmpty()) {
setConnectionStatus(COLOR_ERROR, getString(R.string.import_err_read));
return;
}
applyConfig(text);
if (!applyConfig(text)) return;
boolean deleted;
try {
deleted = file.delete();
} catch (SecurityException e) {
deleted = false;
}
if (deleted) {
setConnectionStatus(COLOR_OK, getString(R.string.import_ok_deleted));
} else {
setConnectionStatus(COLOR_ERROR, getString(R.string.import_ok_not_deleted, file.getAbsolutePath()));
}
}
private void importConfig(Uri uri) {
@@ -800,11 +815,11 @@ public class MainActivity extends AppCompatActivity {
* only the token does not wipe the URL. Server settings are validated and saved together;
* cadence keys go to the plain prefs the same way the Upload page writes them.
*/
private void applyConfig(String text) {
private boolean applyConfig(String text) {
ConfigImport config = ConfigImport.parse(text);
if (config.isEmpty()) {
setConnectionStatus(COLOR_ERROR, getString(R.string.import_err_empty));
return;
return false;
}
ServerConfig current = ServerConfig.get(this);
if (config.serverUrl != null || config.token != null || config.installationId != null) {
@@ -817,7 +832,7 @@ public class MainActivity extends AppCompatActivity {
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;
return false;
}
}
SharedPreferences.Editor edit = prefs.edit();
@@ -828,6 +843,7 @@ public class MainActivity extends AppCompatActivity {
bindCadenceControls();
setConnectionStatus(COLOR_OK, getString(R.string.import_ok));
refreshStatus();
return true;
}
// ---------- Upload page ----------
@@ -993,15 +1009,16 @@ 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.
*/
private void refreshRangeEstimates(EnergySnapshot s, long nowMs) {
private void refreshRangeEstimates(EnergySnapshot s) {
RangeEstimator estimator = RangeEstimator.shared();
estimator.add(nowMs, s.getBatteryPowerKw(), s.getSpeedKmh());
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, nowMs), R.string.range_waiting);
estimator.average(remaining, monotonicMs), R.string.range_waiting);
}
private void showEstimate(TextView value, TextView note, RangeEstimator.Estimate e, int whyNot) {
@@ -1041,7 +1058,7 @@ public class MainActivity extends AppCompatActivity {
vehHvac.setText(fmt(s.getClimate().getDriverTargetCelsius(), "%.0f °C"));
vehCharging.setText(chargingLabel(s));
refreshRangeEstimates(s, System.currentTimeMillis());
refreshRangeEstimates(s);
vehTireFl.setText(fmt(s.getTirePressures().getFrontLeftKpa(), "%.0f kPa"));
vehTireFr.setText(fmt(s.getTirePressures().getFrontRightKpa(), "%.0f kPa"));
@@ -2,12 +2,6 @@ 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
@@ -15,8 +9,6 @@ import java.nio.charset.StandardCharsets;
*/
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;
@@ -68,7 +60,7 @@ final class PairingClient {
private PairingClient() { }
static Request start(String serverUrl, String installationId) throws Exception {
Response r = post(serverUrl + "/v1/pair/start",
ServerHttp.Response r = post(serverUrl + "/v1/pair/start",
new JSONObject().put("installationId", installationId));
// 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.
@@ -86,7 +78,7 @@ final class PairingClient {
}
static PollResult poll(String serverUrl, String deviceCode) throws Exception {
Response r = post(serverUrl + "/v1/pair/poll", new JSONObject().put("deviceCode", deviceCode));
ServerHttp.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);
@@ -96,42 +88,7 @@ final class PairingClient {
}
}
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();
}
private static ServerHttp.Response post(String url, JSONObject json) throws Exception {
return ServerHttp.request("POST", url, null, json);
}
}
@@ -14,7 +14,9 @@ import java.util.Iterator;
* 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.
* time-weighted, so the two rates mix correctly. Times are on the monotonic clock
* (SystemClock.elapsedRealtime): the head unit sets its wall clock from GPS, and a jump back
* would make every new sample look out of order. Free of Android types, unit-tested.
*/
final class RangeEstimator {
@@ -7,10 +7,18 @@ import android.util.Log;
import androidx.security.crypto.EncryptedSharedPreferences;
import androidx.security.crypto.MasterKey;
import java.util.HashMap;
import java.util.Map;
/**
* Encrypted storage for the server settings — above all the ingest token, which `adb backup`
* would otherwise hand out with the regular SharedPreferences file. Backed by an
* AndroidKeyStore master key.
*
* If the keystore fails, the settings go to a plaintext file for that run rather than the app
* crashing on the head unit. The next run that opens the encrypted store moves them back in
* and deletes the file ({@link #mergePlaintext}), so a token saved meanwhile is neither lost
* nor left in plaintext.
*/
final class SecurePrefs {
@@ -33,16 +41,59 @@ final class SecurePrefs {
MasterKey masterKey = new MasterKey.Builder(app)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build();
instance = EncryptedSharedPreferences.create(
SharedPreferences encrypted = EncryptedSharedPreferences.create(
app,
FILE_NAME,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM);
migrateFromPlaintext(app, encrypted);
instance = encrypted;
} catch (Exception e) {
Log.e(TAG, "Encrypted preferences unavailable, falling back to plaintext", e);
instance = app.getSharedPreferences(PLAIN_NAME, Context.MODE_PRIVATE);
}
return instance;
}
/** Moves what a fallback run wrote into the encrypted store, then deletes the plaintext. */
private static void migrateFromPlaintext(Context app, SharedPreferences encrypted) {
SharedPreferences plain = app.getSharedPreferences(PLAIN_NAME, Context.MODE_PRIVATE);
Map<String, ?> leftover = plain.getAll();
if (leftover.isEmpty()) return;
Map<String, String> moves = mergePlaintext(strings(leftover), strings(encrypted.getAll()));
SharedPreferences.Editor edit = encrypted.edit();
for (Map.Entry<String, String> e : moves.entrySet()) edit.putString(e.getKey(), e.getValue());
if (edit.commit()) {
plain.edit().clear().commit();
Log.i(TAG, "Moved " + moves.size() + " setting(s) from plaintext into encrypted storage");
}
}
/**
* What to write into the encrypted store from a fallback run's plaintext file. The plaintext
* is newer, so its URL and token win — that is a pairing done while the keystore was down.
* The installation id is the exception: the encrypted one is what the car's history is
* stored under, and a fallback run may only have made up a new one.
*/
static Map<String, String> mergePlaintext(Map<String, String> plain, Map<String, String> encrypted) {
Map<String, String> moves = new HashMap<>();
for (Map.Entry<String, String> e : plain.entrySet()) {
if (e.getValue() == null) continue;
String existing = encrypted.get(e.getKey());
if (ServerConfig.KEY_INSTALLATION_ID.equals(e.getKey()) && existing != null && !existing.isEmpty()) {
continue;
}
moves.put(e.getKey(), e.getValue());
}
return moves;
}
private static Map<String, String> strings(Map<String, ?> all) {
Map<String, String> out = new HashMap<>();
for (Map.Entry<String, ?> e : all.entrySet()) {
if (e.getValue() instanceof String) out.put(e.getKey(), (String) e.getValue());
}
return out;
}
}
@@ -49,6 +49,9 @@ final class ServerConfig {
return !url.isEmpty() && !token.isEmpty();
}
// ANDROID_ID on purpose: it is hashed into the installation id, so a reinstall keeps its
// history (installationIdFor). It is never sent or logged as it is.
@android.annotation.SuppressLint("HardwareIds")
static ServerConfig get(Context context) {
ServerConfig config = current;
if (config != null) return config;
@@ -57,8 +60,18 @@ final class ServerConfig {
SharedPreferences prefs = SecurePrefs.get(context);
String id = prefs.getString(KEY_INSTALLATION_ID, "");
if (!isValidInstallationId(id)) {
// First start: this installation gets its own identity, kept from now on.
id = UUID.randomUUID().toString();
// First start, or the first after a reinstall: derived from the head unit, so
// the history stays under the same id (see installationIdFor).
String androidId = null;
try {
androidId = android.provider.Settings.Secure.getString(
context.getApplicationContext().getContentResolver(),
android.provider.Settings.Secure.ANDROID_ID);
} catch (RuntimeException ignored) {
// no id from the platform: a random one below
}
id = installationIdFor(androidId);
if (id == null) id = UUID.randomUUID().toString();
prefs.edit().putString(KEY_INSTALLATION_ID, id).apply();
}
current = new ServerConfig(prefs.getString(KEY_URL, DEFAULT_URL), prefs.getString(KEY_TOKEN, ""), id);
@@ -97,6 +110,22 @@ final class ServerConfig {
return token != null && token.trim().length() >= MIN_TOKEN_LENGTH && !token.trim().contains(" ");
}
/**
* The installation id for a head unit, or null without a usable ANDROID_ID. From Android 8
* ANDROID_ID is fixed per device and signing key and survives an uninstall, so a
* reinstall of the same platform-signed build finds its history again without importing
* the id. Hashed into a name-based UUID: the server expects a UUID, and the raw
* ANDROID_ID never leaves the car. An id already stored (or imported) always wins over it.
*/
static String installationIdFor(String androidId) {
if (androidId == null) return null;
String trimmed = androidId.trim();
// The emulator-era constant that many devices once shared; not an identity.
if (trimmed.isEmpty() || trimmed.equalsIgnoreCase("9774d56d682e549c")) return null;
byte[] name = ("nilsmg4:" + trimmed.toLowerCase(Locale.US)).getBytes(java.nio.charset.StandardCharsets.UTF_8);
return UUID.nameUUIDFromBytes(name).toString();
}
static boolean isValidInstallationId(String id) {
return id != null && UUID_PATTERN.matcher(id.trim()).matches();
}
@@ -0,0 +1,87 @@
package com.evsuite.abrp;
import org.json.JSONObject;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
/**
* One JSON request to the car server, shared by telemetry, consumption sync and pairing.
* 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
* body is read for every status (error bodies name the problem) and capped, so a wrong server
* cannot fill memory.
*/
final class ServerHttp {
static final int TIMEOUT_MS = 8_000;
private static final int MAX_BODY_BYTES = 64 * 1024;
/** Status and body of an answer. */
static final class Response {
final int status;
final String body;
Response(int status, String body) {
this.status = status;
this.body = body;
}
boolean ok() { return status >= 200 && status < 300; }
/** The body for a log line: short, on one line. */
String excerpt() {
String text = body.replace('\n', ' ').trim();
return text.length() > 300 ? text.substring(0, 300) + "…" : text;
}
}
private ServerHttp() { }
/**
* Sends {@code json} (or nothing, when null) with {@code method} to {@code url}.
* {@code token} may be null for the unauthenticated pairing calls.
*/
static Response request(String method, String url, String token, JSONObject json) 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);
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();
return new Response(status, readBody(connection, status));
} finally {
connection.disconnect();
}
}
private static String readBody(HttpURLConnection connection, int status) {
try {
InputStream in = status < 400 ? connection.getInputStream() : connection.getErrorStream();
if (in == null) return "";
try (InputStream stream = in) {
ByteArrayOutputStream buf = new ByteArrayOutputStream();
byte[] chunk = new byte[4096];
int n;
while ((n = stream.read(chunk)) != -1 && buf.size() < MAX_BODY_BYTES) buf.write(chunk, 0, n);
return buf.toString(StandardCharsets.UTF_8.name());
}
} catch (Exception e) {
// The status is what matters; an unreadable body is just empty.
return "";
}
}
}
@@ -64,7 +64,8 @@ final class TelemetryPayload {
tlm.put("utc", utc);
putIfPresent(tlm, "soc", soc);
putIfPresent(tlm, "speed", speedKmh == null ? null : Math.round(speedKmh));
// Reversing reads negative; ABRP and the server take speed as a magnitude.
putIfPresent(tlm, "speed", speedKmh == null ? null : Math.round(Math.abs(speedKmh)));
putIfPresent(tlm, "est_battery_range", rangeKm);
if (powerKw != null) tlm.put("power", round2(powerKw));
putIfPresent(tlm, "is_charging", boolToInt(charging));
@@ -134,9 +135,62 @@ final class TelemetryPayload {
// already exclude. Returning just the timestamp keeps the upload alive.
return "{\"utc\":" + utc + "}";
}
dropOutOfRange(tlm);
return tlm.toString();
}
/**
* The server's range for every numeric field (server/model.go, telemetryNumbers). A value
* outside it is left out here, as an unread one would be: the server would drop it anyway,
* and an older server rejects the whole batch for it.
*/
static final java.util.Map<String, double[]> SERVER_LIMITS = new java.util.HashMap<>();
static {
SERVER_LIMITS.put("soc", new double[]{0, 100});
SERVER_LIMITS.put("speed", new double[]{0, 400});
SERVER_LIMITS.put("power", new double[]{-1000, 1000});
SERVER_LIMITS.put("est_battery_range", new double[]{0, 2000});
SERVER_LIMITS.put("capacity", new double[]{0, 500});
SERVER_LIMITS.put("soe", new double[]{0, 500});
SERVER_LIMITS.put("kwh_charged", new double[]{0, 1000});
SERVER_LIMITS.put("odometer", new double[]{0, 10_000_000});
SERVER_LIMITS.put("lat", new double[]{-90, 90});
SERVER_LIMITS.put("lon", new double[]{-180, 180});
SERVER_LIMITS.put("elevation", new double[]{-1000, 10_000});
SERVER_LIMITS.put("heading", new double[]{0, 360});
SERVER_LIMITS.put("batt_temp", new double[]{-60, 100});
SERVER_LIMITS.put("cabin_temp", new double[]{-60, 100});
SERVER_LIMITS.put("ext_temp", new double[]{-60, 70});
SERVER_LIMITS.put("hvac_setpoint", new double[]{0, 40});
SERVER_LIMITS.put("voltage", new double[]{0, 1500});
SERVER_LIMITS.put("current", new double[]{-2000, 2000});
SERVER_LIMITS.put("tire_pressure_fl", new double[]{0, 1000});
SERVER_LIMITS.put("tire_pressure_fr", new double[]{0, 1000});
SERVER_LIMITS.put("tire_pressure_rl", new double[]{0, 1000});
SERVER_LIMITS.put("tire_pressure_rr", new double[]{0, 1000});
}
private static void dropOutOfRange(JSONObject tlm) {
for (java.util.Map.Entry<String, double[]> limit : SERVER_LIMITS.entrySet()) {
String key = limit.getKey();
if (!tlm.has(key)) continue;
double value = tlm.optDouble(key, Double.NaN);
if (Double.isNaN(value) || value < limit.getValue()[0] || value > limit.getValue()[1]) tlm.remove(key);
}
// A position is only a position as a pair.
if (tlm.has("lat") != tlm.has("lon")) {
tlm.remove("lat");
tlm.remove("lon");
}
}
/** Epoch seconds the server accepts: from 2001. Earlier means the head unit's clock is not set yet. */
static final long MIN_UTC = 1_000_000_000L;
static boolean isPlausibleUtc(long utcSeconds) {
return utcSeconds >= MIN_UTC;
}
/**
* True for a temperature that a sensor could actually have measured. Excludes exactly
* 0.0, which is what an unimplemented VHAL property returns — see cabin_temp above.
@@ -15,10 +15,6 @@ import com.evsuite.hardware.telemetry.TirePressureSnapshot;
import org.json.JSONArray;
import org.json.JSONObject;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Objects;
@@ -28,7 +24,9 @@ import java.util.Objects;
* 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
* at the configured interval. Parked and not charging, a sample is only taken when one is
* sent, every 15 minutes: nothing changes, and a sample every 15 s overnight is waste.
* sent, every 15 minutes: nothing changes, and a sample every 15 s overnight is waste. The GPS
* subscription follows the same rule: it is paused while parked and idle, and those samples
* carry the fix the car parked with. {@link #stop} ends it when live telemetry is off.
*
* Runs on the service's scheduler thread; the HTTP calls block it, with short timeouts.
* Location updates arrive on the main looper and are only read here.
@@ -36,7 +34,6 @@ import java.util.Objects;
final class TelemetryUploader {
private static final String TAG = "NilsMG4.Telemetry";
private static final int TIMEOUT_MS = 8_000;
/** Batches per tick: a backlog drains over a few ticks instead of stalling one. */
private static final int MAX_BATCHES_PER_TICK = 2;
/** A fix older than this no longer says where the car is; position is omitted. */
@@ -48,6 +45,10 @@ final class TelemetryUploader {
private final ChargingSignal chargingSignal = new ChargingSignal();
private volatile Location lastLocation;
/** The fix kept while parked and idle, with the GPS paused; null otherwise. */
private volatile Location parkedLocation;
/** Whether the GPS should run; a request still queued on the main looper checks it. */
private volatile boolean locationWanted;
private volatile boolean locationActive;
private volatile long lastLocationRequestMs;
private LocationManager locationManager;
@@ -64,7 +65,6 @@ final class TelemetryUploader {
void tick(EnergySnapshot vehicle, ChargeSessionTracker.Snapshot session, UploadSettings settings) {
long nowMs = System.currentTimeMillis();
ensureLocationUpdates(settings);
Integer soc = vehicle.getSocPercent() == null ? null : Math.round(vehicle.getSocPercent());
Float powerKw = vehicle.getBatteryPowerKw();
@@ -82,10 +82,24 @@ final class TelemetryUploader {
lastCharging = charging;
boolean idle = Boolean.TRUE.equals(parked) && Boolean.FALSE.equals(charging);
if (idle) {
// Parked and idle the car does not move: keep one fresh fix and stop the GPS until
// the car is in use again. Without a fix yet (just started, or in a garage) it
// keeps looking.
if (parkedLocation == null) parkedLocation = freshLocation(settings);
if (parkedLocation == null) ensureLocationUpdates(settings);
else if (locationWanted) stop();
} else {
parkedLocation = null;
ensureLocationUpdates(settings);
}
boolean uploadDue = UploadCadence.shouldUpload(
nowMs, lastUploadAttemptMs, parked, charging, soc, stateChanged, settings);
if (!idle || uploadDue) {
queue.add(buildSample(nowMs, vehicle, session, settings, soc, charging, dcfc, parked));
// Before the head unit has set its clock the sample's time is meaningless, and the
// server would drop it anyway.
boolean clockSet = TelemetryPayload.isPlausibleUtc(nowMs / 1000);
if (clockSet && (!idle || uploadDue)) {
queue.add(buildSample(nowMs, vehicle, session, settings, soc, charging, dcfc, parked, idle));
}
if (!uploadDue) return;
// Cadence counts attempts, not successes: retrying every tick while offline would
@@ -99,7 +113,8 @@ final class TelemetryUploader {
// ---------- Sample ----------
private String buildSample(long nowMs, EnergySnapshot vehicle, ChargeSessionTracker.Snapshot session,
UploadSettings settings, Integer soc, Boolean charging, Boolean dcfc, Boolean parked) {
UploadSettings settings, Integer soc, Boolean charging, Boolean dcfc, Boolean parked,
boolean idle) {
TelemetryPayload tlm = new TelemetryPayload(nowMs / 1000);
tlm.soc = soc;
tlm.speedKmh = vehicle.getSpeedKmh();
@@ -124,7 +139,7 @@ final class TelemetryUploader {
tlm.tirePressureFrKpa = tires.getFrontRightKpa();
tlm.tirePressureRlKpa = tires.getRearLeftKpa();
tlm.tirePressureRrKpa = tires.getRearRightKpa();
Location loc = freshLocation(settings);
Location loc = idle ? parkedLocation : freshLocation(settings);
if (loc != null) {
tlm.lat = loc.getLatitude();
tlm.lon = loc.getLongitude();
@@ -146,14 +161,16 @@ final class TelemetryUploader {
List<String> batch = queue.peekBatch();
String summary = TelemetryPayload.summarize(batch.get(batch.size() - 1));
try {
int status = post(config, batch);
boolean ok = status >= 200 && status < 300;
ServerHttp.Response response = post(config, batch);
int status = response.status;
boolean ok = response.ok();
AbrpUploadService.log().record(new UploadLog.Entry(System.currentTimeMillis(), status, ok,
ok ? "Telemetry: " + batch.size() + " sample(s) sent"
: "Telemetry rejected (" + batch.size() + " queued)", summary));
ok ? "Telemetry: " + batch.size() + " sample(s) sent" + droppedNote(response)
: "Telemetry rejected (" + batch.size() + " queued): " + response.excerpt(), summary));
if (!ok) {
// A 400 will never succeed on retry: drop the batch rather than block the
// queue behind it forever. Anything else (401, 5xx) is kept for later.
// The server drops bad values itself and answers 200, so a 400 is a request
// it cannot read at all and will never take: drop the batch rather than
// block the queue behind it forever. Anything else (401, 403, 5xx) is kept.
if (status == 400) queue.remove(batch.size());
return;
}
@@ -167,28 +184,22 @@ final class TelemetryUploader {
}
}
private int post(ServerConfig config, List<String> batch) throws Exception {
private ServerHttp.Response post(ServerConfig config, List<String> batch) throws Exception {
JSONArray samples = new JSONArray();
for (String sample : batch) samples.put(new JSONObject(sample));
byte[] body = new JSONObject()
JSONObject body = new JSONObject()
.put("installationId", config.installationId)
.put("samples", samples)
.toString().getBytes(StandardCharsets.UTF_8);
HttpURLConnection connection = null;
.put("samples", samples);
return ServerHttp.request("POST", config.url + "/v1/telemetry", config.token, body);
}
/** ", 2 value(s) dropped by the server" when it left anything out, for the upload log. */
static String droppedNote(ServerHttp.Response response) {
try {
connection = (HttpURLConnection) new URL(config.url + "/v1/telemetry").openConnection();
connection.setRequestMethod("POST");
connection.setConnectTimeout(TIMEOUT_MS);
connection.setReadTimeout(TIMEOUT_MS);
connection.setDoOutput(true);
connection.setFixedLengthStreamingMode(body.length);
connection.setRequestProperty("Content-Type", "application/json");
// The token goes in a header, never the URL, and is never logged.
connection.setRequestProperty("Authorization", "Bearer " + config.token);
try (OutputStream out = connection.getOutputStream()) { out.write(body); }
return connection.getResponseCode();
} finally {
if (connection != null) connection.disconnect();
int dropped = new JSONObject(response.body).optInt("droppedCount", 0);
return dropped > 0 ? ", " + dropped + " value(s) dropped by the server" : "";
} catch (Exception e) {
return ""; // an older server answers 204 with no body
}
}
@@ -207,6 +218,7 @@ final class TelemetryUploader {
/** Subscribes to GPS once, and again after a failure or a provider change. */
private void ensureLocationUpdates(UploadSettings settings) {
locationWanted = true;
if (locationActive) return;
long now = SystemClock.elapsedRealtime();
if (lastLocationRequestMs != 0L && now - lastLocationRequestMs < LOCATION_RETRY_MS) return;
@@ -215,6 +227,7 @@ final class TelemetryUploader {
}
private void requestLocationUpdates(UploadSettings settings) {
if (!locationWanted) return; // stopped while this request was queued
try {
if (locationManager == null) {
locationManager = (LocationManager) context.getSystemService(Context.LOCATION_SERVICE);
@@ -237,11 +250,14 @@ final class TelemetryUploader {
}
}
/** Ends the GPS subscription; the next tick that needs a position starts it again. */
void stop() {
locationWanted = false;
try {
if (locationManager != null) locationManager.removeUpdates(locationListener);
} catch (Exception ignored) { }
locationActive = false;
lastLocationRequestMs = 0L;
}
private Location freshLocation(UploadSettings settings) {
@@ -154,10 +154,10 @@ final class WifiAutoConnect {
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, "Bekleniyor…");
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, "Sistem izin vermedi");
static final Result REFUSED = new Result(false, null, "The system refused");
}
}
+2
View File
@@ -92,6 +92,8 @@
<!-- Config file import -->
<string name="import_ok">Config imported and saved — press Test to verify</string>
<string name="import_ok_deleted">Config imported and saved; the file was deleted</string>
<string name="import_ok_not_deleted">Config imported and saved, but the file could not be deleted. It holds your token: delete %1$s</string>
<string name="import_err_read">Could not read that file</string>
<string name="import_err_empty">No settings found in that file</string>
<string name="import_err_not_found">No config file found. Copy it to %1$s</string>