Files
mg4/app/src/main/java/com/evsuite/abrp/TelemetryPayload.java
T
nilsandClaude Opus 5.5 a5db5604d3 Code review fixes: app 2.9.0, server 2.2.0
Telemetry no longer loses queued batches. The server drops a bad value,
or a sample without a plausible time, instead of rejecting the batch,
and answers 200 with what it dropped. The app sends only values inside
the server's ranges (speed as a magnitude, so reversing is fine) and no
samples before the head unit's clock is set.

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 00:36:19 +02:00

268 lines
12 KiB
Java

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);
// Reversing reads negative; ABRP and the server take speed as a magnitude.
putIfPresent(tlm, "speed", speedKmh == null ? null : Math.round(Math.abs(speedKmh)));
putIfPresent(tlm, "est_battery_range", rangeKm);
if (powerKw != null) tlm.put("power", round2(powerKw));
putIfPresent(tlm, "is_charging", boolToInt(charging));
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 + "}";
}
dropOutOfRange(tlm);
return tlm.toString();
}
/**
* The server's range for every numeric field (server/model.go, telemetryNumbers). A value
* outside it is left out here, as an unread one would be: the server would drop it anyway,
* and an older server rejects the whole batch for it.
*/
static final java.util.Map<String, double[]> SERVER_LIMITS = new java.util.HashMap<>();
static {
SERVER_LIMITS.put("soc", new double[]{0, 100});
SERVER_LIMITS.put("speed", new double[]{0, 400});
SERVER_LIMITS.put("power", new double[]{-1000, 1000});
SERVER_LIMITS.put("est_battery_range", new double[]{0, 2000});
SERVER_LIMITS.put("capacity", new double[]{0, 500});
SERVER_LIMITS.put("soe", new double[]{0, 500});
SERVER_LIMITS.put("kwh_charged", new double[]{0, 1000});
SERVER_LIMITS.put("odometer", new double[]{0, 10_000_000});
SERVER_LIMITS.put("lat", new double[]{-90, 90});
SERVER_LIMITS.put("lon", new double[]{-180, 180});
SERVER_LIMITS.put("elevation", new double[]{-1000, 10_000});
SERVER_LIMITS.put("heading", new double[]{0, 360});
SERVER_LIMITS.put("batt_temp", new double[]{-60, 100});
SERVER_LIMITS.put("cabin_temp", new double[]{-60, 100});
SERVER_LIMITS.put("ext_temp", new double[]{-60, 70});
SERVER_LIMITS.put("hvac_setpoint", new double[]{0, 40});
SERVER_LIMITS.put("voltage", new double[]{0, 1500});
SERVER_LIMITS.put("current", new double[]{-2000, 2000});
SERVER_LIMITS.put("tire_pressure_fl", new double[]{0, 1000});
SERVER_LIMITS.put("tire_pressure_fr", new double[]{0, 1000});
SERVER_LIMITS.put("tire_pressure_rl", new double[]{0, 1000});
SERVER_LIMITS.put("tire_pressure_rr", new double[]{0, 1000});
}
private static void dropOutOfRange(JSONObject tlm) {
for (java.util.Map.Entry<String, double[]> limit : SERVER_LIMITS.entrySet()) {
String key = limit.getKey();
if (!tlm.has(key)) continue;
double value = tlm.optDouble(key, Double.NaN);
if (Double.isNaN(value) || value < limit.getValue()[0] || value > limit.getValue()[1]) tlm.remove(key);
}
// A position is only a position as a pair.
if (tlm.has("lat") != tlm.has("lon")) {
tlm.remove("lat");
tlm.remove("lon");
}
}
/** Epoch seconds the server accepts: from 2001. Earlier means the head unit's clock is not set yet. */
static final long MIN_UTC = 1_000_000_000L;
static boolean isPlausibleUtc(long utcSeconds) {
return utcSeconds >= MIN_UTC;
}
/**
* True for a temperature that a sensor could actually have measured. Excludes exactly
* 0.0, which is what an unimplemented VHAL property returns — see cabin_temp above.
*
* 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);
}
}