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 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 14:23:04 +02:00
co-authored by Claude Opus 5.5
parent c253876ed2
commit 6145736c97
27 changed files with 1590 additions and 287 deletions
+32 -24
View File
@@ -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-<version>.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.
+4 -10
View File
@@ -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"
}
+7 -1
View File
@@ -8,6 +8,11 @@
<!-- Required from API 33 to post the foreground-service notification, which is the
only status UI the service has. Without it the notification is silently dropped. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- GPS for the position in live telemetry. -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<!-- Wi-Fi auto-join. CHANGE_WIFI_STATE covers enabling the radio and asking the
supplicant to join a network the unit already has saved; ACCESS_WIFI_STATE covers
@@ -52,7 +57,8 @@
<service
android:name=".AbrpUploadService"
android:exported="false" />
android:exported="false"
android:foregroundServiceType="dataSync|location" />
<!-- The head unit resumes far more often than it cold-boots, and announces the
resume with QUICKBOOT_POWERON. Listening only for BOOT_COMPLETED is why
@@ -24,11 +24,13 @@ import java.util.concurrent.TimeUnit;
/**
* Foreground service that polls vehicle telemetry every 15 s, integrates consumption locally,
* and syncs it to the cloud (MongoDB) in 30-minute delta chunks. Never sends anything to ABRP.
* 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.
*
* 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 for the cloud sync, and a
* missing one is handled by caching chunks locally until the connection returns.
* credentials or a toggle. The only thing it needs is a network, and a missing one is
* handled by queueing locally until the connection returns.
*/
public class AbrpUploadService extends Service {
@@ -64,6 +66,7 @@ public class AbrpUploadService extends Service {
private ConsumptionTracker consumptionTracker;
private ConsumptionCloudClient consumptionCloudClient;
private ChargeSessionTracker chargeSessionTracker;
private TelemetryUploader telemetryUploader;
private WifiAutoConnect wifi;
private ScheduledExecutorService scheduler;
private SharedPreferences prefs;
@@ -94,6 +97,7 @@ public class AbrpUploadService extends Service {
consumptionCloudClient = new ConsumptionCloudClient(getApplicationContext());
chargeSessionTracker = ChargeSessionTracker.get(getApplicationContext());
wifi = new WifiAutoConnect(getApplicationContext());
telemetryUploader = new TelemetryUploader(getApplicationContext());
scheduler.scheduleWithFixedDelay(
this::safeUploadCycle,
@@ -111,15 +115,16 @@ public class AbrpUploadService extends Service {
}
/**
* Enters the foreground. No location type — this service no longer reads GPS. Foreground
* service types do not exist before API 29, so on this API-28 head unit a plain
* startForeground is the only correct call.
* Enters the foreground. Foreground service types do not exist before API 29, so on this
* API-28 head unit a plain startForeground is the only correct call; from API 29 the
* location type is what lets the GPS subscription run in the background.
*/
private void startInForeground() {
Notification notification = buildNotification(lastStatus);
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
ServiceCompat.startForeground(
this, NOTIF_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC);
this, NOTIF_ID, notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC | ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION);
} else {
startForeground(NOTIF_ID, notification);
}
@@ -133,6 +138,7 @@ public class AbrpUploadService extends Service {
running = false;
prefs.edit().putBoolean("service_running", false).apply();
if (scheduler != null) scheduler.shutdownNow();
if (telemetryUploader != null) telemetryUploader.stop();
Log.i(TAG, "Service stopped");
}
@@ -159,7 +165,16 @@ public class AbrpUploadService extends Service {
wifi.ensureConnected(false);
}
updateNotification("Consumption tracking active");
UploadSettings settings = UploadSettings.from(prefs);
if (!ServerConfig.get(this).isConfigured()) {
updateNotification(getString(R.string.server_not_configured));
} else if (!settings.abrpUploadEnabled) {
updateNotification(getString(R.string.notif_tracking_locally));
} else {
telemetryUploader.tick(vehicle, chargeSessionTracker.snapshot(), settings);
int queued = telemetryUploader.queued();
updateNotification(queued > 0 ? "Sending telemetry — " + queued + " queued" : "Sending telemetry");
}
}
// ---------- Notification ----------
@@ -0,0 +1,127 @@
package com.evsuite.abrp;
/**
* Parses a configuration text file so the app can be set up without typing on the car's
* on-screen keyboard — the ingest token in particular is long and error-prone to enter by
* hand. The file is a plain {@code key = value} list, one entry per line:
*
* <pre>
* # Nils' MG4 config
* server_url = https://car.stinnesbeck.com
* token = 0123abcd... (the server's INGEST_TOKEN)
* installation_id = 6f1a2b3c-4d5e-4f60-8a9b-0c1d2e3f4a5b
* interval_sec = 60
* boost_low_soc = true
* low_soc_percent = 20
* </pre>
*
* Usually only server_url and token are needed. installation_id is for a reinstall: it puts
* the car back under the id its history is stored with. The cadence keys are optional. Blank lines and
* lines starting with {@code #} are ignored, keys are case-insensitive, and surrounding
* whitespace is trimmed. Every field is nullable: the caller applies only what the file set,
* leaving anything absent untouched.
*/
final class ConfigImport {
final String serverUrl;
final String token;
final String installationId;
final Integer intervalSec;
final Boolean boostLowSoc;
final Integer lowSocPercent;
private ConfigImport(String serverUrl, String token, String installationId, Integer intervalSec,
Boolean boostLowSoc, Integer lowSocPercent) {
this.serverUrl = serverUrl;
this.token = token;
this.installationId = installationId;
this.intervalSec = intervalSec;
this.boostLowSoc = boostLowSoc;
this.lowSocPercent = lowSocPercent;
}
/** 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;
}
static ConfigImport parse(String text) {
String serverUrl = null, token = null, installationId = null;
Integer intervalSec = null, lowSocPercent = null;
Boolean boostLowSoc = null;
for (String line : text.split("\n", -1)) {
line = line.trim();
if (line.isEmpty() || line.charAt(0) == '#') continue;
int sep = line.indexOf('=');
if (sep <= 0) continue;
String key = line.substring(0, sep).trim().toLowerCase(java.util.Locale.US);
String value = line.substring(sep + 1).trim();
if (value.isEmpty()) continue;
switch (key) {
case "server_url":
case "server":
case "url":
serverUrl = value;
break;
case "token":
case "ingest_token":
token = value;
break;
case "installation_id":
installationId = value;
break;
case "interval_sec":
case "upload_interval_sec":
intervalSec = nearestInterval(value);
break;
case "boost_low_soc":
boostLowSoc = parseBool(value);
break;
case "low_soc_percent":
lowSocPercent = clampPercent(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);
}
/** Snaps to the nearest offered choice: the UI spinner has no free-form interval. */
private static Integer nearestInterval(String value) {
int requested;
try {
requested = Integer.parseInt(value);
} catch (NumberFormatException e) {
return null;
}
int best = UploadSettings.INTERVAL_CHOICES_SEC[0];
for (int choice : UploadSettings.INTERVAL_CHOICES_SEC) {
if (Math.abs(choice - requested) < Math.abs(best - requested)) best = choice;
}
return best;
}
/** Same 1–99 clamp the manual field enforces, so an import cannot set an unusable value. */
private static Integer clampPercent(String value) {
try {
return Math.max(1, Math.min(99, Integer.parseInt(value)));
} 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;
if (value.equals("false") || value.equals("off") || value.equals("0")) return Boolean.FALSE;
return null;
}
}
@@ -20,12 +20,13 @@ import java.util.Collections;
import java.util.List;
/**
* Best-effort MongoDB sync through the web API. Never blocks local persistence.
* Best-effort sync to the car server. Never blocks local persistence.
*
* The installationId is a fixed value baked into BuildConfig — never a random UUID — so an
* APK wipe cannot orphan the cloud history under a new identity. The consumption UI reads
* back its own uploaded totals via {@link #fetchHistoryIfDue}, which means an APK reinstall
* picks up right where the old one left off instead of showing zeroes.
* 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
* identity. The consumption UI reads back its own uploaded totals via
* {@link #fetchHistoryIfDue}, so a reinstall picks up where the old one left off.
*/
final class ConsumptionCloudClient {
private static final String TAG = "NilsMG4.Mongo";
@@ -37,9 +38,7 @@ final class ConsumptionCloudClient {
private static final String CHUNK_CACHE = "chunk_cache"; // JSON array of pending chunks
private static final int MAX_CHARGES_PER_SYNC = 10;
private final String endpoint;
private final String token;
private final String installationId;
private final Context context;
private final SharedPreferences prefs;
private long lastConsumptionAttemptMs;
private long lastChargeAttemptMs;
@@ -65,14 +64,12 @@ final class ConsumptionCloudClient {
}
ConsumptionCloudClient(Context context) {
endpoint = BuildConfig.CONSUMPTION_API_URL.trim();
token = BuildConfig.CONSUMPTION_API_TOKEN.trim();
this.context = context.getApplicationContext();
prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
installationId = BuildConfig.CONSUMPTION_INSTALLATION_ID.trim();
if (endpoint.isEmpty() || token.isEmpty()) {
Log.w(TAG, "Consumption API not configured: endpoint=" + (endpoint.isEmpty() ? "empty" : "ok")
+ ", token=" + (token.isEmpty() ? "empty" : "ok"));
}
}
private ServerConfig config() {
return ServerConfig.get(context);
}
/**
@@ -87,7 +84,7 @@ final class ConsumptionCloudClient {
*/
void syncIfDue(ConsumptionTracker tracker, ChargeSessionTracker charges, long nowMs) {
long elapsed = android.os.SystemClock.elapsedRealtime();
if (endpoint.isEmpty() || token.isEmpty()) {
if (!config().isConfigured()) {
Log.i(TAG, "Sync skipped: API not configured");
return;
}
@@ -200,7 +197,7 @@ final class ConsumptionCloudClient {
private JSONObject consumptionBody(ConsumptionTracker.CloudSnapshot snapshot, long nowMs)
throws Exception {
JSONObject body = new JSONObject()
.put("installationId", installationId)
.put("installationId", config().installationId)
.put("recordedAt", nowMs)
.put("date", snapshot.date)
.put("day", totals(snapshot.day));
@@ -230,7 +227,7 @@ final class ConsumptionCloudClient {
Log.i(TAG, "Found " + pending.size() + " pending charge sessions");
int sent = 0;
for (JSONObject session : pending) {
JSONObject body = new JSONObject(session.toString()).put("installationId", installationId);
JSONObject body = new JSONObject(session.toString()).put("installationId", config().installationId);
String id = session.getString("sessionId");
Log.i(TAG, "Uploading charge session: " + id);
if (send("/v1/charging-sessions/" + id, "PUT", body)) {
@@ -250,7 +247,7 @@ final class ConsumptionCloudClient {
HttpURLConnection connection = null;
try {
byte[] bytes = body.toString().getBytes(StandardCharsets.UTF_8);
String url = trimSlash(endpoint) + path;
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();
@@ -260,7 +257,7 @@ final class ConsumptionCloudClient {
connection.setDoOutput(true);
connection.setFixedLengthStreamingMode(bytes.length);
connection.setRequestProperty("Content-Type", "application/json");
connection.setRequestProperty("Authorization", "Bearer " + token);
connection.setRequestProperty("Authorization", "Bearer " + config().token);
try (OutputStream out = connection.getOutputStream()) { out.write(bytes); }
int status = connection.getResponseCode();
Log.i(TAG, "Response: HTTP " + status);
@@ -295,7 +292,7 @@ final class ConsumptionCloudClient {
* Returns the current cache immediately; the fresh copy lands on the next poll.
*/
synchronized List<DayRecord> fetchHistoryIfDue() {
if (endpoint.isEmpty() || token.isEmpty()) return cachedHistory;
if (!config().isConfigured()) return cachedHistory;
long elapsed = android.os.SystemClock.elapsedRealtime();
if (lastHistoryFetchMs != 0L && elapsed - lastHistoryFetchMs < HISTORY_FETCH_INTERVAL_MS) {
return cachedHistory;
@@ -329,7 +326,7 @@ final class ConsumptionCloudClient {
* status for the log.
*/
String flushNow(ConsumptionTracker tracker, long nowMs) {
if (endpoint.isEmpty() || token.isEmpty()) return "API not configured";
if (!config().isConfigured()) return "API not configured";
int cached = drainChunkCache();
ConsumptionTracker.CloudSnapshot chunk = tracker.takeChunkDelta();
int total = cached;
@@ -357,12 +354,12 @@ final class ConsumptionCloudClient {
private List<DayRecord> fetchHistory() throws Exception {
HttpURLConnection connection = null;
try {
String url = trimSlash(endpoint) + "/v1/consumption/history/" + installationId;
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 " + token);
connection.setRequestProperty("Authorization", "Bearer " + config().token);
int status = connection.getResponseCode();
Log.i(TAG, "History GET response: HTTP " + status);
if (status < 200 || status >= 300) {
@@ -16,6 +16,8 @@ import android.widget.TextView;
import java.time.LocalDate;
import java.util.List;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.app.AppCompatDelegate;
@@ -39,16 +41,18 @@ 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.tabWifi,
R.id.tabLog };
{ R.id.tabVehicle, R.id.tabConsumption, R.id.tabCharging, R.id.tabService, R.id.tabServer,
R.id.tabWifi, 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_wifi_title, R.string.design_log_title
R.string.design_service_title, R.string.design_server_title, R.string.design_wifi_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_wifi_subtitle, R.string.design_log_subtitle
R.string.design_service_subtitle, R.string.design_server_subtitle, R.string.design_wifi_subtitle,
R.string.design_log_subtitle
};
// Status colours come from the palette, not from the Material swatches: #4CAF50 and
@@ -61,13 +65,14 @@ public class MainActivity extends AppCompatActivity {
private TextView statusText;
private TextView callLogText;
private View servicePane;
private View serverPane;
private View logPane;
private View vehiclePane;
private View consumptionPane;
private View chargingPane;
private View wifiPane;
/** The six pages in navigation order. Parallel to {@link #TAB_IDS}. */
/** 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. */
@@ -102,6 +107,28 @@ public class MainActivity extends AppCompatActivity {
private ChargeSessionTracker chargeSessionTracker;
private ChargingGraphView chargeGraph;
/** Server page. */
private TextInputLayout serverUrlLayout, serverTokenLayout, installationIdLayout;
private TextInputEditText serverUrlInput, serverTokenInput, installationIdInput;
private View connectionStatusRow, connectionIndicator;
private TextView connectionStatusText;
/** Upload page: live telemetry switch and cadence. */
private SwitchMaterial liveTelemetrySwitch, boostSwitch;
private android.widget.Spinner intervalSpinner;
private TextInputLayout lowSocLayout;
private TextInputEditText lowSocInput;
/**
* Picks a config text file. Only reachable on devices that ship a document picker — the
* MG4 head unit does not, which is why {@link #startConfigImport()} scans the app's own
* folders first and only falls back to this.
*/
private final ActivityResultLauncher<String[]> configPicker =
registerForActivityResult(new ActivityResultContracts.OpenDocument(), uri -> {
if (uri != null) importConfig(uri);
});
/**
* 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
@@ -201,17 +228,22 @@ public class MainActivity extends AppCompatActivity {
// in activity_main.xml — a recycled page would invalidate these references as
// soon as the user swiped away from it.
servicePane = inflatePane(R.layout.pane_service);
serverPane = inflatePane(R.layout.pane_server);
vehiclePane = inflatePane(R.layout.pane_vehicle);
consumptionPane = inflatePane(R.layout.pane_consumption);
chargingPane = inflatePane(R.layout.pane_charging);
wifiPane = inflatePane(R.layout.pane_wifi);
logPane = inflatePane(R.layout.pane_log);
panes = new View[] { vehiclePane, consumptionPane, chargingPane, servicePane, wifiPane, logPane };
panes = new View[] { vehiclePane, consumptionPane, chargingPane, servicePane, serverPane,
wifiPane, logPane };
bindVehiclePane();
bindConsumptionPane();
bindChargingPane();
bindWifiPane();
bindServerPane();
bindServicePane();
requestLocationPermissionIfNeeded();
statusText = servicePane.findViewById(R.id.status_text);
callLogText = logPane.findViewById(R.id.call_log_text);
@@ -225,7 +257,7 @@ public class MainActivity extends AppCompatActivity {
// a no-op and does not even contain the updater.
UpdateHook.checkInBackground(this);
// The service is always-on: it tracks consumption and syncs to MongoDB cloud.
// The service is always-on: it tracks consumption and sends everything to the server.
// startForegroundService is idempotent — if the service is already up this
// is a no-op aside from delivering a new intent.
startForegroundService(new Intent(this, AbrpUploadService.class));
@@ -337,6 +369,327 @@ public class MainActivity extends AppCompatActivity {
});
}
// ---------- Server page ----------
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);
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))) {
setConnectionStatus(COLOR_OK, getString(R.string.server_saved));
}
});
serverPane.findViewById(R.id.server_test_button).setOnClickListener(v -> testConnection());
serverPane.findViewById(R.id.import_button).setOnClickListener(v -> startConfigImport());
}
private void showServerConfig() {
ServerConfig config = ServerConfig.get(this);
serverUrlInput.setText(config.url);
serverTokenInput.setText(config.token);
installationIdInput.setText(config.installationId);
}
/** Validates, marks the bad fields, and saves only when all three are good. */
private boolean saveServerConfig(String rawUrl, String rawToken, String rawId) {
String url = ServerConfig.normalizeUrl(rawUrl);
String token = rawToken == null ? "" : rawToken.trim();
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;
ServerConfig.save(this, url, token, id);
if (consumptionCloudClient != null) consumptionCloudClient.invalidateHistory();
showServerConfig();
refreshStatus();
return true;
}
/**
* Checks reachability, then the token, without writing anything: the health endpoint
* first, then the car's own history read, which needs the ingest token. Uses what is in
* the fields, saved or not, so a typo can be caught before it is stored.
*/
private void testConnection() {
String url = ServerConfig.normalizeUrl(textOf(serverUrlInput));
String token = textOf(serverTokenInput);
String id = textOf(installationIdInput).toLowerCase(java.util.Locale.US);
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;
int color;
try {
int health = httpGet(url + "/v1/health", null);
if (health != 200) {
message = getString(R.string.conn_err_http, health);
color = COLOR_ERROR;
} else {
int auth = httpGet(url + "/v1/consumption/history/" + id, token);
if (auth == 200) { message = getString(R.string.conn_ok); color = COLOR_OK; }
else if (auth == 401) { message = getString(R.string.conn_err_auth); color = COLOR_ERROR; }
else { message = getString(R.string.conn_err_http, auth); color = COLOR_ERROR; }
}
} catch (java.net.SocketTimeoutException e) {
message = getString(R.string.conn_err_timeout);
color = COLOR_ERROR;
} catch (Exception e) {
message = getString(R.string.conn_err_no_internet);
color = COLOR_ERROR;
}
final String m = message;
final int c = color;
runOnUiThread(() -> setConnectionStatus(c, m));
}, "server-test").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 {
connection.setConnectTimeout(8_000);
connection.setReadTimeout(8_000);
if (token != null) connection.setRequestProperty("Authorization", "Bearer " + token);
return connection.getResponseCode();
} finally {
connection.disconnect();
}
}
private void setConnectionStatus(int color, String message) {
connectionStatusRow.setVisibility(View.VISIBLE);
connectionIndicator.getBackground().mutate().setTint(color);
connectionStatusText.setText(message);
connectionStatusText.setTextColor(color);
}
// ---------- Config import ----------
/**
* The MG4 head unit ships no document picker — SAF answers "FileManagement is no supported
* on this device" — so the file is found by scanning instead. getExternalFilesDirs() returns
* this app's own folder on internal storage and on every mounted volume including a USB
* stick, and those need no storage permission at any API level. The picker stays as the
* fallback for phones and tablets, where the user may keep the file anywhere.
*/
private void startConfigImport() {
java.io.File found = findConfigFile();
if (found != null) {
importConfig(found);
return;
}
try {
// Any MIME: the MG4 Files app tags .txt inconsistently, so filtering by type hides
// the very file the user is trying to pick. They select it by name instead.
configPicker.launch(new String[]{"*/*"});
} catch (android.content.ActivityNotFoundException e) {
setConnectionStatus(COLOR_ERROR, getString(R.string.import_err_not_found, configDirHint()));
}
}
/**
* First readable file in an app-specific folder that parses as a config. Names are not
* filtered: the user copies one file to the folder, whatever they called it.
*/
private java.io.File findConfigFile() {
for (java.io.File dir : getExternalFilesDirs(null)) {
if (dir == null) continue;
java.io.File[] files = dir.listFiles();
if (files == null) continue;
for (java.io.File f : files) {
if (f.isFile() && f.canRead() && f.length() > 0 && f.length() <= 64 * 1024
&& !ConfigImport.parse(readText(f)).isEmpty()) {
return f;
}
}
}
return null;
}
/** File text, or "" when unreadable — callers treat that as "not a config". */
private String readText(java.io.File file) {
try (java.io.InputStream in = new java.io.FileInputStream(file)) {
return readCapped(in);
} catch (Exception e) {
return "";
}
}
/** Capped so a wrong file picked by mistake is not slurped whole into memory. */
private String readCapped(java.io.InputStream in) throws java.io.IOException {
java.io.ByteArrayOutputStream buf = new java.io.ByteArrayOutputStream();
byte[] chunk = new byte[4096];
int n;
while ((n = in.read(chunk)) != -1 && buf.size() < 64 * 1024) buf.write(chunk, 0, n);
return buf.toString(java.nio.charset.StandardCharsets.UTF_8.name());
}
/** Where the user should drop the file. */
private String configDirHint() {
java.io.File[] dirs = getExternalFilesDirs(null);
return dirs.length > 0 && dirs[0] != null ? dirs[0].getAbsolutePath() : "";
}
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);
}
private void importConfig(Uri uri) {
String text;
try (java.io.InputStream in = getContentResolver().openInputStream(uri)) {
if (in == null) throw new java.io.IOException("no stream");
text = readCapped(in);
} catch (Exception e) {
setConnectionStatus(COLOR_ERROR, getString(R.string.import_err_read));
return;
}
applyConfig(text);
}
/**
* Applies whatever the file sets; absent keys keep their current value, so a file with
* 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) {
ConfigImport config = ConfigImport.parse(text);
if (config.isEmpty()) {
setConnectionStatus(COLOR_ERROR, getString(R.string.import_err_empty));
return;
}
ServerConfig current = ServerConfig.get(this);
if (config.serverUrl != null || config.token != null || config.installationId != null) {
boolean saved = saveServerConfig(
config.serverUrl != null ? config.serverUrl : current.url,
config.token != null ? config.token : current.token,
config.installationId != null ? config.installationId : current.installationId);
if (!saved) {
// 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;
}
}
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);
if (config.lowSocPercent != null) edit.putInt(UploadSettings.KEY_LOW_SOC_PERCENT, config.lowSocPercent);
edit.apply();
bindCadenceControls();
setConnectionStatus(COLOR_OK, getString(R.string.import_ok));
refreshStatus();
}
// ---------- Upload page ----------
private void bindServicePane() {
liveTelemetrySwitch = servicePane.findViewById(R.id.abrp_upload_switch);
intervalSpinner = servicePane.findViewById(R.id.interval_spinner);
boostSwitch = servicePane.findViewById(R.id.boost_switch);
lowSocInput = servicePane.findViewById(R.id.low_soc_input);
lowSocLayout = servicePane.findViewById(R.id.low_soc_layout);
liveTelemetrySwitch.setChecked(UploadSettings.from(prefs).abrpUploadEnabled);
liveTelemetrySwitch.setOnCheckedChangeListener((btn, checked) -> {
prefs.edit().putBoolean(UploadSettings.KEY_ABRP_UPLOAD_ENABLED, checked).apply();
refreshStatus();
});
bindCadenceControls();
}
/** The service reads these settings on every tick, so a change applies without a restart. */
private void bindCadenceControls() {
UploadSettings current = UploadSettings.from(prefs);
String[] labels = new String[UploadSettings.INTERVAL_CHOICES_SEC.length];
int selected = 0;
for (int i = 0; i < UploadSettings.INTERVAL_CHOICES_SEC.length; i++) {
int sec = UploadSettings.INTERVAL_CHOICES_SEC[i];
labels[i] = sec >= 60
? getResources().getQuantityString(R.plurals.interval_minutes, sec / 60, sec / 60)
: getResources().getQuantityString(R.plurals.interval_seconds, sec, sec);
if (sec == current.intervalSec) selected = i;
}
android.widget.ArrayAdapter<String> adapter = new android.widget.ArrayAdapter<>(
this, R.layout.spinner_value, labels);
adapter.setDropDownViewResource(R.layout.spinner_dropdown);
intervalSpinner.setAdapter(adapter);
intervalSpinner.setSelection(selected);
intervalSpinner.setOnItemSelectedListener(new android.widget.AdapterView.OnItemSelectedListener() {
@Override
public void onItemSelected(android.widget.AdapterView<?> parent, View view, int position, long id) {
prefs.edit().putInt(UploadSettings.KEY_INTERVAL_SEC,
UploadSettings.INTERVAL_CHOICES_SEC[position]).apply();
}
@Override public void onNothingSelected(android.widget.AdapterView<?> parent) { }
});
boostSwitch.setOnCheckedChangeListener(null);
boostSwitch.setChecked(current.boostLowSoc);
lowSocLayout.setEnabled(current.boostLowSoc);
lowSocInput.setEnabled(current.boostLowSoc);
boostSwitch.setOnCheckedChangeListener((button, checked) -> {
prefs.edit().putBoolean(UploadSettings.KEY_BOOST_LOW_SOC, checked).apply();
lowSocLayout.setEnabled(checked);
lowSocInput.setEnabled(checked);
});
lowSocInput.setText(String.valueOf(current.lowSocPercent));
lowSocInput.setOnFocusChangeListener((view, hasFocus) -> {
if (!hasFocus) saveLowSocThreshold();
});
}
/** Clamped to 1-99: 0 would never trigger and 100 would boost permanently. */
private void saveLowSocThreshold() {
CharSequence raw = lowSocInput.getText();
int value;
try {
value = Integer.parseInt(raw == null ? "" : raw.toString().trim());
} catch (NumberFormatException e) {
value = UploadSettings.DEFAULT_LOW_SOC_PERCENT;
}
value = Math.max(1, Math.min(99, value));
lowSocInput.setText(String.valueOf(value));
prefs.edit().putInt(UploadSettings.KEY_LOW_SOC_PERCENT, value).apply();
}
/**
* GPS for the telemetry position. On the car the app runs as the system user and holds
* it already; this only asks where it does not, such as a phone running the stable build.
*/
private void requestLocationPermissionIfNeeded() {
if (checkSelfPermission(android.Manifest.permission.ACCESS_FINE_LOCATION)
!= PackageManager.PERMISSION_GRANTED) {
requestPermissions(new String[]{android.Manifest.permission.ACCESS_FINE_LOCATION,
android.Manifest.permission.ACCESS_COARSE_LOCATION}, 1);
}
}
// ---------- Wi-Fi page ----------
private void bindWifiPane() {
@@ -764,8 +1117,16 @@ public class MainActivity extends AppCompatActivity {
break;
case RUNNING:
case STARTING:
statusText.setText("Consumption tracking active");
statusText.setTextColor(COLOR_OK);
if (!ServerConfig.get(this).isConfigured()) {
statusText.setText(R.string.server_not_configured);
statusText.setTextColor(COLOR_ERROR);
} else if (!UploadSettings.from(prefs).abrpUploadEnabled) {
statusText.setText(R.string.state_tracking_locally);
statusText.setTextColor(COLOR_PENDING);
} else {
statusText.setText(R.string.state_running);
statusText.setTextColor(COLOR_OK);
}
break;
case STOPPED:
default:
@@ -0,0 +1,48 @@
package com.evsuite.abrp;
import android.content.Context;
import android.content.SharedPreferences;
import android.util.Log;
import androidx.security.crypto.EncryptedSharedPreferences;
import androidx.security.crypto.MasterKey;
/**
* 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.
*/
final class SecurePrefs {
private static final String TAG = "SecurePrefs";
private static final String FILE_NAME = "server_secure_prefs";
private static final String PLAIN_NAME = "server_prefs";
private static SharedPreferences instance;
private SecurePrefs() { }
/**
* Encrypted preferences, or a plaintext file if the crypto stack is unavailable. Falling
* back keeps the app usable on a head unit with a broken keystore instead of crashing it.
*/
static synchronized SharedPreferences get(Context context) {
if (instance != null) return instance;
Context app = context.getApplicationContext();
try {
MasterKey masterKey = new MasterKey.Builder(app)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build();
instance = EncryptedSharedPreferences.create(
app,
FILE_NAME,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM);
} catch (Exception e) {
Log.e(TAG, "Encrypted preferences unavailable, falling back to plaintext", e);
instance = app.getSharedPreferences(PLAIN_NAME, Context.MODE_PRIVATE);
}
return instance;
}
}
@@ -0,0 +1,100 @@
package com.evsuite.abrp;
import android.content.Context;
import android.content.SharedPreferences;
import java.util.Locale;
import java.util.UUID;
import java.util.regex.Pattern;
/**
* Where the car's data goes: the server URL, its ingest token, and the installation id the
* data is stored under. Entered in the Server tab or imported from a config file, kept in
* {@link SecurePrefs}.
*
* Read through {@link #get}, which caches one immutable snapshot for the process and is
* replaced on {@link #save}: the service and the activity both see a change at once, without
* a restart.
*/
final class ServerConfig {
static final String KEY_URL = "server_url";
static final String KEY_TOKEN = "ingest_token";
static final String KEY_INSTALLATION_ID = "installation_id";
/** Same rule as the server: a version 1–5 UUID with the RFC 4122 variant. */
private static final Pattern UUID_PATTERN = Pattern.compile(
"^[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);
/** The server refuses shorter ingest tokens outright. */
static final int MIN_TOKEN_LENGTH = 24;
final String url;
final String token;
final String installationId;
private static volatile ServerConfig current;
ServerConfig(String url, String token, String installationId) {
this.url = url;
this.token = token;
this.installationId = installationId;
}
boolean isConfigured() {
return !url.isEmpty() && !token.isEmpty();
}
static ServerConfig get(Context context) {
ServerConfig config = current;
if (config != null) return config;
synchronized (ServerConfig.class) {
if (current != null) return current;
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();
prefs.edit().putString(KEY_INSTALLATION_ID, id).apply();
}
current = new ServerConfig(prefs.getString(KEY_URL, ""), prefs.getString(KEY_TOKEN, ""), id);
return current;
}
}
/** Stores already-validated values; see {@link #validate}. */
static synchronized void save(Context context, String url, String token, String installationId) {
SecurePrefs.get(context).edit()
.putString(KEY_URL, url)
.putString(KEY_TOKEN, token)
.putString(KEY_INSTALLATION_ID, installationId)
.apply();
current = new ServerConfig(url, token, installationId);
}
// ---------- Validation (pure, unit-tested) ----------
/**
* The URL as it will be used: trimmed, without a trailing slash. Null when it is not an
* https URL with a host — the token travels in a header, so plain http is refused.
*/
static String normalizeUrl(String raw) {
if (raw == null) return null;
String url = raw.trim();
while (url.endsWith("/")) url = url.substring(0, url.length() - 1);
if (!url.toLowerCase(Locale.US).startsWith("https://")) return null;
String rest = url.substring("https://".length());
if (rest.isEmpty() || rest.startsWith("/") || rest.contains(" ") || rest.contains("?")
|| rest.contains("#") || rest.contains("@")) return null;
return url;
}
static boolean isValidToken(String token) {
return token != null && token.trim().length() >= MIN_TOKEN_LENGTH && !token.trim().contains(" ");
}
static boolean isValidInstallationId(String id) {
return id != null && UUID_PATTERN.matcher(id.trim()).matches();
}
}
@@ -0,0 +1,213 @@
package com.evsuite.abrp;
import org.json.JSONException;
import org.json.JSONObject;
/**
* Builds one live telemetry sample in ABRP's tlm format. The car server stores it and forwards
* the newest one to ABRP, so the field names and units are exactly ABRP's.
*
* Kept free of Android types so it can be unit-tested on the JVM: the caller
* pulls the values out of the car adapter and the Location, this class only
* decides what ends up in the payload.
*
* Every vehicle field is nullable and a null field is OMITTED. A property that
* cannot be read is not a zero: sending soc:0 because a getter threw tells ABRP
* the battery is empty and wrecks the user's route plan.
*
* Fields are set by name rather than passed positionally. The payload now carries
* 24 optional values and a positional build(...) call had become a wall of nulls
* that no reader could check against the ABRP field list.
*/
final class TelemetryPayload {
/** Only value that is always present. Epoch SECONDS, not milliseconds. */
private final long utc;
// --- High priority (ABRP: "many features will only be usable with enough data") ---
Integer soc; // %
Float speedKmh; // km/h
Float powerKw; // kW, ABRP sign: positive leaving the battery
Boolean charging;
Boolean dcfc;
Boolean parked;
Double lat; // °
Double lon; // °
// --- Lower priority ---
Integer rangeKm; // km, vehicle's own estimate
Float cabinTemp; // °C
Float battTempC; // °C, traction battery
Float capacityKwh; // kWh, usable pack capacity
Float soeKwh; // kWh, energy currently in the pack
Double kwhCharged; // kWh, taken by the pack during the current charge session
Float extTempC; // °C, outside
Float voltageV; // V, traction battery
Float currentA; // A, traction battery
Float odometerKm; // km
Float hvacSetpointC; // °C, climate setpoint
Float tirePressureFlKpa;
Float tirePressureFrKpa;
Float tirePressureRlKpa;
Float tirePressureRrKpa;
Double elevation; // m
Float heading; // °
TelemetryPayload(long utc) {
this.utc = utc;
}
/** Build the {@code tlm} JSON object. Only utc is always present. */
String build() {
JSONObject tlm = new JSONObject();
try {
tlm.put("utc", utc);
putIfPresent(tlm, "soc", soc);
putIfPresent(tlm, "speed", speedKmh == null ? null : Math.round(speedKmh));
putIfPresent(tlm, "est_battery_range", rangeKm);
if (powerKw != null) tlm.put("power", round2(powerKw));
putIfPresent(tlm, "is_charging", boolToInt(charging));
putIfPresent(tlm, "is_dcfc", boolToInt(dcfc));
putIfPresent(tlm, "is_parked", boolToInt(parked));
// Only send cabin_temp if we got a plausible reading — 0.0 likely
// means the property isn't supported on this VHAL.
if (isPlausibleTemp(cabinTemp)) {
tlm.put("cabin_temp", Math.round(cabinTemp));
}
// Same guard for the pack: a VHAL that does not implement the property
// answers 0.0, and "battery at 0 °C" is a reading ABRP acts on.
if (isPlausibleTemp(battTempC)) {
tlm.put("batt_temp", Math.round(battTempC));
}
// hvac_setpoint is a user setting, so 0 °C is not a real value either —
// no cabin climate control is ever set to freezing.
if (isPlausibleTemp(hvacSetpointC)) {
tlm.put("hvac_setpoint", Math.round(hvacSetpointC));
}
// A pack cannot hold 0 kWh and a car cannot have driven 0 km by the time
// it runs this app: both zeros mean "property not implemented".
if (capacityKwh != null && capacityKwh > 0f) {
tlm.put("capacity", round2(capacityKwh));
}
if (odometerKm != null && odometerKm > 0f) {
tlm.put("odometer", Math.round(odometerKm));
}
// Same reasoning as capacity: a car that is running this app has some energy
// in its pack, so 0 kWh is the VHAL saying it does not implement the property.
if (soeKwh != null && soeKwh > 0f) {
tlm.put("soe", round2(soeKwh));
}
// 0.0 IS meaningful here — it is a charge session that has just begun — so
// This one is guarded on null only. EVHardware's charging-session meter sends
// null between sessions.
if (kwhCharged != null) {
tlm.put("kwh_charged", kwhCharged.doubleValue());
}
if (isPlausibleTemp(extTempC)) {
tlm.put("ext_temp", Math.round(extTempC));
}
// A pack under load is never at 0 V; 0 is the property not being implemented.
if (voltageV != null && voltageV > 0f && voltageV < 1500f) {
tlm.put("voltage", round2(voltageV));
}
// 0 A is a real reading (idle), so this one is guarded on range only.
if (currentA != null && currentA > -2000f && currentA < 2000f) {
tlm.put("current", round2(currentA));
}
putIfPresent(tlm, "tire_pressure_fl", kpa(tirePressureFlKpa));
putIfPresent(tlm, "tire_pressure_fr", kpa(tirePressureFrKpa));
putIfPresent(tlm, "tire_pressure_rl", kpa(tirePressureRlKpa));
putIfPresent(tlm, "tire_pressure_rr", kpa(tirePressureRrKpa));
if (lat != null && lon != null) {
tlm.put("lat", lat.doubleValue());
tlm.put("lon", lon.doubleValue());
putIfPresent(tlm, "elevation", elevation == null ? null : Math.round(elevation));
putIfPresent(tlm, "heading", heading == null ? null : Math.round(heading));
}
} catch (JSONException e) {
// JSONObject.put only throws on NaN/Infinity values, which the guards above
// already exclude. Returning just the timestamp keeps the upload alive.
return "{\"utc\":" + utc + "}";
}
return tlm.toString();
}
/**
* 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.
*
* Package-private because the service asks the same question before deciding whether to
* look for an outside temperature somewhere other than the car.
*/
static boolean isPlausibleTemp(Float celsius) {
return celsius != null && celsius > -50f && celsius < 80f && celsius != 0f;
}
/**
* Rounded kPa, or null when the reading is outside anything a road tyre reports.
* A flat tyre still reads well above zero, so 0 means the wheel has no sensor.
*/
private static Integer kpa(Float pressure) {
if (pressure == null || pressure < 50f || pressure > 500f) return null;
return Math.round(pressure);
}
private static void putIfPresent(JSONObject json, String key, Object value)
throws JSONException {
if (value != null) json.put(key, value);
}
private static Integer boolToInt(Boolean value) {
return value == null ? null : (value ? 1 : 0);
}
/** ABRP expects kW with 2 decimals; avoids a locale-dependent String.format. */
private static double round2(float value) {
return Math.round(value * 100.0) / 100.0;
}
/**
* Every field {@link #build()} can emit, in the order the summary lists them.
* Used only to work out what was left out — see {@link #summarize(String)}.
*/
private static final String[] ALL_FIELDS = {
"utc", "soc", "speed", "power", "est_battery_range", "cabin_temp",
"batt_temp", "hvac_setpoint", "capacity", "soe", "kwh_charged", "odometer",
"is_charging", "is_dcfc", "is_parked", "lat", "lon", "elevation", "heading",
"tire_pressure_fl", "tire_pressure_fr", "tire_pressure_rl", "tire_pressure_rr",
"ext_temp", "voltage", "current",
};
/**
* One-line, human-readable digest of an upload: what was sent, and what was left out.
*
* Derived from the JSON that actually goes on the wire rather than from the fields
* above, so it cannot drift away from the payload it claims to describe — which is
* the whole point of having it when a value on the ABRP side looks wrong.
*/
static String summarize(String tlmJson) {
JSONObject tlm;
try {
tlm = new JSONObject(tlmJson);
} catch (JSONException e) {
return "unparseable payload: " + tlmJson;
}
StringBuilder sent = new StringBuilder();
StringBuilder omitted = new StringBuilder();
for (String field : ALL_FIELDS) {
if (tlm.has(field)) {
if (sent.length() > 0) sent.append(' ');
sent.append(field).append('=').append(tlm.opt(field));
} else {
if (omitted.length() > 0) omitted.append(',');
omitted.append(field);
}
}
return sent + (omitted.length() == 0 ? "" : " | omitted: " + omitted);
}
}
@@ -0,0 +1,59 @@
package com.evsuite.abrp;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.List;
/**
* Live samples waiting to reach the server, oldest first.
*
* Bounded: while the car is offline the oldest samples are dropped once {@link #capacity}
* is reached, so a long dead spot costs the start of the gap, never the head unit's memory.
* Held in memory only — a process restart loses what is queued, which is the price of not
* writing to flash every 15 seconds.
*
* Free of Android types so the policy is unit-tested on the JVM.
*/
final class TelemetryQueue {
/** About eight hours at one sample per 15-second tick. */
static final int DEFAULT_CAPACITY = 2000;
/** The server takes at most 500 samples per request. */
static final int MAX_BATCH = 500;
private final int capacity;
private final ArrayDeque<String> samples = new ArrayDeque<>();
private int dropped;
TelemetryQueue() { this(DEFAULT_CAPACITY); }
TelemetryQueue(int capacity) { this.capacity = capacity; }
synchronized void add(String sampleJson) {
if (samples.size() >= capacity) {
samples.removeFirst();
dropped++;
}
samples.addLast(sampleJson);
}
/** Up to {@link #MAX_BATCH} of the oldest samples, still queued until {@link #remove}. */
synchronized List<String> peekBatch() {
List<String> batch = new ArrayList<>(Math.min(samples.size(), MAX_BATCH));
for (String sample : samples) {
if (batch.size() >= MAX_BATCH) break;
batch.add(sample);
}
return batch;
}
/** Drops the oldest {@code count} samples once the server has confirmed them. */
synchronized void remove(int count) {
for (int i = 0; i < count && !samples.isEmpty(); i++) samples.removeFirst();
}
synchronized int size() { return samples.size(); }
/** Samples lost to the capacity limit since the queue was created. */
synchronized int dropped() { return dropped; }
}
@@ -0,0 +1,264 @@
package com.evsuite.abrp;
import android.content.Context;
import android.location.Location;
import android.location.LocationListener;
import android.location.LocationManager;
import android.os.Bundle;
import android.os.Looper;
import android.os.SystemClock;
import android.util.Log;
import com.evsuite.hardware.telemetry.EnergySnapshot;
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;
/**
* Live telemetry to the car server, which stores it and forwards the newest sample 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
* 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.
*
* 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.
*/
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. */
private static final long MIN_LOCATION_MAX_AGE_MS = 5 * 60_000L;
private static final long LOCATION_RETRY_MS = 30_000L;
private final Context context;
private final TelemetryQueue queue = new TelemetryQueue();
private final ChargingSignal chargingSignal = new ChargingSignal();
private volatile Location lastLocation;
private volatile boolean locationActive;
private volatile long lastLocationRequestMs;
private LocationManager locationManager;
private long lastUploadAttemptMs;
private Boolean lastParked;
private Boolean lastCharging;
TelemetryUploader(Context context) {
this.context = context.getApplicationContext();
}
// ---------- Tick ----------
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();
Boolean parked = vehicle.getParked();
// The charge port property need not be implemented; ChargingSignal falls back to the
// charge rate and the park state, exactly as the removed ABRP uploader did.
Boolean charging = chargingSignal.evaluate(
nowMs, vehicle.getChargePortConnected(), powerKw == null ? null : -powerKw, parked, soc);
Boolean dcfc = charging == null || powerKw == null ? null
: charging && ChargingState.isDcCharging(vehicle.getChargingStatus(),
vehicle.getChargePortConnected(), powerKw, vehicle.getSpeedKmh());
boolean stateChanged = !Objects.equals(parked, lastParked) || !Objects.equals(charging, lastCharging);
lastParked = parked;
lastCharging = charging;
boolean idle = Boolean.TRUE.equals(parked) && Boolean.FALSE.equals(charging);
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));
}
if (!uploadDue) return;
// Cadence counts attempts, not successes: retrying every tick while offline would
// spend power exactly when it buys nothing.
lastUploadAttemptMs = nowMs;
flush();
}
int queued() { return queue.size(); }
// ---------- Sample ----------
private String buildSample(long nowMs, EnergySnapshot vehicle, ChargeSessionTracker.Snapshot session,
UploadSettings settings, Integer soc, Boolean charging, Boolean dcfc, Boolean parked) {
TelemetryPayload tlm = new TelemetryPayload(nowMs / 1000);
tlm.soc = soc;
tlm.speedKmh = vehicle.getSpeedKmh();
tlm.rangeKm = vehicle.getRangeKm() == null ? null : Math.round(vehicle.getRangeKm());
tlm.powerKw = vehicle.getBatteryPowerKw();
tlm.charging = charging;
tlm.dcfc = dcfc;
tlm.parked = parked;
tlm.cabinTemp = vehicle.getCabinTempCelsius();
tlm.battTempC = vehicle.getBatteryTempCelsius();
tlm.extTempC = vehicle.getOutsideTempCelsius();
tlm.capacityKwh = vehicle.getBatteryCapacityKwh();
tlm.soeKwh = vehicle.getBatteryEnergyKwh();
tlm.odometerKm = vehicle.getOdometerKm();
tlm.hvacSetpointC = vehicle.getClimate().getDriverTargetCelsius();
tlm.voltageV = vehicle.getBatteryVoltageV();
tlm.currentA = vehicle.getBatteryCurrentA();
// Only DC charges are metered, so only those carry an energy figure.
if (Boolean.TRUE.equals(dcfc) && session != null && session.charging) tlm.kwhCharged = session.energyKwh;
TirePressureSnapshot tires = vehicle.getTirePressures();
tlm.tirePressureFlKpa = tires.getFrontLeftKpa();
tlm.tirePressureFrKpa = tires.getFrontRightKpa();
tlm.tirePressureRlKpa = tires.getRearLeftKpa();
tlm.tirePressureRrKpa = tires.getRearRightKpa();
Location loc = freshLocation(settings);
if (loc != null) {
tlm.lat = loc.getLatitude();
tlm.lon = loc.getLongitude();
tlm.elevation = loc.hasAltitude() ? loc.getAltitude() : null;
tlm.heading = loc.hasBearing() ? loc.getBearing() : null;
}
return tlm.build();
}
// ---------- Upload ----------
private void flush() {
ServerConfig config = ServerConfig.get(context);
if (!config.isConfigured()) {
Log.i(TAG, "Telemetry not sent: server not configured (" + queue.size() + " queued)");
return;
}
for (int i = 0; i < MAX_BATCHES_PER_TICK && queue.size() > 0; i++) {
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;
AbrpUploadService.log().record(new UploadLog.Entry(System.currentTimeMillis(), status, ok,
ok ? "Telemetry: " + batch.size() + " sample(s) sent"
: "Telemetry rejected (" + batch.size() + " queued)", 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.
if (status == 400) queue.remove(batch.size());
return;
}
queue.remove(batch.size());
} catch (Exception e) {
AbrpUploadService.log().record(new UploadLog.Entry(System.currentTimeMillis(), 0, false,
"Telemetry offline — " + queue.size() + " queued: " + e.getClass().getSimpleName(),
summary));
return;
}
}
}
private int 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()
.put("installationId", config.installationId)
.put("samples", samples)
.toString().getBytes(StandardCharsets.UTF_8);
HttpURLConnection connection = null;
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();
}
}
// ---------- Location ----------
private final LocationListener locationListener = new LocationListener() {
@Override public void onLocationChanged(Location location) { lastLocation = location; }
@Override public void onStatusChanged(String provider, int status, Bundle extras) { }
@Override public void onProviderEnabled(String provider) { locationActive = false; }
@Override public void onProviderDisabled(String provider) {
// A stale fix is worse than none: it would put the car back where it was.
lastLocation = null;
locationActive = false;
}
};
/** Subscribes to GPS once, and again after a failure or a provider change. */
private void ensureLocationUpdates(UploadSettings settings) {
if (locationActive) return;
long now = SystemClock.elapsedRealtime();
if (lastLocationRequestMs != 0L && now - lastLocationRequestMs < LOCATION_RETRY_MS) return;
lastLocationRequestMs = now;
new android.os.Handler(Looper.getMainLooper()).post(() -> requestLocationUpdates(settings));
}
private void requestLocationUpdates(UploadSettings settings) {
try {
if (locationManager == null) {
locationManager = (LocationManager) context.getSystemService(Context.LOCATION_SERVICE);
}
try { locationManager.removeUpdates(locationListener); } catch (Exception ignored) { }
if (!locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER)) {
Log.w(TAG, "GPS provider disabled — position omitted, will retry");
return;
}
// Matched to the upload interval: fixes between uploads would only be thrown away.
locationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER,
Math.min(settings.intervalMs(), 15_000L), 0f, locationListener, Looper.getMainLooper());
locationActive = true;
Location last = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER);
if (last != null && locationAgeMs(last) <= MIN_LOCATION_MAX_AGE_MS) lastLocation = last;
} catch (SecurityException e) {
Log.w(TAG, "Location permission not granted: " + e.getMessage());
} catch (Exception e) {
Log.w(TAG, "Location unavailable: " + e.getMessage());
}
}
void stop() {
try {
if (locationManager != null) locationManager.removeUpdates(locationListener);
} catch (Exception ignored) { }
locationActive = false;
}
private Location freshLocation(UploadSettings settings) {
Location loc = lastLocation;
if (loc == null) return null;
long maxAge = Math.max(MIN_LOCATION_MAX_AGE_MS, 3 * settings.intervalMs());
return locationAgeMs(loc) <= maxAge ? loc : null;
}
/**
* Age on the monotonic clock: the head unit sets its wall clock from GPS, so a fix's
* wall-clock time can jump. A provider that does not stamp the monotonic clock leaves 0;
* such a fix counts as fresh rather than blanking the position for good.
*/
private static long locationAgeMs(Location loc) {
long fixNanos = loc.getElapsedRealtimeNanos();
if (fixNanos <= 0L) return 0L;
return Math.max(0L, (SystemClock.elapsedRealtimeNanos() - fixNanos) / 1_000_000L);
}
}
@@ -0,0 +1,66 @@
package com.evsuite.abrp;
/**
* Decides whether a given scheduler tick should actually upload.
*
* The service ticks at a fixed short rate; this class thins the uploads out. A car parked
* overnight and not charging does not need a sample every minute — that is wasted data and
* power — but nothing may be lost while driving or charging, where ABRP needs the
* resolution to keep a route plan accurate.
*
* Free of Android types so the policy can be unit-tested directly.
*/
final class UploadCadence {
/**
* Parked and not charging: one keep-alive sample every 15 minutes. The car is not
* moving and its SOC is not changing, so anything more is spent for nothing.
*/
static final long PARKED_INTERVAL_MS = 900_000L;
private UploadCadence() { }
/**
* @param nowMs current time
* @param lastUploadMs when the last upload was attempted, 0 if never
* @param parked null when the gear could not be read
* @param charging null when the charge port state could not be read
* @param socPercent null when the battery level could not be read
* @param stateChanged true if parked/charging differ from the previous tick
* @param settings user-configured cadence
*/
static boolean shouldUpload(long nowMs, long lastUploadMs,
Boolean parked, Boolean charging, Integer socPercent,
boolean stateChanged, UploadSettings settings) {
// Never uploaded yet: send immediately so ABRP sees the car come online.
if (lastUploadMs <= 0L) return true;
// A transition (just parked, plugged in, unplugged) is exactly what ABRP must
// learn straight away — never make it wait for the slow interval.
if (stateChanged) return true;
return nowMs - lastUploadMs >= intervalFor(parked, charging, socPercent, settings);
}
/**
* Interval that applies in the given state.
*
* Unknown state counts as active: if the gear or charge port could not be read we do
* not know the car is idle, and under-reporting a moving car is worse than a few
* extra requests from a stationary one.
*/
static long intervalFor(Boolean parked, Boolean charging, Integer socPercent,
UploadSettings settings) {
boolean idle = Boolean.TRUE.equals(parked) && Boolean.FALSE.equals(charging);
if (idle) return PARKED_INTERVAL_MS;
// Low battery: the driver is picking a charge stop and ABRP needs a tighter SOC
// curve to get the arrival estimate right. Only while actually in use — a car
// parked at 5 % is still parked and gains nothing from fast polling.
if (settings.boostLowSoc && socPercent != null
&& socPercent <= settings.lowSocPercent) {
return Math.min(settings.intervalMs(), UploadSettings.BOOSTED_INTERVAL_SEC * 1000L);
}
return settings.intervalMs();
}
}
@@ -1,14 +1,79 @@
package com.evsuite.abrp;
import android.content.SharedPreferences;
/**
* Placeholder — the service is always-on and samples on a fixed 15-second scheduler, so
* there is no user-configurable upload policy left. Kept as an empty shell so any stray
* references compile during the ABRP removal; will be deleted once all callers are gone.
* User-configurable upload policy.
*
* Defaults are deliberately conservative. The head unit is awake whenever the car is on,
* and both the upload tick and the GPS subscription cost power the whole time, so the
* default cadence is the slowest one that still keeps an ABRP route plan usable.
*/
final class UploadSettings {
static final String KEY_AUTOSTART = "autostart";
static final boolean DEFAULT_AUTOSTART = true;
static final String KEY_INTERVAL_SEC = "upload_interval_sec";
static final String KEY_BOOST_LOW_SOC = "boost_low_soc";
static final String KEY_LOW_SOC_PERCENT = "low_soc_percent";
static final String KEY_AUTOSTART = "autostart";
static final String KEY_ABRP_UPLOAD_ENABLED = "abrp_upload_enabled";
private UploadSettings() { }
/**
* On by default: an ABRP plan is only useful if it is fed from the moment the drive
* starts, and nobody opens this app before setting off. Kept as a switch because a
* service that starts itself must also be switchable off.
*/
static final boolean DEFAULT_AUTOSTART = true;
static final boolean DEFAULT_ABRP_UPLOAD_ENABLED = true;
/**
* Default cadence while driving. 60 s is roughly 1.5 km between points at motorway
* speed, which ABRP can still track; going much slower makes the live plan lag
* reality. Parked and idle backs off far further on its own — see UploadCadence.
*/
static final int DEFAULT_INTERVAL_SEC = 60;
/** Offered in the UI. Kept short so the list stays a single glance on a car screen. */
static final int[] INTERVAL_CHOICES_SEC = { 15, 30, 60, 120, 300 };
static final boolean DEFAULT_BOOST_LOW_SOC = true;
/** Below this, range planning gets tight and ABRP needs a finer SOC curve. */
static final int DEFAULT_LOW_SOC_PERCENT = 20;
/** Cadence used while SOC is low and the boost is enabled. */
static final int BOOSTED_INTERVAL_SEC = 15;
final int intervalSec;
final boolean boostLowSoc;
final int lowSocPercent;
final boolean abrpUploadEnabled;
UploadSettings(int intervalSec, boolean boostLowSoc, int lowSocPercent) {
this(intervalSec, boostLowSoc, lowSocPercent, DEFAULT_ABRP_UPLOAD_ENABLED);
}
UploadSettings(int intervalSec, boolean boostLowSoc, int lowSocPercent,
boolean abrpUploadEnabled) {
this.intervalSec = intervalSec;
this.boostLowSoc = boostLowSoc;
this.lowSocPercent = lowSocPercent;
this.abrpUploadEnabled = abrpUploadEnabled;
}
static UploadSettings from(SharedPreferences prefs) {
return new UploadSettings(
prefs.getInt(KEY_INTERVAL_SEC, DEFAULT_INTERVAL_SEC),
prefs.getBoolean(KEY_BOOST_LOW_SOC, DEFAULT_BOOST_LOW_SOC),
prefs.getInt(KEY_LOW_SOC_PERCENT, DEFAULT_LOW_SOC_PERCENT),
prefs.getBoolean(KEY_ABRP_UPLOAD_ENABLED, DEFAULT_ABRP_UPLOAD_ENABLED));
}
static UploadSettings defaults() {
return new UploadSettings(
DEFAULT_INTERVAL_SEC, DEFAULT_BOOST_LOW_SOC, DEFAULT_LOW_SOC_PERCENT);
}
long intervalMs() {
return intervalSec * 1000L;
}
}
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Arrow into a tray: the Server tab (where the telemetry goes). Tinted by
@color/nav_tab_foreground. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:strokeColor="#FFFFFFFF"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:fillColor="#00000000"
android:pathData="M4,14V19H20V14" />
<path
android:strokeColor="#FFFFFFFF"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:fillColor="#00000000"
android:pathData="M12,3V13M8,7L12,3L16,7" />
</vector>
@@ -10,6 +10,7 @@
<com.google.android.material.button.MaterialButton android:id="@+id/tabConsumption" style="@style/Widget.EV.NavTab" android:layout_width="match_parent" android:layout_height="64dp" android:layout_marginBottom="6dp" android:text="@string/tab_consumption" app:icon="@drawable/ic_tab_vehicle" />
<com.google.android.material.button.MaterialButton android:id="@+id/tabCharging" style="@style/Widget.EV.NavTab" android:layout_width="match_parent" android:layout_height="64dp" android:layout_marginBottom="6dp" android:text="@string/tab_charging" app:icon="@drawable/ic_tab_service" />
<com.google.android.material.button.MaterialButton android:id="@+id/tabService" style="@style/Widget.EV.NavTab" android:layout_width="match_parent" android:layout_height="64dp" android:layout_marginBottom="6dp" android:text="@string/tab_service" app:icon="@drawable/ic_tab_service" />
<com.google.android.material.button.MaterialButton android:id="@+id/tabServer" style="@style/Widget.EV.NavTab" android:layout_width="match_parent" android:layout_height="64dp" android:layout_marginBottom="6dp" android:text="@string/tab_server" app:icon="@drawable/ic_tab_server" />
<com.google.android.material.button.MaterialButton android:id="@+id/tabWifi" style="@style/Widget.EV.NavTab" android:layout_width="match_parent" android:layout_height="64dp" android:layout_marginBottom="6dp" android:text="@string/tab_wifi" app:icon="@drawable/ic_tab_wifi" />
<com.google.android.material.button.MaterialButton android:id="@+id/tabLog" style="@style/Widget.EV.NavTab" android:layout_width="match_parent" android:layout_height="64dp" android:layout_marginBottom="6dp" android:text="@string/tab_log" app:icon="@drawable/ic_tab_log" />
</LinearLayout>
@@ -3,12 +3,9 @@
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:padding="24dp" android:baselineAligned="false">
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:layout_marginEnd="16dp" android:padding="24dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_service_controls" android:textAppearance="@style/Text.EV.Eyebrow" />
<TextView android:id="@+id/status_text" android:layout_width="match_parent" android:layout_height="wrap_content" android:background="@drawable/bg_input_panel" android:padding="16dp" android:layout_marginTop="20dp" android:layout_marginBottom="12dp" android:text="@string/status_stopped" android:textAppearance="@style/Text.EV.Body" />
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/service_switch" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:text="@string/label_upload_enabled" android:textAppearance="@style/Text.EV.Body" android:paddingTop="8dp" android:paddingBottom="8dp" app:useMaterialThemeColors="true" app:thumbTint="@color/switch_thumb" app:trackTint="@color/switch_track" android:textColor="@color/ev_text_primary" />
<TextView android:id="@+id/status_text" android:layout_width="match_parent" android:layout_height="wrap_content" android:background="@drawable/bg_input_panel" android:padding="16dp" android:layout_marginTop="20dp" android:layout_marginBottom="12dp" android:text="@string/state_stopped" android:textAppearance="@style/Text.EV.Body" />
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/abrp_upload_switch" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:text="@string/label_abrp_upload_enabled" android:textAppearance="@style/Text.EV.Body" android:paddingTop="8dp" android:paddingBottom="8dp" app:useMaterialThemeColors="true" app:thumbTint="@color/switch_thumb" app:trackTint="@color/switch_track" android:textColor="@color/ev_text_primary" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/hint_abrp_upload_enabled" android:textAppearance="@style/Text.EV.Secondary" />
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/autostart_switch" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:text="@string/label_autostart" android:textAppearance="@style/Text.EV.Body" android:paddingTop="8dp" android:paddingBottom="8dp" app:useMaterialThemeColors="true" app:thumbTint="@color/switch_thumb" app:trackTint="@color/switch_track" android:textColor="@color/ev_text_primary" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/hint_autostart" android:textAppearance="@style/Text.EV.Secondary" />
</LinearLayout>
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:padding="24dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_service_timing" android:textAppearance="@style/Text.EV.Eyebrow" />
@@ -17,6 +17,7 @@
<com.google.android.material.button.MaterialButton android:id="@+id/tabConsumption" style="@style/Widget.EV.NavTab.Compact" android:layout_width="0dp" android:layout_height="76dp" android:layout_weight="1" android:text="@string/tab_consumption" app:icon="@drawable/ic_tab_vehicle"/>
<com.google.android.material.button.MaterialButton android:id="@+id/tabCharging" style="@style/Widget.EV.NavTab.Compact" android:layout_width="0dp" android:layout_height="76dp" android:layout_weight="1" android:text="@string/tab_charging" app:icon="@drawable/ic_tab_service"/>
<com.google.android.material.button.MaterialButton android:id="@+id/tabService" style="@style/Widget.EV.NavTab.Compact" android:layout_width="0dp" android:layout_height="76dp" android:layout_weight="1" android:text="@string/tab_service" app:icon="@drawable/ic_tab_service"/>
<com.google.android.material.button.MaterialButton android:id="@+id/tabServer" style="@style/Widget.EV.NavTab.Compact" android:layout_width="0dp" android:layout_height="76dp" android:layout_weight="1" android:text="@string/tab_server" app:icon="@drawable/ic_tab_server"/>
<com.google.android.material.button.MaterialButton android:id="@+id/tabWifi" style="@style/Widget.EV.NavTab.Compact" android:layout_width="0dp" android:layout_height="76dp" android:layout_weight="1" android:text="@string/tab_wifi" app:icon="@drawable/ic_tab_wifi"/>
<com.google.android.material.button.MaterialButton android:id="@+id/tabLog" style="@style/Widget.EV.NavTab.Compact" android:layout_width="0dp" android:layout_height="76dp" android:layout_weight="1" android:text="@string/tab_log" app:icon="@drawable/ic_tab_log"/>
</LinearLayout>
+30
View File
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" android:fillViewport="true" android:clipToPadding="false">
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="24dp">
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="24dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/server_card_title" android:textAppearance="@style/Text.EV.Eyebrow" />
<com.google.android.material.textfield.TextInputLayout android:id="@+id/server_url_layout" style="@style/Widget.EV.Input" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="18dp" android:hint="@string/hint_server_url" app:helperText="@string/helper_server_url">
<com.google.android.material.textfield.TextInputEditText android:id="@+id/server_url_input" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:inputType="textUri" android:singleLine="true" android:textSize="20sp" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout android:id="@+id/server_token_layout" style="@style/Widget.EV.Input" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="18dp" android:hint="@string/hint_server_token" app:endIconMode="password_toggle" app:endIconMinSize="72dp" app:helperText="@string/helper_server_token">
<com.google.android.material.textfield.TextInputEditText android:id="@+id/server_token_input" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:inputType="textPassword" android:singleLine="true" android:textSize="20sp" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout android:id="@+id/installation_id_layout" style="@style/Widget.EV.Input" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="18dp" android:hint="@string/hint_installation_id" app:helperText="@string/helper_installation_id">
<com.google.android.material.textfield.TextInputEditText android:id="@+id/installation_id_input" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:inputType="text" android:singleLine="true" android:textSize="18sp" android:fontFamily="monospace" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton android:id="@+id/server_save_button" style="@style/Widget.EV.Button" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/btn_save" android:layout_marginTop="20dp" />
<com.google.android.material.button.MaterialButton android:id="@+id/server_test_button" style="@style/Widget.EV.Button.Outlined" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/btn_test" android:layout_marginTop="8dp" />
<LinearLayout android:id="@+id/connection_status_row" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:gravity="center_vertical" android:orientation="horizontal" android:visibility="gone">
<View android:id="@+id/connection_indicator" android:layout_width="10dp" android:layout_height="10dp" android:layout_marginEnd="12dp" android:background="@drawable/circle_indicator" />
<TextView android:id="@+id/connection_status_text" android:layout_width="match_parent" android:layout_height="wrap_content" android:textAppearance="@style/Text.EV.Body" />
</LinearLayout>
</LinearLayout>
<Space android:layout_width="1dp" android:layout_height="20dp" />
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="24dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_private_title" android:textAppearance="@style/Text.EV.Title" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_private_body" android:textAppearance="@style/Text.EV.Secondary" android:layout_marginTop="16dp" android:lineSpacingMultiplier="1.3" />
<com.google.android.material.button.MaterialButton android:id="@+id/import_button" style="@style/Widget.EV.Button.Outlined" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/btn_import" android:layout_marginTop="24dp" />
<TextView android:id="@+id/import_hint" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:textAppearance="@style/Text.EV.Secondary" android:textIsSelectable="true" />
</LinearLayout>
</LinearLayout>
</ScrollView>
+1 -4
View File
@@ -3,12 +3,9 @@
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="24dp">
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="24dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_service_controls" android:textAppearance="@style/Text.EV.Eyebrow" />
<TextView android:id="@+id/status_text" android:layout_width="match_parent" android:layout_height="wrap_content" android:background="@drawable/bg_input_panel" android:padding="16dp" android:layout_marginTop="20dp" android:layout_marginBottom="12dp" android:text="@string/status_stopped" android:textAppearance="@style/Text.EV.Body" />
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/service_switch" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:text="@string/label_upload_enabled" android:textAppearance="@style/Text.EV.Body" android:paddingTop="8dp" android:paddingBottom="8dp" app:useMaterialThemeColors="true" app:thumbTint="@color/switch_thumb" app:trackTint="@color/switch_track" android:textColor="@color/ev_text_primary" />
<TextView android:id="@+id/status_text" android:layout_width="match_parent" android:layout_height="wrap_content" android:background="@drawable/bg_input_panel" android:padding="16dp" android:layout_marginTop="20dp" android:layout_marginBottom="12dp" android:text="@string/state_stopped" android:textAppearance="@style/Text.EV.Body" />
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/abrp_upload_switch" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:text="@string/label_abrp_upload_enabled" android:textAppearance="@style/Text.EV.Body" android:paddingTop="8dp" android:paddingBottom="8dp" app:useMaterialThemeColors="true" app:thumbTint="@color/switch_thumb" app:trackTint="@color/switch_track" android:textColor="@color/ev_text_primary" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/hint_abrp_upload_enabled" android:textAppearance="@style/Text.EV.Secondary" />
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/autostart_switch" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:text="@string/label_autostart" android:textAppearance="@style/Text.EV.Body" android:paddingTop="8dp" android:paddingBottom="8dp" app:useMaterialThemeColors="true" app:thumbTint="@color/switch_thumb" app:trackTint="@color/switch_track" android:textColor="@color/ev_text_primary" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/hint_autostart" android:textAppearance="@style/Text.EV.Secondary" />
</LinearLayout>
<Space android:layout_width="1dp" android:layout_height="20dp" />
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="24dp" android:background="@drawable/bg_glass">
+24 -24
View File
@@ -2,7 +2,6 @@
<string name="app_name">Nils\' MG4</string>
<!-- Main screen -->
<string name="tab_abrp">ABRP</string>
<string name="tab_service">Upload</string>
<string name="tab_vehicle">Overview</string>
<string name="tab_consumption">Consumption</string>
@@ -11,6 +10,7 @@
<string name="consumption_subtitle">Since start, motor, lifetime and trip counters</string>
<string name="charging_title">Charging</string>
<string name="charging_subtitle">Live battery values and SOC/power curve</string>
<string name="tab_server">Server</string>
<string name="tab_wifi">Wi-Fi</string>
<string name="tab_log">Logs</string>
@@ -42,45 +42,45 @@
<string name="vehicle_tire_fr">Front Right</string>
<string name="vehicle_tire_rl">Rear Left</string>
<string name="vehicle_tire_rr">Rear Right</string>
<string name="card_config">Configuration</string>
<string name="hint_api_key">ABRP API Key</string>
<string name="default_abrp_api_key" translatable="false">8cfc314b-03cd-4efe-ab7d-4431cd8f2e2d</string>
<string name="helper_api_key">Default: open-source ABRP telemetry key. You may replace it with your own.</string>
<string name="api_key_required">Enter your ABRP API key first</string>
<string name="hint_token">ABRP User Token</string>
<string name="helper_token">Find your token in the ABRP app: Settings → Connections → Generic (Manual Entry)</string>
<string name="desc_toggle_api_key">Show or hide API key</string>
<string name="desc_toggle_token">Show or hide user token</string>
<string name="btn_save">Save</string>
<string name="btn_test">Test connection</string>
<string name="btn_import">Import from file</string>
<!-- Server page -->
<string name="server_card_title">Car server</string>
<string name="hint_server_url">Server URL</string>
<string name="helper_server_url">For example https://car.stinnesbeck.com</string>
<string name="hint_server_token">Ingest token</string>
<string name="helper_server_token">The server\'s INGEST_TOKEN, at least 24 characters</string>
<string name="hint_installation_id">Installation ID</string>
<string name="helper_installation_id">Your data is stored under this ID. Keep it when reinstalling.</string>
<string name="server_err_url">Enter an https:// URL</string>
<string name="server_err_token">The token must be at least 24 characters, without spaces</string>
<string name="server_err_installation_id">Not a valid ID (UUID)</string>
<string name="server_saved">Server settings saved</string>
<string name="server_not_configured">Server not configured — open the Server tab</string>
<string name="import_hint">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.</string>
<!-- Config file import -->
<string name="import_ok">Config imported — press Test to verify</string>
<string name="import_ok">Config imported and saved — press Test to verify</string>
<string name="import_err_read">Could not read that file</string>
<string name="import_err_empty">No ABRP settings found in 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>
<!-- Connection test -->
<string name="conn_testing">Testing…</string>
<string name="conn_ok">Connected — API responding OK</string>
<string name="conn_err_auth">Auth failed — check your API key and token</string>
<string name="conn_ok">Connected — server accepts the token</string>
<string name="conn_err_auth">Token rejected — check it against the server\'s INGEST_TOKEN</string>
<string name="perm_denied_location">Location denied — telemetry will have no position. Grant it in Settings to send GPS to ABRP.</string>
<string name="perm_denied_car">Vehicle data denied — no battery %%, speed or range will be sent. Grant it in Settings.</string>
<string name="perm_denied_all">Location and vehicle data denied — only a timestamp will be sent. Grant both in Settings.</string>
<string name="conn_err_no_internet">No internet connection</string>
<string name="conn_err_timeout">Connection timed out</string>
<string name="conn_err_http">Server error (HTTP %1$d)</string>
<string name="token_required">Enter your ABRP token first</string>
<string name="card_service">Upload Service</string>
<string name="label_upload_enabled">Enable background telemetry</string>
<string name="label_abrp_upload_enabled">Upload telemetry to ABRP</string>
<string name="hint_abrp_upload_enabled">Turn this off when DiPlay sends ABRP data. Local consumption tracking and background telemetry keep running.</string>
<string name="label_autostart">Start automatically with the car</string>
<string name="hint_autostart">Uploading resumes on its own when the head unit powers up. Turn this off to start it by hand from this screen.</string>
<string name="label_abrp_upload_enabled">Send live telemetry to the server</string>
<string name="hint_abrp_upload_enabled">The server stores it and forwards it to ABRP. Turn this off when another app feeds ABRP; consumption and charging tracking keep running.</string>
<string name="status_stopped">Service not running</string>
<!-- Cadence configuration -->
<string name="label_frequency">Upload frequency</string>
@@ -100,8 +100,8 @@
<string name="state_stopped">Stopped</string>
<string name="state_starting">Starting…</string>
<string name="state_running">Running</string>
<string name="state_tracking_locally">Tracking locally — ABRP upload off</string>
<string name="notif_tracking_locally">Tracking locally — ABRP upload off</string>
<string name="state_tracking_locally">Tracking locally — live telemetry off</string>
<string name="notif_tracking_locally">Tracking locally — live telemetry off</string>
<string name="state_error">In error — last %1$d uploads failed</string>
<!-- Call log -->
+4 -4
View File
@@ -7,8 +7,8 @@
<string name="design_brand_footer">ON THE ROAD, ELECTRIC</string>
<string name="design_overview_title">Your drive at a glance.</string>
<string name="design_overview_subtitle">Data from your car, all in one place.</string>
<string name="design_abrp_title">Connect to your route.</string>
<string name="design_abrp_subtitle">Link your car with your ABRP account.</string>
<string name="design_server_title">Where your data goes.</string>
<string name="design_server_subtitle">Your own server stores it and passes it on to ABRP.</string>
<string name="design_service_title">You focus on the road.</string>
<string name="design_service_subtitle">Your data stays up to date in the background.</string>
<string name="design_wifi_title">Stay connected.</string>
@@ -18,9 +18,9 @@
<string name="design_battery">BATTERY</string>
<string name="design_energy">ENERGY AND TEMPERATURE</string>
<string name="design_tires">TIRE PRESSURES</string>
<string name="design_account">ACCOUNT LINK</string>
<string name="design_account">SERVER LINK</string>
<string name="design_private_title">Your private connection.</string>
<string name="design_private_body">The token links your car to your ABRP account. Your credentials are stored encrypted on this device.</string>
<string name="design_private_body">The token lets the car write to your server. It is stored encrypted on this device; your ABRP credentials stay on the server.</string>
<string name="design_no_data">Values that cannot be read are shown as &#x2014;.</string>
<string name="design_service_controls">UPLOAD CONTROL</string>
<string name="design_service_timing">UPDATE FREQUENCY</string>
@@ -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);
}
}
@@ -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);
}
}
@@ -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());
}
}
@@ -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(""));
}
}
@@ -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<String> 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());
}
}