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:
TCFDONMEZ
2026-09-22 10:45:00 +03:00
co-authored by Claude
commit 2fd7b2dde1
245 changed files with 35594 additions and 0 deletions
@@ -0,0 +1,50 @@
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);
}
}
@@ -0,0 +1,122 @@
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;
/**
* Charge detection. The reported regression was a charging car being read as
* "parked and not charging", which hands it to {@link UploadCadence#PARKED_INTERVAL_MS}
* and costs ABRP the SOC curve it needs to size a charging stop.
*/
public class ChargingSignalTest {
private static final long T0 = 1_700_000_000_000L;
private final ChargingSignal signal = new ChargingSignal();
// ---- Charge port property, when the VHAL answers it ----
@Test
public void aConnectedPortIsCharging() {
assertTrue(signal.evaluate(T0, true, null, true, 50));
}
@Test
public void anUnpluggedParkedCarIsNotCharging() {
assertFalse(signal.evaluate(T0, false, null, true, 50));
}
@Test
public void anUnreadablePortStaysUnknown() {
// Unknown must not collapse to "not charging": UploadCadence treats null as active.
assertNull(signal.evaluate(T0, null, null, true, 50));
}
// ---- Fallback 1: charge rate ----
@Test
public void aPositiveChargeRateOverridesAPortThatReadsUnplugged() {
assertTrue(signal.evaluate(T0, false, 7.4f, true, 50));
}
@Test
public void aNegativeChargeRateIsDriving() {
assertFalse(signal.evaluate(T0, false, -14.5f, false, 50));
}
@Test
public void noiseAroundZeroIsNotCharging() {
assertFalse(signal.evaluate(T0, false, 0.1f, true, 50));
}
// ---- Fallback 2: rising SOC on a parked car ----
@Test
public void aRisingSocOnAParkedCarIsCharging() {
// Neither charge property readable — the only evidence left is the battery filling.
assertNull(signal.evaluate(T0, null, null, true, 50));
assertTrue(signal.evaluate(T0 + 300_000, null, null, true, 51));
}
@Test
public void theSocRiseIsLatchedAcrossFlatTicks() {
// SOC moves a whole percent every few minutes; without the latch is_charging
// would flap between every pair of ticks.
signal.evaluate(T0, null, null, true, 50);
signal.evaluate(T0 + 300_000, null, null, true, 51);
assertTrue(signal.evaluate(T0 + 315_000, null, null, true, 51));
assertTrue(signal.evaluate(T0 + 1_500_000, null, null, true, 51));
}
@Test
public void theLatchExpiresSoAParkedCarGoesBackToIdle() {
signal.evaluate(T0, null, null, true, 50);
signal.evaluate(T0 + 300_000, null, null, true, 51);
assertNull(signal.evaluate(T0 + 300_000 + ChargingSignal.SOC_RISE_LATCH_MS,
null, null, true, 51));
}
@Test
public void aFallingSocDropsTheLatchImmediately() {
signal.evaluate(T0, null, null, true, 50);
assertTrue(signal.evaluate(T0 + 300_000, null, null, true, 51));
assertNull(signal.evaluate(T0 + 600_000, null, null, true, 50));
}
@Test
public void regenWhileDrivingDoesNotLatch() {
// SOC climbing on a downhill run, then the car parks: not a charging session.
signal.evaluate(T0, false, null, false, 50);
signal.evaluate(T0 + 60_000, false, null, false, 52);
assertFalse(signal.evaluate(T0 + 120_000, false, null, true, 52));
}
@Test
public void anUnreadableSocKeepsThePreviousReadingToCompareAgainst() {
signal.evaluate(T0, null, null, true, 50);
signal.evaluate(T0 + 15_000, null, null, true, null); // one failed read
assertTrue(signal.evaluate(T0 + 30_000, null, null, true, 51));
}
// ---- The regression itself ----
@Test
public void aChargingCarIsNeverHandedToTheParkedCadence() {
// Charging at home with a VHAL that does not implement the charge port property:
// before the fix this was parked + FALSE, i.e. one upload every 15 minutes.
ChargingSignal s = new ChargingSignal();
UploadSettings settings = UploadSettings.defaults();
Boolean charging = s.evaluate(T0, false, null, true, 50);
assertEquals(UploadCadence.PARKED_INTERVAL_MS,
UploadCadence.intervalFor(true, charging, 50, settings));
charging = s.evaluate(T0 + 300_000, false, null, true, 51);
assertEquals(settings.intervalMs(),
UploadCadence.intervalFor(true, charging, 51, settings));
}
}
@@ -0,0 +1,87 @@
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;
/**
* ABRP 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
* 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);
assertEquals("def456", c.token);
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);
assertEquals("tok", c.token);
}
@Test
public void absentKeysStayNullSoNothingIsWiped() {
ConfigImport c = ConfigImport.parse("token = only-token\n");
assertNull(c.apiKey);
assertEquals("only-token", c.token);
assertNull(c.intervalSec);
assertNull(c.boostLowSoc);
assertNull(c.lowSocPercent);
}
@Test
public void emptyOrJunkFileYieldsEmptyConfig() {
assertTrue(ConfigImport.parse("").isEmpty());
assertTrue(ConfigImport.parse("just some prose\nno equals here\n").isEmpty());
assertTrue(ConfigImport.parse("# only a comment\n").isEmpty());
}
@Test
public void intervalSnapsToNearestOfferedChoice() {
// 45 is not an offered value; the spinner only has 15/30/60/120/300.
assertEquals(Integer.valueOf(30), ConfigImport.parse("interval_sec = 45").intervalSec);
assertEquals(Integer.valueOf(60), ConfigImport.parse("interval_sec = 59").intervalSec);
assertEquals(Integer.valueOf(300), ConfigImport.parse("interval_sec = 9999").intervalSec);
}
@Test
public void lowSocPercentIsClampedTo1To99() {
assertEquals(Integer.valueOf(99), ConfigImport.parse("low_soc_percent = 250").lowSocPercent);
assertEquals(Integer.valueOf(1), ConfigImport.parse("low_soc_percent = 0").lowSocPercent);
assertEquals(Integer.valueOf(20), ConfigImport.parse("low_soc_percent = 20").lowSocPercent);
}
@Test
public void boostAcceptsCommonBooleanSpellings() {
assertEquals(Boolean.TRUE, ConfigImport.parse("boost_low_soc = on").boostLowSoc);
assertEquals(Boolean.TRUE, ConfigImport.parse("boost_low_soc = TRUE").boostLowSoc);
assertEquals(Boolean.FALSE, ConfigImport.parse("boost_low_soc = 0").boostLowSoc);
assertNull(ConfigImport.parse("boost_low_soc = maybe").boostLowSoc);
}
@Test
public void onlyFirstEqualsSplitsSoValueMayContainEquals() {
ConfigImport c = ConfigImport.parse("token = tok=with=equals\n");
assertEquals("tok=with=equals", c.token);
}
@Test
public void aliasKeysAreAccepted() {
ConfigImport c = ConfigImport.parse("apikey = k\nuser_token = t\n");
assertEquals("k", c.apiKey);
assertEquals("t", c.token);
}
}
@@ -0,0 +1,109 @@
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,333 @@
package com.evsuite.abrp;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import org.json.JSONObject;
import org.junit.Test;
/**
* [T-913] Telemetry payload construction — pure JVM, no car and no Android needed.
*
* The rule under test: a property that could not be read is OMITTED. Emitting 0 instead
* tells ABRP the car really is at 0 % / 0 km/h and corrupts the user's route plan.
*/
public class TelemetryPayloadTest {
/** Everything readable, no location. */
private TelemetryPayload fullCar() {
TelemetryPayload t = new TelemetryPayload(1_700_000_000L);
t.soc = 63;
t.speedKmh = 48.4f;
t.rangeKm = 210;
t.extTemp = 12f;
t.powerKw = -14.5f;
t.charging = false;
t.dcfc = false;
t.parked = false;
t.cabinTemp = 21.5f;
return t;
}
/** Nothing readable at all — the car link is down. */
private String noCar() {
return new TelemetryPayload(1_700_000_000L).build();
}
@Test
public void utcIsAlwaysPresent() throws Exception {
JSONObject json = new JSONObject(noCar());
assertEquals(1, json.length());
assertEquals(1_700_000_000L, json.getLong("utc"));
}
@Test
public void unreadablePropertiesAreOmittedNotZeroed() {
String json = noCar();
for (String field : new String[]{
"soc", "speed", "est_battery_range", "ext_temp", "power",
"is_charging", "is_dcfc", "is_parked", "cabin_temp",
"batt_temp", "capacity", "odometer", "hvac_setpoint",
"tire_pressure_fl", "tire_pressure_fr",
"tire_pressure_rl", "tire_pressure_rr"}) {
assertFalse("le champ " + field + " aurait dû être omis", json.contains(field));
}
}
@Test
public void aSingleUnreadablePropertyDoesNotSuppressTheOthers() throws Exception {
// SOC throws (null) but speed reads fine: speed must still be sent.
TelemetryPayload t = new TelemetryPayload(1L);
t.speedKmh = 48.4f;
JSONObject json = new JSONObject(t.build());
assertFalse("soc illisible aurait dû être omis", json.has("soc"));
assertEquals(48, json.getInt("speed"));
}
@Test
public void aGenuineZeroIsStillSent() throws Exception {
// 0 % battery is a real reading and must not be confused with "unreadable".
TelemetryPayload t = new TelemetryPayload(1L);
t.soc = 0;
t.speedKmh = 0f;
t.rangeKm = 0;
JSONObject json = new JSONObject(t.build());
assertEquals(0, json.getInt("soc"));
assertEquals(0, json.getInt("speed"));
assertEquals(0, json.getInt("est_battery_range"));
}
@Test
public void readablePropertiesAreEmitted() throws Exception {
JSONObject json = new JSONObject(fullCar().build());
assertEquals(63, json.getInt("soc"));
assertEquals(48, json.getInt("speed")); // rounded
assertEquals(210, json.getInt("est_battery_range"));
assertEquals(12, json.getInt("ext_temp"));
assertEquals(-14.5, json.getDouble("power"), 0.001);
assertEquals(0, json.getInt("is_charging"));
assertEquals(0, json.getInt("is_dcfc"));
assertEquals(0, json.getInt("is_parked"));
}
@Test
public void powerUsesDotDecimalSeparatorRegardlessOfDefaultLocale() throws Exception {
java.util.Locale previous = java.util.Locale.getDefault();
try {
java.util.Locale.setDefault(java.util.Locale.FRANCE); // comma locale
assertTrue(fullCar().build().contains("-14.5"));
} finally {
java.util.Locale.setDefault(previous);
}
}
@Test
public void implausibleCabinTempIsOmitted() {
// 0.0 means the property is unsupported on this VHAL.
TelemetryPayload zero = new TelemetryPayload(1L);
zero.cabinTemp = 0f;
assertFalse(zero.build().contains("cabin_temp"));
TelemetryPayload hot = new TelemetryPayload(1L);
hot.cabinTemp = 120f;
assertFalse(hot.build().contains("cabin_temp"));
}
@Test
public void plausibleCabinTempIsSent() throws Exception {
assertEquals(22, new JSONObject(fullCar().build()).getInt("cabin_temp")); // 21.5 rounds up
}
// ---------- Fields added for the ABRP lower-priority set ----------
@Test
public void batteryTemperatureIsSentWhenPlausible() throws Exception {
TelemetryPayload t = new TelemetryPayload(1L);
t.battTempC = 24.6f;
assertEquals(25, new JSONObject(t.build()).getInt("batt_temp"));
}
@Test
public void unsupportedBatteryTemperatureIsOmitted() {
// A VHAL without the property answers 0.0; "pack at 0 °C" changes ABRP's plan.
TelemetryPayload t = new TelemetryPayload(1L);
t.battTempC = 0f;
assertFalse(t.build().contains("batt_temp"));
}
@Test
public void capacityIsSentInKwhWithTwoDecimals() throws Exception {
TelemetryPayload t = new TelemetryPayload(1L);
t.capacityKwh = 61.734f;
assertEquals(61.73, new JSONObject(t.build()).getDouble("capacity"), 0.001);
}
@Test
public void zeroCapacityIsOmitted() {
TelemetryPayload t = new TelemetryPayload(1L);
t.capacityKwh = 0f;
assertFalse(t.build().contains("capacity"));
}
@Test
public void odometerIsRoundedToWholeKm() throws Exception {
TelemetryPayload t = new TelemetryPayload(1L);
t.odometerKm = 24518.7f;
assertEquals(24519, new JSONObject(t.build()).getInt("odometer"));
}
@Test
public void zeroOdometerIsOmitted() {
TelemetryPayload t = new TelemetryPayload(1L);
t.odometerKm = 0f;
assertFalse(t.build().contains("odometer"));
}
@Test
public void hvacSetpointIsSentWhenSet() throws Exception {
TelemetryPayload t = new TelemetryPayload(1L);
t.hvacSetpointC = 21f;
assertEquals(21, new JSONObject(t.build()).getInt("hvac_setpoint"));
}
@Test
public void unsupportedHvacSetpointIsOmitted() {
TelemetryPayload t = new TelemetryPayload(1L);
t.hvacSetpointC = 0f;
assertFalse(t.build().contains("hvac_setpoint"));
}
@Test
public void stateOfEnergyIsSentInKwh() throws Exception {
TelemetryPayload t = fullCar();
t.soeKwh = 41.256f;
assertEquals(41.26, new JSONObject(t.build()).getDouble("soe"), 0.001);
}
@Test
public void unsupportedStateOfEnergyIsOmitted() {
TelemetryPayload t = fullCar();
t.soeKwh = 0f;
assertFalse(t.build().contains("soe"));
}
@Test
public void chargedEnergyIsSentIncludingZeroAtSessionStart() throws Exception {
TelemetryPayload started = fullCar();
started.kwhCharged = 0.0;
assertEquals(0.0, new JSONObject(started.build()).getDouble("kwh_charged"), 0.001);
TelemetryPayload running = fullCar();
running.kwhCharged = 12.34;
assertEquals(12.34, new JSONObject(running.build()).getDouble("kwh_charged"), 0.001);
}
@Test
public void chargedEnergyIsOmittedOutsideACharge() {
assertFalse(fullCar().build().contains("kwh_charged"));
}
@Test
public void tyrePressuresAreSentPerWheelInKpa() throws Exception {
TelemetryPayload t = new TelemetryPayload(1L);
t.tirePressureFlKpa = 240f;
t.tirePressureFrKpa = 241.4f;
t.tirePressureRlKpa = 250.6f;
t.tirePressureRrKpa = 249f;
JSONObject json = new JSONObject(t.build());
assertEquals(240, json.getInt("tire_pressure_fl"));
assertEquals(241, json.getInt("tire_pressure_fr"));
assertEquals(251, json.getInt("tire_pressure_rl"));
assertEquals(249, json.getInt("tire_pressure_rr"));
}
@Test
public void aWheelWithoutASensorIsOmittedWithoutHidingTheOthers() throws Exception {
// 0 kPa is not a flat tyre, it is a missing sensor: a flat still reads well above 0.
TelemetryPayload t = new TelemetryPayload(1L);
t.tirePressureFlKpa = 0f;
t.tirePressureFrKpa = 240f;
JSONObject json = new JSONObject(t.build());
assertFalse(json.has("tire_pressure_fl"));
assertEquals(240, json.getInt("tire_pressure_fr"));
}
// ---------- Location ----------
@Test
public void locationIsIndependentOfTheCarLink() throws Exception {
// No car data at all, but a GPS fix: ABRP should still see the vehicle online.
TelemetryPayload t = new TelemetryPayload(1L);
t.lat = 48.85;
t.lon = 2.35;
JSONObject json = new JSONObject(t.build());
assertEquals(48.85, json.getDouble("lat"), 0.0001);
assertEquals(2.35, json.getDouble("lon"), 0.0001);
assertFalse(json.has("soc"));
}
@Test
public void missingLocationOmitsAllLocationFields() {
String json = fullCar().build();
assertFalse(json.contains("lat"));
assertFalse(json.contains("lon"));
assertFalse(json.contains("elevation"));
assertFalse(json.contains("heading"));
}
@Test
public void elevationAndHeadingAreOptional() throws Exception {
TelemetryPayload flat = new TelemetryPayload(1L);
flat.lat = 48.85;
flat.lon = 2.35;
JSONObject flatJson = new JSONObject(flat.build());
assertFalse(flatJson.has("elevation"));
assertFalse(flatJson.has("heading"));
TelemetryPayload full = new TelemetryPayload(1L);
full.lat = 48.85;
full.lon = 2.35;
full.elevation = 35.4;
full.heading = 271.6f;
JSONObject fullJson = new JSONObject(full.build());
assertEquals(35, fullJson.getInt("elevation"));
assertEquals(272, fullJson.getInt("heading"));
}
@Test
public void booleanFlagsAreEmittedAsIntegers() throws Exception {
TelemetryPayload t = new TelemetryPayload(1L);
t.soc = 50;
t.powerKw = 62f;
t.charging = true;
t.dcfc = true;
t.parked = true;
JSONObject json = new JSONObject(t.build());
assertEquals(1, json.getInt("is_charging"));
assertEquals(1, json.getInt("is_dcfc"));
assertEquals(1, json.getInt("is_parked"));
}
@Test
public void thePayloadIsValidJson() throws Exception {
// The hand-built string could not guarantee this; JSONObject can.
new JSONObject(fullCar().build());
new JSONObject(noCar());
}
@Test
public void unimplementedOutsideTempIsOmittedNotSentAsZero() {
// A VHAL without ENV_OUTSIDE_TEMPERATURE answers 0.0. Sent as ext_temp:0 it is a
// freezing day as far as ABRP's consumption model is concerned.
TelemetryPayload t = fullCar();
t.extTemp = 0f;
assertFalse(t.build().contains("ext_temp"));
}
@Test
public void impossibleOutsideTempIsOmitted() {
TelemetryPayload t = fullCar();
t.extTemp = -273f;
assertFalse(t.build().contains("ext_temp"));
}
@Test
public void summaryListsSentValuesAndOmittedFields() {
String summary = TelemetryPayload.summarize(fullCar().build());
assertTrue(summary, summary.contains("soc=63"));
assertTrue(summary, summary.contains("ext_temp=12"));
assertTrue(summary, summary.contains("cabin_temp=22"));
// Nothing gave us a position, so lat/lon must show up as omitted, not as 0.
assertFalse(summary, summary.contains("lat="));
assertTrue(summary, summary.contains("| omitted: "));
assertTrue(summary, summary.substring(summary.indexOf("| omitted: ")).contains("lat"));
}
@Test
public void summaryOfAnEmptyPayloadReportsEverythingOmitted() {
String summary = TelemetryPayload.summarize(noCar());
assertTrue(summary, summary.startsWith("utc=1700000000"));
assertTrue(summary, summary.contains("| omitted: soc,speed,"));
}
}
@@ -0,0 +1,158 @@
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;
/**
* Cadence policy. The head unit is awake for as long as the car is on, so every avoidable
* upload and every avoidable GPS fix costs power — but nothing may be lost while driving,
* charging, or running low on battery.
*/
public class UploadCadenceTest {
private static final long T0 = 1_700_000_000_000L;
private final UploadSettings defaults = UploadSettings.defaults();
private UploadSettings settings(int intervalSec, boolean boost, int lowSoc) {
return new UploadSettings(intervalSec, boost, lowSoc);
}
// ---- Basic cadence ----
@Test
public void theFirstUploadIsImmediate() {
assertTrue(UploadCadence.shouldUpload(T0, 0L, true, false, 80, false, defaults));
}
@Test
public void drivingUsesTheConfiguredInterval() {
UploadSettings s = settings(60, false, 20);
assertFalse(UploadCadence.shouldUpload(T0 + 59_000, T0, false, false, 80, false, s));
assertTrue(UploadCadence.shouldUpload(T0 + 60_000, T0, false, false, 80, false, s));
}
@Test
public void theDefaultIsOneMinute() {
assertEquals(60, UploadSettings.DEFAULT_INTERVAL_SEC);
assertEquals(60_000L, UploadCadence.intervalFor(false, false, 80, defaults));
}
@Test
public void chargingKeepsTheActiveIntervalEvenWhenParked() {
// Charging at a stop is exactly when ABRP needs a tight SOC curve.
assertEquals(defaults.intervalMs(),
UploadCadence.intervalFor(true, true, 50, defaults));
}
@Test
public void parkedAndIdleBacksOffToFifteenMinutes() {
assertEquals(900_000L, UploadCadence.intervalFor(true, false, 50, defaults));
assertFalse(UploadCadence.shouldUpload(T0 + 899_000, T0, true, false, 50, false, defaults));
assertTrue(UploadCadence.shouldUpload(T0 + 900_000, T0, true, false, 50, false, defaults));
}
@Test
public void aStateChangeUploadsImmediately() {
// Just parked, plugged in or unplugged: ABRP must not wait for the slow interval.
assertTrue(UploadCadence.shouldUpload(T0 + 1_000, T0, true, false, 50, true, defaults));
}
@Test
public void unknownStateCountsAsActive() {
// An unreadable gear or charge port does not prove the car is idle.
assertEquals(defaults.intervalMs(), UploadCadence.intervalFor(null, null, 80, defaults));
assertEquals(defaults.intervalMs(), UploadCadence.intervalFor(true, null, 80, defaults));
assertEquals(defaults.intervalMs(), UploadCadence.intervalFor(null, false, 80, defaults));
}
// ---- Low-SOC boost ----
@Test
public void lowSocBoostsTheCadence() {
UploadSettings s = settings(300, true, 20);
assertEquals(15_000L, UploadCadence.intervalFor(false, false, 19, s));
assertEquals(15_000L, UploadCadence.intervalFor(false, false, 20, s)); // inclusive
}
@Test
public void aboveTheThresholdThereIsNoBoost() {
UploadSettings s = settings(300, true, 20);
assertEquals(300_000L, UploadCadence.intervalFor(false, false, 21, s));
}
@Test
public void theThresholdIsConfigurable() {
UploadSettings s = settings(300, true, 35);
assertEquals(15_000L, UploadCadence.intervalFor(false, false, 30, s));
assertEquals(300_000L, UploadCadence.intervalFor(false, false, 40, s));
}
@Test
public void theBoostCanBeDisabled() {
UploadSettings s = settings(300, false, 20);
assertEquals(300_000L, UploadCadence.intervalFor(false, false, 5, s));
}
@Test
public void theBoostNeverSlowsAFasterConfiguredInterval() {
// Configured 15 s with the boost on must not become slower at low SOC.
UploadSettings s = settings(15, true, 20);
assertEquals(15_000L, UploadCadence.intervalFor(false, false, 10, s));
}
@Test
public void anUnreadableSocDoesNotBoost() {
// No reading is not a low reading; boosting on unknown would poll fast forever.
UploadSettings s = settings(300, true, 20);
assertEquals(300_000L, UploadCadence.intervalFor(false, false, null, s));
}
@Test
public void aParkedCarDoesNotBoostEvenAtLowSoc() {
// Parked at 5 % and unplugged: still parked, still nothing to report.
UploadSettings s = settings(60, true, 20);
assertEquals(900_000L, UploadCadence.intervalFor(true, false, 5, s));
}
// ---- Measured effect ----
@Test
public void requestCountOverAnHourMatchesThePolicy() {
// Counts exclude the baseline first upload, hence one short of the round number.
assertEquals("parked and idle: one upload every 15 min",
3, countUploadsOverAnHour(true, false, 50, defaults));
assertEquals("driving at the default 60 s",
59, countUploadsOverAnHour(false, false, 50, defaults));
assertEquals("driving at low SOC: boosted to 15 s",
239, countUploadsOverAnHour(false, false, 10, defaults));
// The point of the exercise: the old fixed 15 s cadence sent 239 uploads an hour
// in every state, including parked overnight.
assertTrue(countUploadsOverAnHour(true, false, 50, defaults)
< countUploadsOverAnHour(false, false, 50, defaults));
}
/** Simulates one hour of 15 s scheduler ticks with no state change. */
private long countUploadsOverAnHour(Boolean parked, Boolean charging,
Integer soc, UploadSettings settings) {
long uploads = 0;
long lastUpload = 0L;
for (long tick = 0; tick < 3_600_000L; tick += 15_000L) {
long now = T0 + tick;
if (lastUpload == 0L) { // ignore the immediate first upload
lastUpload = now;
continue;
}
if (UploadCadence.shouldUpload(now, lastUpload, parked, charging, soc,
false, settings)) {
uploads++;
lastUpload = now;
}
}
return uploads;
}
}
@@ -0,0 +1,164 @@
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.Before;
import org.junit.Test;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
/**
* Upload history and the service state derived from it. A single failed upload is normal
* in a car (tunnel, dead spot); three in a row means something is actually wrong.
*/
public class UploadLogTest {
private UploadLog log;
@Before
public void setUp() {
log = new UploadLog();
}
private UploadLog.Entry ok() {
return new UploadLog.Entry(1L, 200, true, "OK");
}
private UploadLog.Entry fail(int status, String detail) {
return new UploadLog.Entry(1L, status, false, detail);
}
// ---- Ring buffer ----
@Test
public void keepsOnlyTheLastTwentyCalls() {
for (int i = 0; i < 25; i++) {
log.record(new UploadLog.Entry(i, 200, true, "call" + i));
}
List<UploadLog.Entry> recent = log.recent();
assertEquals(UploadLog.MAX_ENTRIES, recent.size());
// Most recent first.
assertEquals("call24", recent.get(0).detail);
assertEquals("call5", recent.get(recent.size() - 1).detail);
}
@Test
public void anEmptyLogHasNoLatestEntry() {
assertNull(log.latest());
assertTrue(log.recent().isEmpty());
}
@Test
public void recordsTheHttpStatusAndDetail() {
log.record(fail(401, "HTTP 401"));
UploadLog.Entry latest = log.latest();
assertEquals(401, latest.httpStatus);
assertEquals("HTTP 401", latest.detail);
assertEquals(false, latest.success);
}
@Test
public void aRequestThatNeverReachedAServerHasNoStatus() {
// Status 0 is how the UI knows to show "---" rather than a bogus code.
log.record(fail(0, "No internet"));
assertEquals(0, log.latest().httpStatus);
}
// ---- Error state ----
@Test
public void stoppedWhenTheServiceIsNotRunning() {
log.record(ok());
assertEquals(UploadLog.State.STOPPED, log.state(false));
}
@Test
public void startingWhileRunningWithNoAttemptYet() {
assertEquals(UploadLog.State.STARTING, log.state(true));
}
@Test
public void runningAfterASuccess() {
log.record(ok());
assertEquals(UploadLog.State.RUNNING, log.state(true));
}
@Test
public void oneOrTwoFailuresAreNotAnError() {
// A tunnel or a dead spot must not alarm the driver.
log.record(fail(0, "No internet"));
assertEquals(UploadLog.State.RUNNING, log.state(true));
log.record(fail(0, "No internet"));
assertEquals(UploadLog.State.RUNNING, log.state(true));
}
@Test
public void threeConsecutiveFailuresAreAnError() {
for (int i = 0; i < 3; i++) log.record(fail(500, "HTTP 500"));
assertEquals(UploadLog.State.ERROR, log.state(true));
assertEquals(3, log.consecutiveFailures());
}
@Test
public void aSuccessClearsTheErrorState() {
for (int i = 0; i < 5; i++) log.record(fail(500, "HTTP 500"));
assertEquals(UploadLog.State.ERROR, log.state(true));
log.record(ok());
assertEquals(UploadLog.State.RUNNING, log.state(true));
assertEquals(0, log.consecutiveFailures());
}
@Test
public void failuresMustBeConsecutiveToCount() {
log.record(fail(500, "a"));
log.record(fail(500, "b"));
log.record(ok());
log.record(fail(500, "c"));
// 2 failures, then a success, then 1: not three in a row.
assertEquals(1, log.consecutiveFailures());
assertEquals(UploadLog.State.RUNNING, log.state(true));
}
// ---- Threading ----
@Test
public void concurrentWritesAndReadsStayConsistent() throws Exception {
// Written from the scheduler thread, read from the UI thread.
int threads = 6;
CountDownLatch start = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(threads);
for (int t = 0; t < threads; t++) {
new Thread(() -> {
try {
start.await();
for (int i = 0; i < 200; i++) {
log.record(ok());
log.recent();
log.state(true);
}
} catch (InterruptedException ignored) {
} finally {
done.countDown();
}
}).start();
}
start.countDown();
assertTrue(done.await(30, TimeUnit.SECONDS));
assertEquals(UploadLog.MAX_ENTRIES, log.recent().size());
}
@Test
public void entryCarriesThePayloadSummaryAndDefaultsItToNull() {
UploadLog log = new UploadLog();
log.record(new UploadLog.Entry(1L, 200, true, "OK", "soc=63 | omitted: lat,lon"));
log.record(new UploadLog.Entry(2L, 0, false, "No internet"));
java.util.List<UploadLog.Entry> recent = log.recent();
assertNull(recent.get(0).summary);
assertEquals("soc=63 | omitted: lat,lon", recent.get(1).summary);
}
}