mise.toml, the comments in app/build.gradle (corrected where they no longer described the build: the car build is signed by build-car, and the updater reads the version from build-car's file name), OTA.md's message table (now the updater's current English messages), the Turkish messages quoted in CHANGELOG.md and OTA.md (glossed in English), two test assertion messages, an icon comment, and the ignition-state comment in EVHardware.kt. The vendored EVHardware library still has about 120 half-French comment lines; noted in SPECS.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
356 lines
13 KiB
Java
356 lines
13 KiB
Java
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.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", "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("field " + field + " should have been omitted", 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("an unreadable soc should have been omitted", 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(-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 summaryListsSentValuesAndOmittedFields() {
|
|
String summary = TelemetryPayload.summarize(fullCar().build());
|
|
assertTrue(summary, summary.contains("soc=63"));
|
|
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,"));
|
|
}
|
|
|
|
// ---------- The server's ranges (server/model.go) ----------
|
|
|
|
@Test
|
|
public void reversingSendsSpeedAsAMagnitude() throws Exception {
|
|
TelemetryPayload t = new TelemetryPayload(1_700_000_000L);
|
|
t.speedKmh = -4.2f;
|
|
assertEquals(4, new JSONObject(t.build()).getInt("speed"));
|
|
}
|
|
|
|
@Test
|
|
public void valuesOutsideTheServersRangeAreLeftOut() throws Exception {
|
|
TelemetryPayload t = new TelemetryPayload(1_700_000_000L);
|
|
t.soc = 55;
|
|
t.extTempC = 72f; // plausible to the car's guard, above the server's 70
|
|
t.hvacSetpointC = 50f; // a HI sentinel, above the server's 40
|
|
t.rangeKm = 2500;
|
|
JSONObject json = new JSONObject(t.build());
|
|
assertEquals(55, json.getInt("soc"));
|
|
assertFalse(json.has("ext_temp"));
|
|
assertFalse(json.has("hvac_setpoint"));
|
|
assertFalse(json.has("est_battery_range"));
|
|
}
|
|
|
|
@Test
|
|
public void theAppsLimitsMatchEveryNumericServerField() {
|
|
String[] serverFields = {"soc", "speed", "power", "est_battery_range", "capacity", "soe",
|
|
"kwh_charged", "odometer", "lat", "lon", "elevation", "heading", "batt_temp",
|
|
"cabin_temp", "ext_temp", "hvac_setpoint", "voltage", "current",
|
|
"tire_pressure_fl", "tire_pressure_fr", "tire_pressure_rl", "tire_pressure_rr"};
|
|
for (String field : serverFields) {
|
|
assertTrue(field, TelemetryPayload.SERVER_LIMITS.containsKey(field));
|
|
}
|
|
}
|
|
|
|
@Test
|
|
public void aClockBefore2001IsNotATime() {
|
|
assertFalse(TelemetryPayload.isPlausibleUtc(0L));
|
|
assertFalse(TelemetryPayload.isPlausibleUtc(999_999_999L));
|
|
assertTrue(TelemetryPayload.isPlausibleUtc(1_790_000_000L));
|
|
}
|
|
}
|