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