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:
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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 —.</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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user