Fatih'sMG4 — ABRP telemetry for an MG4 EH32 on SWI69
A fork of malys/EVABRPUploader (itself a fork of Leon Kernan's ABRP_Uploader) carrying the changes that make it work on this car. History starts here: the tree is what matters, and the upstream projects are where the earlier work belongs. EVHardware is vendored rather than tracked as a submodule, so one clone builds. Telemetry. SOC, battery power and range read as unavailable on SWI69 because the vendor fallbacks were gated to SWI68 alone, while the property ids they use are the ones this car publishes — the gates now admit SWI69. Tyre pressure, the odometer and charge status come from the vendor block too, since the generic properties for all three are declared here and never publish. Tyre pressure is confirmed on the vehicle; the other two are not yet. Vehicle page. Every signal the car answers, in a table. One that it does not publish shows a dash: an unimplemented VHAL property returns zero, and "0 °C" would read as a measurement. Wi-Fi page. Joins a network the unit already has saved, so a phone hotspot does not need a trip into Settings on every drive. Tried on the upload tick and from a button, skipped while already associated. This leans on the unit being API 28 and the build being platform-signed — getConfiguredNetworks and enableNetwork are closed to ordinary apps from API 29 — and fails quietly where that does not hold. The OTA updater points at this repository. An APK from upstream is signed with a different key, which Android will not install over a platform-signed build. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
/**
|
||||
* Builds the ABRP telemetry JSON payload.
|
||||
*
|
||||
* 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 extTemp; // °C, outside
|
||||
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 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);
|
||||
// Outside temp gets the same plausibility guard as the other temperatures:
|
||||
// a VHAL that does not implement ENV_OUTSIDE_TEMPERATURE answers 0.0, and
|
||||
// ABRP treats "0 °C outside" as a real reading that costs range in its plan.
|
||||
// A genuine 0 °C is lost with it, which is the cheaper of the two errors.
|
||||
if (isPlausibleTemp(extTemp)) {
|
||||
tlm.put("ext_temp", Math.round(extTemp));
|
||||
}
|
||||
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());
|
||||
}
|
||||
|
||||
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", "ext_temp", "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",
|
||||
};
|
||||
|
||||
/**
|
||||
* 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