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 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 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); } }