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,123 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.OutputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* ABRP telemetry endpoint.
|
||||
*
|
||||
* Credentials go in the POST body, never in the query string: a URL travels
|
||||
* through proxies, CDN access logs and crash reports, a body does not.
|
||||
*/
|
||||
final class AbrpApi {
|
||||
|
||||
/** No query string: credentials travel in the body, so the URL is a constant. */
|
||||
static final String API_URL = "https://api.iternio.com/1/tlm/send";
|
||||
|
||||
/**
|
||||
* Read-only endpoint used to validate credentials. The connection test must never
|
||||
* touch /tlm/send: posting a placeholder sample tells ABRP the car really is in that
|
||||
* state and corrupts the user's live route plan.
|
||||
*/
|
||||
static final String VERIFY_URL = "https://api.iternio.com/1/tlm/get_next_charge";
|
||||
private static final int TIMEOUT_MS = 8_000;
|
||||
|
||||
private AbrpApi() { }
|
||||
|
||||
/** HTTP status plus response body. */
|
||||
static final class Response {
|
||||
final int code;
|
||||
final String body;
|
||||
|
||||
Response(int code, String body) {
|
||||
this.code = code;
|
||||
this.body = body;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates credentials without writing anything. Read-only by construction: it is a
|
||||
* GET against {@link #VERIFY_URL}, so no telemetry is recorded whatever the outcome.
|
||||
*
|
||||
* Tension with T-912 (no credential in a URL): this endpoint only accepts the token as
|
||||
* a query parameter. The api_key is moved to the Authorization header, which is the
|
||||
* part we can keep out of access logs; the token cannot be, short of dropping the
|
||||
* read-only check and going back to writing fake telemetry. Sending one token in one
|
||||
* URL when the user presses Test beats corrupting their route plan on every press.
|
||||
*/
|
||||
static Response verifyCredentials(String apiKey, String token) throws IOException {
|
||||
HttpURLConnection conn = null;
|
||||
try {
|
||||
String url = VERIFY_URL
|
||||
+ "?token=" + URLEncoder.encode(token, StandardCharsets.UTF_8.name());
|
||||
conn = (HttpURLConnection) new URL(url).openConnection();
|
||||
conn.setRequestMethod("GET");
|
||||
conn.setRequestProperty("Authorization", "APIKEY " + apiKey);
|
||||
conn.setConnectTimeout(TIMEOUT_MS);
|
||||
conn.setReadTimeout(TIMEOUT_MS);
|
||||
int code = conn.getResponseCode();
|
||||
return new Response(code, readBody(conn, code));
|
||||
} finally {
|
||||
if (conn != null) conn.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/** Posts one telemetry sample. Throws on network failure. */
|
||||
static Response send(String apiKey, String token, String tlmJson) throws IOException {
|
||||
HttpURLConnection conn = null;
|
||||
try {
|
||||
conn = (HttpURLConnection) new URL(API_URL).openConnection();
|
||||
conn.setRequestMethod("POST");
|
||||
conn.setConnectTimeout(TIMEOUT_MS);
|
||||
conn.setReadTimeout(TIMEOUT_MS);
|
||||
conn.setDoOutput(true);
|
||||
conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded");
|
||||
|
||||
byte[] payload = formBody(apiKey, token, tlmJson).getBytes(StandardCharsets.UTF_8);
|
||||
conn.setFixedLengthStreamingMode(payload.length);
|
||||
try (OutputStream out = conn.getOutputStream()) {
|
||||
out.write(payload);
|
||||
}
|
||||
|
||||
int code = conn.getResponseCode();
|
||||
return new Response(code, readBody(conn, code));
|
||||
} finally {
|
||||
if (conn != null) conn.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/** Form-encoded request body carrying the credentials and the telemetry sample. */
|
||||
static String formBody(String apiKey, String token, String tlmJson) throws IOException {
|
||||
return "api_key=" + URLEncoder.encode(apiKey, StandardCharsets.UTF_8.name())
|
||||
+ "&token=" + URLEncoder.encode(token, StandardCharsets.UTF_8.name())
|
||||
+ "&tlm=" + URLEncoder.encode(tlmJson, StandardCharsets.UTF_8.name());
|
||||
}
|
||||
|
||||
private static String readBody(HttpURLConnection conn, int code) {
|
||||
// getErrorStream() returns null on some non-200 responses; passing null to
|
||||
// InputStreamReader throws. Checked rather than left to the catch-all below.
|
||||
java.io.InputStream stream;
|
||||
try {
|
||||
stream = code == 200 ? conn.getInputStream() : conn.getErrorStream();
|
||||
} catch (Exception e) {
|
||||
return "";
|
||||
}
|
||||
if (stream == null) return "";
|
||||
|
||||
try (BufferedReader br = new BufferedReader(
|
||||
new InputStreamReader(stream, StandardCharsets.UTF_8))) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
String line;
|
||||
while ((line = br.readLine()) != null) sb.append(line);
|
||||
return sb.toString();
|
||||
} catch (Exception e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,762 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import android.app.Notification;
|
||||
import android.app.NotificationChannel;
|
||||
import android.app.NotificationManager;
|
||||
import android.app.PendingIntent;
|
||||
import android.app.Service;
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
import android.content.SharedPreferences;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.content.pm.ServiceInfo;
|
||||
import android.location.Location;
|
||||
import android.location.LocationListener;
|
||||
import android.location.LocationManager;
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import android.os.IBinder;
|
||||
import android.os.Looper;
|
||||
import android.os.SystemClock;
|
||||
import android.util.Log;
|
||||
|
||||
import android.Manifest;
|
||||
|
||||
import androidx.core.app.NotificationCompat;
|
||||
import androidx.core.app.ServiceCompat;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import com.evsuite.hardware.saic.SaicWeather;
|
||||
import com.evsuite.hardware.telemetry.EnergySnapshot;
|
||||
import com.evsuite.hardware.telemetry.EnergyTelemetryReader;
|
||||
import com.evsuite.hardware.telemetry.ChargingSessionMeter;
|
||||
import com.evsuite.hardware.telemetry.TirePressureSnapshot;
|
||||
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Foreground service that polls vehicle telemetry every 15 s and pushes it to ABRP.
|
||||
*
|
||||
* Reliability design:
|
||||
* - Uses ScheduledExecutorService (not Handler chains) so exceptions inside one
|
||||
* upload cycle cannot break the schedule. Even on a thrown RuntimeException
|
||||
* the next tick still fires.
|
||||
* - Uploads unconditionally: if vehicle telemetry is unavailable we still emit
|
||||
* GPS + timestamp so ABRP at least sees the vehicle online.
|
||||
* - EVHardware owns the vehicle connection, firmware routing and nullable reads.
|
||||
* - All IPC + HTTP happens on the scheduler thread, never the main thread.
|
||||
*/
|
||||
public class AbrpUploadService extends Service {
|
||||
|
||||
public static final String ACTION_STOP = "com.evsuite.abrp.STOP";
|
||||
|
||||
/**
|
||||
* Live in-process signal. The "service_running" preference cannot be trusted on its
|
||||
* own: a force-kill (adb install -r, low-memory kill) skips onDestroy and leaves it
|
||||
* stale-true forever. This field dies with the process, so it is only ever true while
|
||||
* the service really is up.
|
||||
*/
|
||||
private static volatile boolean running = false;
|
||||
|
||||
public static boolean isRunning() { return running; }
|
||||
|
||||
/**
|
||||
* Upload history, shared with the UI. Static because the activity reads it while the
|
||||
* service owns it; it lives and dies with the process, like {@link #running}.
|
||||
*/
|
||||
private static final UploadLog uploadLog = new UploadLog();
|
||||
|
||||
public static UploadLog log() { return uploadLog; }
|
||||
|
||||
public static UploadLog.State state() { return uploadLog.state(running); }
|
||||
|
||||
/** Re-read after the user changes the cadence, without restarting the service. */
|
||||
public static void reloadSettings() { settingsDirty = true; }
|
||||
|
||||
private static volatile boolean settingsDirty = false;
|
||||
|
||||
private static final String TAG = "AbrpUploadService";
|
||||
private static final String CHANNEL_ID = "abrp_uploader";
|
||||
private static final int NOTIF_ID = 1;
|
||||
/** Scheduler tick. Whether a tick uploads is decided by {@link UploadCadence}. */
|
||||
private static final long UPLOAD_INTERVAL_SEC = 15;
|
||||
/**
|
||||
* Warm-up before the first upload. Was 45 s with no stated reason; the car adapter
|
||||
* connects asynchronously and telemetry now omits whatever it cannot read (T-913), so
|
||||
* an early first sample is useful rather than misleading.
|
||||
*/
|
||||
private static final long FIRST_UPLOAD_DELAY_SEC = 20;
|
||||
private static final long LOCATION_RETRY_INTERVAL_SEC = 30;
|
||||
/**
|
||||
* Floor for how old a GPS fix may be and still describe where the car is now.
|
||||
*
|
||||
* A fix has no expiry of its own, so the previous behaviour was to keep reporting the
|
||||
* last one forever: the position saved when the car was parked at home was uploaded,
|
||||
* stamped with the current time, for the whole first part of the next drive. ABRP then
|
||||
* plans from a place the car left minutes ago. The subscription asks for a fix every
|
||||
* upload interval, so anything older than several intervals means the receiver has
|
||||
* stopped producing (garage, tunnel) and position is genuinely unknown.
|
||||
*/
|
||||
private static final long MIN_LOCATION_MAX_AGE_MS = 5 * 60 * 1000L;
|
||||
/**
|
||||
* How long a weather reading stands in for the car's own thermometer.
|
||||
*
|
||||
* The query can reach the network from inside the head unit and blocks the upload thread
|
||||
* while it does, so it is not something to do every tick. Outside air does not move fast
|
||||
* enough for ten minutes to matter to a consumption model.
|
||||
*/
|
||||
private static final long WEATHER_TTL_MS = 10 * 60 * 1000L;
|
||||
/**
|
||||
* How long a broadcast temperature keeps standing in for the car's thermometer.
|
||||
*
|
||||
* The weather app broadcasts when it refreshes itself, not on a cadence this app can ask
|
||||
* for, so a reading has to outlive the moment it arrived or it would be useless between
|
||||
* refreshes. Two hours is long enough to bridge them and short enough that the value has
|
||||
* not stopped describing the day.
|
||||
*/
|
||||
private static final long WEATHER_MAX_AGE_MS = 2 * 60 * 60 * 1000L;
|
||||
private static final String KEY_WEATHER_TEMP = "weather_temp_c";
|
||||
private static final String KEY_WEATHER_PLACE = "weather_place";
|
||||
private static final String KEY_WEATHER_MS = "weather_received_ms";
|
||||
|
||||
// Set from the main thread (LocationListener uses Main looper), read from
|
||||
// the scheduler thread inside doUpload. volatile is sufficient since Location
|
||||
// is effectively immutable for our purposes.
|
||||
private volatile Location lastLocation;
|
||||
|
||||
private EnergyTelemetryReader energyReader;
|
||||
/**
|
||||
* Keeps the unit on the phone hotspot without a trip into Settings. Attempted on the
|
||||
* upload tick because that is exactly when being offline costs something, and it is
|
||||
* rate-limited internally so a tick every few seconds does not hammer the supplicant.
|
||||
*/
|
||||
private WifiAutoConnect wifi;
|
||||
private LocationManager locationManager;
|
||||
private ScheduledExecutorService scheduler;
|
||||
private SharedPreferences prefs;
|
||||
/** Credentials only — encrypted at rest, see {@link SecurePrefs}. */
|
||||
private SharedPreferences securePrefs;
|
||||
private Handler mainHandler;
|
||||
|
||||
private volatile long lastSuccessfulUploadMs = 0L;
|
||||
/** Previous tick's state, to detect a transition worth reporting immediately. */
|
||||
private volatile Boolean lastParked = null;
|
||||
private volatile Boolean lastCharging = null;
|
||||
private volatile long lastUploadAttemptMs = 0L;
|
||||
/** Charge detection state. Touched only from the scheduler thread, inside doUpload. */
|
||||
private final ChargingSignal chargingSignal = new ChargingSignal();
|
||||
private final ChargingSessionMeter chargeMeter = new ChargingSessionMeter();
|
||||
private volatile UploadSettings settings = UploadSettings.defaults();
|
||||
/** False while no GPS subscription is delivering — drives the watchdog re-arm. */
|
||||
/** What the foreground notification currently says, so re-asserting it does not blank it. */
|
||||
private volatile String lastStatus = "Starting…";
|
||||
private volatile boolean locationUpdatesActive = false;
|
||||
private volatile long lastLocationRequestMs = 0L;
|
||||
/** Last head-unit weather reading and when it was taken — see {@link #WEATHER_TTL_MS}. */
|
||||
private volatile SaicWeather.Reading lastWeather = null;
|
||||
private volatile long lastWeatherMs = 0L;
|
||||
/** The head unit's own weather broadcast — the source its status bar uses. */
|
||||
private final HeadUnitWeather headUnitWeather = new HeadUnitWeather();
|
||||
private final BroadcastReceiver weatherReceiver = new BroadcastReceiver() {
|
||||
@Override public void onReceive(Context context, Intent intent) {
|
||||
headUnitWeather.accept(intent);
|
||||
saveWeather();
|
||||
}
|
||||
};
|
||||
private volatile boolean weatherReceiverRegistered = false;
|
||||
/** How long the last map-service query took, or -1 when it was never made. */
|
||||
private volatile long lastQueryMs = -1L;
|
||||
|
||||
// ---------- Lifecycle ----------
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
prefs = getSharedPreferences("abrp_prefs", MODE_PRIVATE);
|
||||
securePrefs = SecurePrefs.get(this);
|
||||
settings = UploadSettings.from(prefs);
|
||||
uploadLog.clear();
|
||||
mainHandler = new Handler(Looper.getMainLooper());
|
||||
scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
|
||||
Thread t = new Thread(r, "abrp-upload");
|
||||
t.setDaemon(false);
|
||||
return t;
|
||||
});
|
||||
|
||||
createNotificationChannel();
|
||||
startInForeground();
|
||||
running = true;
|
||||
prefs.edit().putBoolean("service_running", true).apply();
|
||||
|
||||
// One shared, firmware-aware EVHardware snapshot owns every vehicle signal.
|
||||
energyReader = new EnergyTelemetryReader(getApplicationContext());
|
||||
wifi = new WifiAutoConnect(getApplicationContext());
|
||||
// The head unit's own weather service, bound for one reason: ENV_OUTSIDE_TEMPERATURE
|
||||
// is not implemented on this vehicle, so the car cannot say how warm it is outside
|
||||
// and something else has to. Idempotent, asynchronous, and absent on a car that does
|
||||
// not have the map stack — in which case ext_temp simply stays unreported.
|
||||
SaicWeather.INSTANCE.connect(getApplicationContext());
|
||||
registerWeatherReceiver();
|
||||
|
||||
requestLocationUpdates();
|
||||
|
||||
// Fire the first upload after a short warm-up, then every UPLOAD_INTERVAL_SEC.
|
||||
// scheduleWithFixedDelay ensures we always get at least UPLOAD_INTERVAL_SEC
|
||||
// between cycles even if a previous upload was slow.
|
||||
scheduler.scheduleWithFixedDelay(
|
||||
this::safeUploadCycle,
|
||||
FIRST_UPLOAD_DELAY_SEC, UPLOAD_INTERVAL_SEC, TimeUnit.SECONDS);
|
||||
|
||||
Log.i(TAG, "Service started, upload scheduler armed");
|
||||
}
|
||||
|
||||
@Override
|
||||
public int onStartCommand(Intent intent, int flags, int startId) {
|
||||
if (intent != null && ACTION_STOP.equals(intent.getAction())) {
|
||||
prefs.edit().putBoolean("service_enabled", false).apply();
|
||||
stopSelf();
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
// Re-asserted on every start: a position grant that arrives after the service is up
|
||||
// changes which foreground types it may hold, and the activity starts it again as
|
||||
// soon as the user answers. Without this the grant only took effect at the next boot.
|
||||
startInForeground();
|
||||
return START_STICKY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enters the foreground holding only the service types this app currently has the
|
||||
* permissions for.
|
||||
*
|
||||
* The service declares {@code foregroundServiceType="location"}, and since API 34 the
|
||||
* platform validates every declared type against what is held at the moment
|
||||
* startForeground runs — the plain two-argument call claims them all. On a car where
|
||||
* position had not been granted yet (a fresh install, or the boot receiver starting the
|
||||
* service before the app was ever opened) that threw a SecurityException and the uploader
|
||||
* died before its first tick. Without the permission the service still runs and still
|
||||
* uploads; it simply has no position to send, exactly as before.
|
||||
*/
|
||||
private void startInForeground() {
|
||||
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.Q) {
|
||||
// Service types do not exist before API 29: nothing is declared and nothing is
|
||||
// checked, so the plain call is the only correct one there.
|
||||
startForeground(NOTIF_ID, buildNotification(lastStatus));
|
||||
return;
|
||||
}
|
||||
int types = hasLocationPermission()
|
||||
? ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
|
||||
: ServiceInfo.FOREGROUND_SERVICE_TYPE_NONE;
|
||||
ServiceCompat.startForeground(this, NOTIF_ID, buildNotification(lastStatus), types);
|
||||
}
|
||||
|
||||
private boolean hasLocationPermission() {
|
||||
return ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION)
|
||||
== PackageManager.PERMISSION_GRANTED
|
||||
|| ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION)
|
||||
== PackageManager.PERMISSION_GRANTED;
|
||||
}
|
||||
|
||||
@Override
|
||||
public IBinder onBind(Intent intent) { return null; }
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
Log.i(TAG, "Service stopping");
|
||||
if (scheduler != null) scheduler.shutdownNow();
|
||||
if (locationManager != null) {
|
||||
try { locationManager.removeUpdates(locationListener); } catch (Exception ignored) {}
|
||||
}
|
||||
if (weatherReceiverRegistered) {
|
||||
try { unregisterReceiver(weatherReceiver); } catch (Exception ignored) {}
|
||||
weatherReceiverRegistered = false;
|
||||
}
|
||||
if (mainHandler != null) mainHandler.removeCallbacksAndMessages(null);
|
||||
running = false;
|
||||
prefs.edit().putBoolean("service_running", false).apply();
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
// ---------- Upload cycle (runs on scheduler thread) ----------
|
||||
|
||||
private void safeUploadCycle() {
|
||||
try {
|
||||
if (settingsDirty) {
|
||||
settingsDirty = false;
|
||||
settings = UploadSettings.from(prefs);
|
||||
// The GPS subscription is a power cost of its own; keep it in step with
|
||||
// how often we actually send.
|
||||
mainHandler.post(this::requestLocationUpdates);
|
||||
Log.i(TAG, "Settings reloaded: interval=" + settings.intervalSec + "s"
|
||||
+ " boostLowSoc=" + settings.boostLowSoc
|
||||
+ " lowSoc=" + settings.lowSocPercent + "%");
|
||||
}
|
||||
// Same watchdog for GPS: requestLocationUpdates used to run once in onCreate,
|
||||
// so a provider that was off at start-up — or dropped later — meant
|
||||
// position-less telemetry for the rest of the session.
|
||||
if (!locationUpdatesActive
|
||||
&& System.currentTimeMillis() - lastLocationRequestMs
|
||||
> LOCATION_RETRY_INTERVAL_SEC * 1000) {
|
||||
Log.w(TAG, "Location updates inactive, re-arming");
|
||||
mainHandler.post(this::requestLocationUpdates);
|
||||
}
|
||||
// Before the upload, not after: an upload that fails for want of a network is
|
||||
// the thing this is here to prevent. Cheap and rate-limited when already on.
|
||||
if (wifi != null && prefs.getBoolean(MainActivity.WIFI_AUTO_KEY, true)) {
|
||||
wifi.ensureConnected(false);
|
||||
}
|
||||
doUpload();
|
||||
} catch (Throwable t) {
|
||||
// Absolutely never let an exception escape — would not break the
|
||||
// schedule with scheduleWithFixedDelay's executor, but we want a
|
||||
// clean log line anyway.
|
||||
Log.e(TAG, "upload cycle threw", t);
|
||||
}
|
||||
}
|
||||
|
||||
private void doUpload() {
|
||||
String token = securePrefs.getString(SecurePrefs.KEY_TOKEN, "").trim();
|
||||
String apiKey = securePrefs.getString(SecurePrefs.KEY_API_KEY, "").trim();
|
||||
|
||||
if (token.isEmpty()) {
|
||||
updateNotification("No ABRP token — open app to configure");
|
||||
return;
|
||||
}
|
||||
if (apiKey.isEmpty()) {
|
||||
updateNotification("No API key — open app to configure");
|
||||
return;
|
||||
}
|
||||
|
||||
// Read whatever the car will give us. Every getter returns null when the read
|
||||
// fails, and a null field is omitted from the payload — never sent as 0.
|
||||
long sampleMs = System.currentTimeMillis();
|
||||
EnergySnapshot vehicle = energyReader.read(sampleMs);
|
||||
boolean carUp = vehicle.getHasVehicleData();
|
||||
Float speedKmh = vehicle.getSpeedKmh();
|
||||
Integer soc = vehicle.getSocPercent() == null
|
||||
? null : Math.round(vehicle.getSocPercent());
|
||||
Integer rangeKm = vehicle.getRangeKm() == null
|
||||
? null : Math.round(vehicle.getRangeKm());
|
||||
Float extTemp = vehicle.getOutsideTempCelsius();
|
||||
Float powerKw = vehicle.getBatteryPowerKw();
|
||||
Float chargeRateKw = powerKw == null ? null : -powerKw;
|
||||
Boolean portConnected = vehicle.getChargePortConnected();
|
||||
Boolean parked = vehicle.getParked();
|
||||
|
||||
// The charge port property is standard AAOS and this VHAL need not implement it.
|
||||
// Trusting it alone meant a plugged-in car that reads "unplugged" was treated as
|
||||
// parked-and-idle and throttled to one sample every 15 minutes for the whole
|
||||
// charge — see ChargingSignal for the fallbacks.
|
||||
Boolean charging = chargingSignal.evaluate(
|
||||
sampleMs, portConnected, chargeRateKw, parked, soc);
|
||||
|
||||
// DCFC heuristic: charging at AC speeds (3-22 kW) is type-2; above ~25 kW
|
||||
// it can only be DC fast. Undecidable if either input is missing.
|
||||
Boolean dcfc = (charging == null || chargeRateKw == null)
|
||||
? null : (charging && chargeRateKw > 25f);
|
||||
|
||||
Float cabinTemp = vehicle.getCabinTempCelsius();
|
||||
Float battTemp = vehicle.getBatteryTempCelsius();
|
||||
Float capacityKwh = vehicle.getBatteryCapacityKwh();
|
||||
Float soeKwh = vehicle.getBatteryEnergyKwh();
|
||||
Float odometerKm = vehicle.getOdometerKm();
|
||||
Float hvacSetpoint = vehicle.getClimate().getDriverTargetCelsius();
|
||||
TirePressureSnapshot tires = vehicle.getTirePressures();
|
||||
|
||||
// Sampled before the cadence check below: the meter integrates the charge rate
|
||||
// over time, so it has to see every tick, not only the ones that get uploaded.
|
||||
Double kwhCharged = chargeMeter.sample(
|
||||
sampleMs, charging, chargeRateKw);
|
||||
|
||||
// Thin out uploads while the car is parked and not charging. Evaluated here, once
|
||||
// parked/charging are known, so a transition is never delayed.
|
||||
boolean stateChanged = !java.util.Objects.equals(parked, lastParked)
|
||||
|| !java.util.Objects.equals(charging, lastCharging);
|
||||
lastParked = parked;
|
||||
lastCharging = charging;
|
||||
|
||||
// Cadence is measured on ATTEMPTS, not successes: keying it on the last success
|
||||
// would retry every tick while offline, which is exactly when saving power matters.
|
||||
if (!UploadCadence.shouldUpload(System.currentTimeMillis(), lastUploadAttemptMs,
|
||||
parked, charging, soc, stateChanged, settings)) {
|
||||
return;
|
||||
}
|
||||
lastUploadAttemptMs = System.currentTimeMillis();
|
||||
|
||||
Location loc = freshLocation();
|
||||
|
||||
// ext_temp was reaching ABRP as 0 degC, and omitting the unreadable value only turned
|
||||
// that into ABRP's own default 0. The car is the problem: this VHAL does not implement
|
||||
// ENV_OUTSIDE_TEMPERATURE, so there is no vehicle reading to correct. The head unit's
|
||||
// weather service answers for a position, and ambient air at the car is what ext_temp
|
||||
// means — a better figure than a bumper sensor in the sun would give anyway.
|
||||
String tempSource = "car";
|
||||
SaicWeather.Reading sky = null;
|
||||
if (!TelemetryPayload.isPlausibleTemp(extTemp)) {
|
||||
// The broadcast first. It is what fills the head unit's own status bar, so on this
|
||||
// car it is the source known to be producing a number, where the map service's
|
||||
// query never even bound.
|
||||
// The broadcast arrives when the weather app feels like it, so a reading that
|
||||
// never came is retried here rather than only at start-up.
|
||||
if (headUnitWeather.temperatureC() == null) probeStickyWeather();
|
||||
Float broadcast = weatherIsFresh() ? headUnitWeather.temperatureC() : null;
|
||||
if (TelemetryPayload.isPlausibleTemp(broadcast)) {
|
||||
extTemp = broadcast;
|
||||
tempSource = "bcast:" + headUnitWeather.diagnostic();
|
||||
} else {
|
||||
sky = weatherAt(loc);
|
||||
if (sky != null && sky.getTemperatureCelsius() != null) {
|
||||
extTemp = sky.getTemperatureCelsius().floatValue();
|
||||
tempSource = "map:" + sky.getCity();
|
||||
} else {
|
||||
// Neither answered. Which one failed, and how, is the difference between
|
||||
// "the broadcast never arrives" and "it arrives in a shape this cannot
|
||||
// read" — and nothing on the ABRP side tells them apart.
|
||||
tempSource = "none bcast=" + headUnitWeather.diagnostic()
|
||||
+ " map=" + (SaicWeather.INSTANCE.isAvailable() ? "bound" : "unbound")
|
||||
+ (lastQueryMs < 0 ? "" : "/" + lastQueryMs + "ms");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TelemetryPayload tlm = new TelemetryPayload(System.currentTimeMillis() / 1000);
|
||||
tlm.soc = soc;
|
||||
tlm.speedKmh = speedKmh;
|
||||
tlm.rangeKm = rangeKm;
|
||||
tlm.extTemp = extTemp;
|
||||
tlm.powerKw = powerKw;
|
||||
tlm.charging = charging;
|
||||
tlm.dcfc = dcfc;
|
||||
tlm.parked = parked;
|
||||
tlm.cabinTemp = cabinTemp;
|
||||
tlm.battTempC = battTemp;
|
||||
tlm.capacityKwh = capacityKwh;
|
||||
tlm.soeKwh = soeKwh;
|
||||
tlm.kwhCharged = kwhCharged;
|
||||
tlm.odometerKm = odometerKm;
|
||||
tlm.hvacSetpointC = hvacSetpoint;
|
||||
tlm.tirePressureFlKpa = tires.getFrontLeftKpa();
|
||||
tlm.tirePressureFrKpa = tires.getFrontRightKpa();
|
||||
tlm.tirePressureRlKpa = tires.getRearLeftKpa();
|
||||
tlm.tirePressureRrKpa = tires.getRearRightKpa();
|
||||
if (loc != null) {
|
||||
tlm.lat = loc.getLatitude();
|
||||
tlm.lon = loc.getLongitude();
|
||||
tlm.elevation = loc.hasAltitude() ? loc.getAltitude() : null;
|
||||
tlm.heading = loc.hasBearing() ? loc.getBearing() : null;
|
||||
}
|
||||
|
||||
String tlmJson = tlm.build();
|
||||
String summary = TelemetryPayload.summarize(tlmJson) + " [temp " + tempSource + "]";
|
||||
if (loc != null) {
|
||||
summary += " (fix " + locationAgeMs(loc) / 1000 + "s old"
|
||||
+ " +/-" + (loc.hasAccuracy() ? Math.round(loc.getAccuracy()) + "m" : "?")
|
||||
+ " via " + loc.getProvider();
|
||||
// The weather service names the place it answered for. ABRP shows an address it
|
||||
// reverse-geocodes from the same coordinates, so the two disagreeing says the
|
||||
// coordinates are wrong, and the two agreeing says ABRP is showing something old.
|
||||
if (sky != null && !sky.getCity().isEmpty()) summary += ", near " + sky.getCity();
|
||||
summary += ")";
|
||||
}
|
||||
// What actually went on the wire. Kept on the log entry as well as in logcat: the
|
||||
// head unit this runs on has no adb attached, so the in-app log is the only place
|
||||
// the driver can see it.
|
||||
Log.i(TAG, "TLM " + summary);
|
||||
|
||||
sendToAbrp(apiKey, token, tlmJson, summary, soc, speedKmh, carUp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Listens for the head unit's weather updates.
|
||||
*
|
||||
* Exported on purpose: the broadcast comes from another application, and a receiver
|
||||
* declared not-exported would never hear it. The flag is required from API 33 and does
|
||||
* not exist before it — the car this runs on is API 28 — so the registration goes
|
||||
* through ContextCompat, which picks the right overload for the platform underneath.
|
||||
*/
|
||||
private void registerWeatherReceiver() {
|
||||
restoreWeather();
|
||||
try {
|
||||
androidx.core.content.ContextCompat.registerReceiver(
|
||||
this, weatherReceiver, new IntentFilter(HeadUnitWeather.ACTION),
|
||||
androidx.core.content.ContextCompat.RECEIVER_EXPORTED);
|
||||
weatherReceiverRegistered = true;
|
||||
// A null receiver asks the platform for the last broadcast it is holding without
|
||||
// subscribing to anything. If the weather app sends its update sticky, this is the
|
||||
// reading straight away rather than after however long until its next refresh —
|
||||
// and the app is started to look at the log, which is exactly the wrong moment to
|
||||
// begin waiting.
|
||||
probeStickyWeather();
|
||||
} catch (Exception e) {
|
||||
// A car whose weather app broadcasts nothing is a car without ext_temp, not a
|
||||
// reason to lose the upload service.
|
||||
Log.w(TAG, "Weather broadcast unavailable: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** Asks for the last held broadcast, if the platform is holding one. Harmless when not. */
|
||||
private void probeStickyWeather() {
|
||||
try {
|
||||
headUnitWeather.accept(
|
||||
registerReceiver(null, new IntentFilter(HeadUnitWeather.ACTION)));
|
||||
saveWeather();
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "Sticky weather probe failed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the last reading across a restart.
|
||||
*
|
||||
* The broadcast arrives when the weather app refreshes, so a service that forgets on every
|
||||
* start would spend most of its life with no temperature at all — and the app is restarted
|
||||
* every time the driver opens it.
|
||||
*/
|
||||
private void saveWeather() {
|
||||
Float celsius = headUnitWeather.temperatureC();
|
||||
if (celsius == null) return;
|
||||
prefs.edit()
|
||||
.putFloat(KEY_WEATHER_TEMP, celsius)
|
||||
.putString(KEY_WEATHER_PLACE, headUnitWeather.place())
|
||||
.putLong(KEY_WEATHER_MS, headUnitWeather.receivedMs())
|
||||
.apply();
|
||||
}
|
||||
|
||||
private void restoreWeather() {
|
||||
long whenMs = prefs.getLong(KEY_WEATHER_MS, 0L);
|
||||
if (whenMs <= 0L || System.currentTimeMillis() - whenMs > WEATHER_MAX_AGE_MS) return;
|
||||
headUnitWeather.restore(prefs.getFloat(KEY_WEATHER_TEMP, 0f),
|
||||
prefs.getString(KEY_WEATHER_PLACE, ""), whenMs);
|
||||
}
|
||||
|
||||
/** True while the stored reading still describes the day — see {@link #WEATHER_MAX_AGE_MS}. */
|
||||
private boolean weatherIsFresh() {
|
||||
long whenMs = headUnitWeather.receivedMs();
|
||||
return whenMs > 0L && System.currentTimeMillis() - whenMs <= WEATHER_MAX_AGE_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Current conditions where the car is, or null when nothing can answer.
|
||||
*
|
||||
* Cached for {@link #WEATHER_TTL_MS}: the query blocks the upload thread on what may be a
|
||||
* network round-trip inside the head unit, and it is asked on every upload the car's own
|
||||
* thermometer cannot answer — which, on this vehicle, is all of them. A stale reading is
|
||||
* kept when a refresh fails, since ten-minute-old air is a better answer than none.
|
||||
*/
|
||||
private SaicWeather.Reading weatherAt(Location loc) {
|
||||
if (loc == null) return null;
|
||||
long now = System.currentTimeMillis();
|
||||
SaicWeather.Reading cached = lastWeather;
|
||||
if (cached != null && now - lastWeatherMs < WEATHER_TTL_MS) return cached;
|
||||
if (!SaicWeather.INSTANCE.isAvailable()) { lastQueryMs = -1L; return cached; }
|
||||
// How long the call took is the only thing that separates its failure modes from out
|
||||
// here: the query is bounded by a two-second wait inside the library, so a null that
|
||||
// comes back at once was refused or answered undecodably, and a null that comes back
|
||||
// at the bound is a service that did not answer in time. The library reports neither.
|
||||
long startedMs = System.currentTimeMillis();
|
||||
SaicWeather.Reading reading = SaicWeather.INSTANCE.currentAt(
|
||||
loc.getLatitude(), loc.getLongitude(), weatherLanguage());
|
||||
lastQueryMs = System.currentTimeMillis() - startedMs;
|
||||
if (reading == null) return cached;
|
||||
lastWeather = reading;
|
||||
lastWeatherMs = now;
|
||||
return reading;
|
||||
}
|
||||
|
||||
/** The provider answers in the language it is asked in; the driver's is the right one. */
|
||||
private static String weatherLanguage() {
|
||||
java.util.Locale locale = java.util.Locale.getDefault();
|
||||
String language = locale.getLanguage().toLowerCase(java.util.Locale.US);
|
||||
String country = locale.getCountry().toLowerCase(java.util.Locale.US);
|
||||
return country.isEmpty() ? language : language + "-" + country;
|
||||
}
|
||||
|
||||
private void sendToAbrp(String apiKey, String token, String tlmJson, String summary,
|
||||
Integer soc, Float speedKmh, boolean carUp) {
|
||||
try {
|
||||
AbrpApi.Response response = AbrpApi.send(apiKey, token, tlmJson);
|
||||
int code = response.code;
|
||||
|
||||
// The response body can echo request details — debug builds only.
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, "ABRP [" + code + "]: " + response.body);
|
||||
|
||||
uploadLog.record(new UploadLog.Entry(System.currentTimeMillis(), code,
|
||||
code == 200, code == 200 ? "OK" : ("HTTP " + code), summary));
|
||||
|
||||
if (code == 200) {
|
||||
lastSuccessfulUploadMs = System.currentTimeMillis();
|
||||
String time = android.text.format.DateFormat
|
||||
.format("HH:mm:ss", lastSuccessfulUploadMs).toString();
|
||||
prefs.edit()
|
||||
.putString("last_upload_time", time)
|
||||
.putString("last_upload_status", "OK")
|
||||
.apply();
|
||||
// Only claim a value in the notification if we actually read one.
|
||||
String detail = (soc != null && speedKmh != null)
|
||||
? ("SOC " + soc + "% · " + Math.round(speedKmh) + " km/h · " + time)
|
||||
: (carUp ? ("Car data partial · " + time) : ("GPS only · " + time));
|
||||
updateNotification(detail);
|
||||
} else {
|
||||
prefs.edit().putString("last_upload_status", "HTTP " + code).apply();
|
||||
int failures = uploadLog.consecutiveFailures();
|
||||
updateNotification(failures >= UploadLog.FAILURES_FOR_ERROR
|
||||
? ("Upload failing (" + failures + "x) — HTTP " + code)
|
||||
: ("Upload error: HTTP " + code));
|
||||
}
|
||||
} catch (java.net.UnknownHostException e) {
|
||||
// httpStatus 0: the request never reached a server.
|
||||
uploadLog.record(new UploadLog.Entry(
|
||||
System.currentTimeMillis(), 0, false, "No internet", summary));
|
||||
prefs.edit().putString("last_upload_status", "No internet").apply();
|
||||
updateNotification("Offline — will retry");
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Upload failed", e);
|
||||
uploadLog.record(new UploadLog.Entry(System.currentTimeMillis(), 0, false,
|
||||
e.getClass().getSimpleName(), summary));
|
||||
prefs.edit().putString("last_upload_status",
|
||||
e.getClass().getSimpleName() + ": " + e.getMessage()).apply();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Location ----------
|
||||
|
||||
private final LocationListener locationListener = new LocationListener() {
|
||||
@Override public void onLocationChanged(Location location) {
|
||||
lastLocation = location;
|
||||
locationUpdatesActive = true;
|
||||
}
|
||||
@Override public void onStatusChanged(String p, int s, Bundle e) {}
|
||||
@Override public void onProviderEnabled(String p) {
|
||||
Log.i(TAG, "GPS provider enabled — re-arming location updates");
|
||||
// The subscription made while the provider was off delivers nothing; ask again.
|
||||
requestLocationUpdates();
|
||||
}
|
||||
@Override public void onProviderDisabled(String p) {
|
||||
Log.w(TAG, "GPS provider disabled — telemetry will omit position");
|
||||
locationUpdatesActive = false;
|
||||
// A stale fix is worse than none: ABRP would think the car is parked there.
|
||||
lastLocation = null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* The last GPS fix if it still describes where the car is, otherwise null so the
|
||||
* payload omits position entirely. Omitting it leaves ABRP on its own last known
|
||||
* point; sending a stale one actively moves the car back to where it used to be.
|
||||
*/
|
||||
private Location freshLocation() {
|
||||
Location loc = lastLocation;
|
||||
if (loc == null) return null;
|
||||
long ageMs = locationAgeMs(loc);
|
||||
long maxAgeMs = maxLocationAgeMs();
|
||||
if (ageMs > maxAgeMs) {
|
||||
Log.w(TAG, "Dropping stale GPS fix: " + ageMs / 1000 + "s old (max "
|
||||
+ maxAgeMs / 1000 + "s) — position omitted");
|
||||
return null;
|
||||
}
|
||||
return loc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Age of a fix, measured on the monotonic clock. Location.getTime() is wall-clock and
|
||||
* the head unit adjusts its own clock from GPS, which can make a fresh fix look like
|
||||
* it came from the future — or from hours ago — the moment that correction lands.
|
||||
*/
|
||||
private static long locationAgeMs(Location loc) {
|
||||
long fixNanos = loc.getElapsedRealtimeNanos();
|
||||
// A provider that does not stamp the monotonic clock leaves this at zero, which would
|
||||
// date every fix to the last boot and drop all of them — turning a staleness guard
|
||||
// into a position blackout. An unstamped fix is one whose age cannot be known, and
|
||||
// guessing "ancient" there is the worse of the two guesses.
|
||||
if (fixNanos <= 0L) return 0L;
|
||||
long ageMs = (SystemClock.elapsedRealtimeNanos() - fixNanos) / 1_000_000L;
|
||||
// A fix from the future is a clock that moved, not a fix worth dropping.
|
||||
return Math.max(ageMs, 0L);
|
||||
}
|
||||
|
||||
/** Several subscription intervals, so a single missed fix never blanks the position. */
|
||||
private long maxLocationAgeMs() {
|
||||
return Math.max(MIN_LOCATION_MAX_AGE_MS, 3 * settings.intervalMs());
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to GPS updates. Safe to call repeatedly: the previous subscription is
|
||||
* removed first, so the watchdog can re-arm without stacking listeners.
|
||||
*/
|
||||
private void requestLocationUpdates() {
|
||||
try {
|
||||
locationManager = (LocationManager) getSystemService(LOCATION_SERVICE);
|
||||
try { locationManager.removeUpdates(locationListener); } catch (Exception ignored) {}
|
||||
locationUpdatesActive = false;
|
||||
|
||||
if (locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER)) {
|
||||
// Match the upload cadence: a 10 s GPS subscription burned power for
|
||||
// fixes that were thrown away between uploads.
|
||||
locationManager.requestLocationUpdates(
|
||||
LocationManager.GPS_PROVIDER, settings.intervalMs(), 0f,
|
||||
locationListener, Looper.getMainLooper());
|
||||
locationUpdatesActive = true;
|
||||
lastLocationRequestMs = System.currentTimeMillis();
|
||||
// Seed from the last known fix so the first upload is not position-less —
|
||||
// but only if it is recent. This is where the car's overnight parking spot
|
||||
// used to come from: cold GPS at start-up delivers nothing for the first
|
||||
// minutes, and the fix from the previous drive filled the gap.
|
||||
Location last = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER);
|
||||
if (last != null && locationAgeMs(last) <= maxLocationAgeMs()) {
|
||||
lastLocation = last;
|
||||
} else if (last != null) {
|
||||
Log.i(TAG, "Ignoring last known fix: " + locationAgeMs(last) / 1000
|
||||
+ "s old, waiting for a live one");
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "GPS provider disabled — will retry");
|
||||
lastLocationRequestMs = System.currentTimeMillis();
|
||||
}
|
||||
} catch (SecurityException e) {
|
||||
Log.w(TAG, "Location permission not granted: " + e.getMessage());
|
||||
lastLocationRequestMs = System.currentTimeMillis();
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "Location unavailable: " + e.getMessage());
|
||||
lastLocationRequestMs = System.currentTimeMillis();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Notification ----------
|
||||
|
||||
private void createNotificationChannel() {
|
||||
NotificationChannel ch = new NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
getString(R.string.notif_channel_name),
|
||||
NotificationManager.IMPORTANCE_LOW);
|
||||
ch.setDescription(getString(R.string.notif_channel_desc));
|
||||
getSystemService(NotificationManager.class).createNotificationChannel(ch);
|
||||
}
|
||||
|
||||
private Notification buildNotification(String status) {
|
||||
PendingIntent openApp = PendingIntent.getActivity(this, 0,
|
||||
new Intent(this, MainActivity.class),
|
||||
PendingIntent.FLAG_IMMUTABLE);
|
||||
|
||||
Intent stopIntent = new Intent(this, AbrpUploadService.class);
|
||||
stopIntent.setAction(ACTION_STOP);
|
||||
PendingIntent stopPi = PendingIntent.getService(this, 1,
|
||||
stopIntent, PendingIntent.FLAG_IMMUTABLE);
|
||||
|
||||
return new NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle(getString(R.string.notif_title))
|
||||
.setContentText(status)
|
||||
.setContentIntent(openApp)
|
||||
.addAction(android.R.drawable.ic_delete, getString(R.string.notif_action_stop), stopPi)
|
||||
.setOngoing(true)
|
||||
.build();
|
||||
}
|
||||
|
||||
private void updateNotification(String status) {
|
||||
lastStatus = status;
|
||||
getSystemService(NotificationManager.class).notify(NOTIF_ID, buildNotification(status));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.util.Log;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Restarts uploading when the head unit powers up.
|
||||
*
|
||||
* BOOT_COMPLETED alone was not enough. An MG4's head unit rarely cold-boots: switching the
|
||||
* car on usually resumes it, and the resume is announced with QUICKBOOT_POWERON instead.
|
||||
* Users reported uploading never starting with the car, and this is why — the app was
|
||||
* waiting for a broadcast their car does not always send. The other EVSuite apps already
|
||||
* listen to the whole family; this one now does too.
|
||||
*
|
||||
* MY_PACKAGE_REPLACED is in the list for a different reason: an update stops the service,
|
||||
* and nothing else would bring it back until the next power cycle.
|
||||
*/
|
||||
public class BootReceiver extends BroadcastReceiver {
|
||||
|
||||
private static final String TAG = "EVABRP.Boot";
|
||||
|
||||
private static final Set<String> START_ACTIONS = new HashSet<>(Arrays.asList(
|
||||
Intent.ACTION_BOOT_COMPLETED,
|
||||
Intent.ACTION_MY_PACKAGE_REPLACED,
|
||||
"android.intent.action.LOCKED_BOOT_COMPLETED",
|
||||
"android.intent.action.QUICKBOOT_POWERON",
|
||||
"com.htc.intent.action.QUICKBOOT_POWERON"
|
||||
));
|
||||
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
String action = intent.getAction();
|
||||
if (action == null || !START_ACTIONS.contains(action)) return;
|
||||
|
||||
SharedPreferences prefs = context.getSharedPreferences("abrp_prefs", Context.MODE_PRIVATE);
|
||||
boolean enabled = prefs.getBoolean("service_enabled", false);
|
||||
boolean autostart = prefs.getBoolean(UploadSettings.KEY_AUTOSTART, UploadSettings.DEFAULT_AUTOSTART);
|
||||
|
||||
// Credentials come from SecurePrefs, not from the plaintext file. This receiver used
|
||||
// to read "token" out of abrp_prefs — where it has not lived since the credentials
|
||||
// were encrypted, because the migration deletes the plaintext copy. The check
|
||||
// therefore always failed, and uploading never started by itself on any car.
|
||||
SharedPreferences secure = SecurePrefs.get(context);
|
||||
boolean haveCreds = !secure.getString(SecurePrefs.KEY_TOKEN, "").trim().isEmpty()
|
||||
&& !secure.getString(SecurePrefs.KEY_API_KEY, "").trim().isEmpty();
|
||||
|
||||
if (!enabled || !autostart || !haveCreds) {
|
||||
Log.i(TAG, action + " ignored (enabled=" + enabled
|
||||
+ " autostart=" + autostart + " credentials=" + haveCreds + ")");
|
||||
return;
|
||||
}
|
||||
|
||||
Log.i(TAG, action + " — starting upload service");
|
||||
try {
|
||||
context.startForegroundService(new Intent(context, AbrpUploadService.class));
|
||||
} catch (Exception e) {
|
||||
// A start refused this early is not worth crashing the boot broadcast over: the
|
||||
// next power cycle, or opening the app, starts it just the same.
|
||||
Log.w(TAG, "startForegroundService failed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import android.content.ComponentName;
|
||||
import android.content.Context;
|
||||
import android.content.ServiceConnection;
|
||||
import android.os.IBinder;
|
||||
import android.util.Log;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
/**
|
||||
* Connects to Android Automotive's CarPropertyManager via reflection.
|
||||
*
|
||||
* Targets AAOS 9 (API 28) where android.car is a platform library not in the
|
||||
* compile-time SDK — direct imports are unavailable, so all access is via reflection.
|
||||
*
|
||||
* Debug discovery transport for {@code VhalProbe}. Production telemetry goes exclusively
|
||||
* through EVHardware; this class deliberately owns no vehicle property ids.
|
||||
*/
|
||||
public class CarPropertyAdapter {
|
||||
|
||||
private static final String TAG = "CarPropertyAdapter";
|
||||
|
||||
public interface Listener {
|
||||
void onConnected();
|
||||
void onDisconnected();
|
||||
}
|
||||
|
||||
private final Listener listener;
|
||||
private Context appContext;
|
||||
// Written on the binder/main thread, read from the abrp-upload scheduler thread.
|
||||
// Without volatile the scheduler can observe a stale null or a half-connected adapter.
|
||||
private volatile Object car; // android.car.Car
|
||||
volatile Object cpm; // android.car.hardware.property.CarPropertyManager (package-private: VhalProbe, debug builds only)
|
||||
private Class<?> carClass;
|
||||
Class<?> cpmClass;
|
||||
|
||||
// Reflected getters resolved once at connect time. getMethod() on every read meant
|
||||
// 8 lookups every 15 s, each allocating a Method and its parameter array.
|
||||
private volatile Method getIntMethod;
|
||||
private volatile Method getFloatMethod;
|
||||
private volatile Method getBooleanMethod;
|
||||
|
||||
/**
|
||||
* Incremented on every disconnect. A polling runnable captures the value it started
|
||||
* with and stops as soon as it no longer matches, so a reconnect cannot leave an
|
||||
* orphan polling loop running alongside the new one.
|
||||
*/
|
||||
private volatile int connectionGeneration = 0;
|
||||
|
||||
public CarPropertyAdapter(Listener listener) {
|
||||
this.listener = listener;
|
||||
}
|
||||
|
||||
public void connect(Context context) {
|
||||
appContext = context.getApplicationContext();
|
||||
|
||||
try {
|
||||
carClass = Class.forName("android.car.Car");
|
||||
cpmClass = Class.forName("android.car.hardware.property.CarPropertyManager");
|
||||
} catch (ClassNotFoundException e) {
|
||||
Log.e(TAG, "android.car not available — not running on AAOS", e);
|
||||
return;
|
||||
}
|
||||
|
||||
ServiceConnection sc = new ServiceConnection() {
|
||||
@Override
|
||||
public void onServiceConnected(ComponentName name, IBinder binder) {
|
||||
try {
|
||||
String svcName = (String) carClass.getField("PROPERTY_SERVICE").get(null);
|
||||
Method getManager = carClass.getMethod("getCarManager", String.class);
|
||||
Object manager = getManager.invoke(car, svcName);
|
||||
if (manager != null) {
|
||||
getIntMethod = cpmClass.getMethod("getIntProperty", int.class, int.class);
|
||||
getFloatMethod = cpmClass.getMethod("getFloatProperty", int.class, int.class);
|
||||
getBooleanMethod = cpmClass.getMethod("getBooleanProperty", int.class, int.class);
|
||||
// Publish last: isConnected() must never be true before the
|
||||
// cached methods are in place.
|
||||
cpm = manager;
|
||||
Log.i(TAG, "CarPropertyManager ready");
|
||||
listener.onConnected();
|
||||
} else {
|
||||
Log.e(TAG, "getCarManager returned null for: " + svcName);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Failed to get CarPropertyManager", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onServiceDisconnected(ComponentName name) {
|
||||
connectionGeneration++;
|
||||
cpm = null;
|
||||
car = null;
|
||||
Log.w(TAG, "Car service disconnected");
|
||||
listener.onDisconnected();
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
Method createCar = carClass.getMethod("createCar", Context.class, ServiceConnection.class);
|
||||
car = createCar.invoke(null, appContext, sc);
|
||||
carClass.getMethod("connect").invoke(car);
|
||||
Log.d(TAG, "Connecting to Car service...");
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Failed to create/connect Car", e);
|
||||
}
|
||||
}
|
||||
|
||||
public void disconnect() {
|
||||
// Bump first: any in-flight polling runnable must stop even if the calls below throw.
|
||||
connectionGeneration++;
|
||||
cpm = null;
|
||||
if (car == null) return;
|
||||
try {
|
||||
carClass.getMethod("disconnect").invoke(car);
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "disconnect error", e);
|
||||
}
|
||||
car = null;
|
||||
}
|
||||
|
||||
public boolean isConnected() {
|
||||
return cpm != null;
|
||||
}
|
||||
|
||||
public interface PropertyCallback {
|
||||
void onValue(int propId, int areaId, Object value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls a float property on the given handler at the specified interval.
|
||||
* Stops automatically when disconnect() is called.
|
||||
* This is the preferred mechanism on AAOS 9 where CarPropertyValue is not
|
||||
* accessible via any standard classloader path, making Proxy-based listeners fail.
|
||||
*/
|
||||
public void startFloatPolling(int propId, int areaId, long intervalMs,
|
||||
android.os.Handler handler, PropertyCallback cb) {
|
||||
if (cpm == null) { Log.w(TAG, "startFloatPolling: not connected"); return; }
|
||||
final int generation = connectionGeneration;
|
||||
Runnable[] r = {null};
|
||||
r[0] = () -> {
|
||||
// Checking cpm alone was not enough: a reconnect makes it non-null again and
|
||||
// the orphan loop resumed forever next to the new one.
|
||||
if (cpm == null || connectionGeneration != generation) return;
|
||||
Float val = getFloatProperty(propId, areaId);
|
||||
if (val != null) cb.onValue(propId, areaId, val);
|
||||
handler.postDelayed(r[0], intervalMs);
|
||||
};
|
||||
handler.post(r[0]);
|
||||
Log.d(TAG, String.format("started polling 0x%08X every %dms", propId, intervalMs));
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to updates for any car property (float, int, bool — type-agnostic).
|
||||
* The callback receives the raw {@link Object} value; cast to the expected type.
|
||||
*
|
||||
* AAOS 9 quirks handled here:
|
||||
* - Method is named registerListener (not registerCallback as in later versions).
|
||||
* - The event callback interface and CarPropertyValue class are discovered from
|
||||
* the register method's signature — on this SAIC build CarPropertyValue lives
|
||||
* at android.car.hardware.CarPropertyValue (NOT android.car.hardware.property.*).
|
||||
* - The Proxy is defined with this class's loader so the framework can verify it.
|
||||
*
|
||||
* @param rateHz for CONTINUOUS properties, the requested rate in Hz.
|
||||
* For ON_CHANGE properties (e.g. gear), this is ignored — pass 0f.
|
||||
*/
|
||||
public void registerPropertyCallback(int propId, int areaId, float rateHz, PropertyCallback cb) {
|
||||
if (cpm == null) { Log.w(TAG, "registerPropertyCallback: not connected"); return; }
|
||||
try {
|
||||
// Find a 3-arg register* method on CarPropertyManager: (Listener, propId, rateHz).
|
||||
// Match by exact name: getMethods() has no defined order, so "the first
|
||||
// 3-arg register* method" could pick a different overload between runs.
|
||||
java.lang.reflect.Method registerMethod = null;
|
||||
for (java.lang.reflect.Method m : cpmClass.getMethods()) {
|
||||
String n = m.getName();
|
||||
if (("registerListener".equals(n) || "registerCallback".equals(n))
|
||||
&& m.getParameterCount() == 3) {
|
||||
Log.d(TAG, "register candidate: " + m.toGenericString());
|
||||
registerMethod = m;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (registerMethod == null) {
|
||||
Log.e(TAG, "no register method found on CarPropertyManager");
|
||||
return;
|
||||
}
|
||||
|
||||
Class<?> cbInterface = registerMethod.getParameterTypes()[0];
|
||||
Log.d(TAG, "callback interface: " + cbInterface.getName());
|
||||
|
||||
// Discover CarPropertyValue directly from the interface's onChangeEvent
|
||||
// parameter type. This sidesteps hardcoding a package path that differs
|
||||
// between AAOS builds.
|
||||
Class<?> cpvClass = null;
|
||||
for (java.lang.reflect.Method m : cbInterface.getMethods()) {
|
||||
Log.d(TAG, " interface method: " + m.toGenericString());
|
||||
if ("onChangeEvent".equals(m.getName()) && m.getParameterCount() == 1) {
|
||||
cpvClass = m.getParameterTypes()[0];
|
||||
}
|
||||
}
|
||||
if (cpvClass == null) {
|
||||
Log.e(TAG, "no onChangeEvent(CarPropertyValue) method on " + cbInterface.getName());
|
||||
return;
|
||||
}
|
||||
Log.d(TAG, "CarPropertyValue resolved to: " + cpvClass.getName());
|
||||
|
||||
java.lang.reflect.Method getPropId = cpvClass.getMethod("getPropertyId");
|
||||
java.lang.reflect.Method getAreaId = cpvClass.getMethod("getAreaId");
|
||||
java.lang.reflect.Method getValue = cpvClass.getMethod("getValue");
|
||||
|
||||
// Use the app classloader (delegates to boot for android.car.* classes)
|
||||
// so the generated Proxy class can actually be defined.
|
||||
ClassLoader proxyLoader = CarPropertyAdapter.class.getClassLoader();
|
||||
|
||||
Object proxy = java.lang.reflect.Proxy.newProxyInstance(
|
||||
proxyLoader,
|
||||
new Class[]{cbInterface},
|
||||
(proxyObj, method, args) -> {
|
||||
String mname = method.getName();
|
||||
if ("onChangeEvent".equals(mname) && args != null && args.length == 1) {
|
||||
int pId = (int) getPropId.invoke(args[0]);
|
||||
int aId = (int) getAreaId.invoke(args[0]);
|
||||
Object val = getValue.invoke(args[0]);
|
||||
cb.onValue(pId, aId, val);
|
||||
} else if ("onErrorEvent".equals(mname)) {
|
||||
Log.e(TAG, "onErrorEvent propId=" + args[0] + " area=" + args[1]);
|
||||
} else if ("equals".equals(mname)) {
|
||||
return proxyObj == args[0];
|
||||
} else if ("hashCode".equals(mname)) {
|
||||
return System.identityHashCode(proxyObj);
|
||||
} else if ("toString".equals(mname)) {
|
||||
return "CarPropertyProxy@" + Integer.toHexString(System.identityHashCode(proxyObj));
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
registerMethod.invoke(cpm, proxy, propId, rateHz);
|
||||
Log.i(TAG, String.format("registered callback for 0x%08X area=%d rate=%.1fHz",
|
||||
propId, areaId, rateHz));
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "registerPropertyCallback failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Getters return null when the property cannot be read. They used to return 0,
|
||||
// which is indistinguishable from a genuine zero: one SecurityException on the SOC
|
||||
// property was enough to tell ABRP the battery was empty, every 15 seconds.
|
||||
// Callers must omit a null field from the payload rather than substituting a value.
|
||||
|
||||
/** Area 0 covers global/non-zoned properties. Null when unreadable. */
|
||||
public Integer getIntProperty(int propId, int areaId) {
|
||||
Object manager = cpm;
|
||||
Method method = getIntMethod;
|
||||
if (manager == null || method == null) return null;
|
||||
try {
|
||||
return (Integer) method.invoke(manager, propId, areaId);
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "getIntProperty(" + Integer.toHexString(propId) + ") failed", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Null when unreadable. */
|
||||
public Float getFloatProperty(int propId, int areaId) {
|
||||
Object manager = cpm;
|
||||
Method method = getFloatMethod;
|
||||
if (manager == null || method == null) return null;
|
||||
try {
|
||||
return (Float) method.invoke(manager, propId, areaId);
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "getFloatProperty(" + Integer.toHexString(propId) + ") failed", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Null when unreadable. */
|
||||
public Boolean getBooleanProperty(int propId, int areaId) {
|
||||
Object manager = cpm;
|
||||
Method method = getBooleanMethod;
|
||||
if (manager == null || method == null) return null;
|
||||
try {
|
||||
return (Boolean) method.invoke(manager, propId, areaId);
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "getBooleanProperty(" + Integer.toHexString(propId) + ") failed", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
/**
|
||||
* Works out whether the car is charging from whichever signals the VHAL actually answers.
|
||||
*
|
||||
* {@code EV_CHARGE_PORT_CONNECTED} is a standard AAOS property and this vehicle's VHAL is
|
||||
* not obliged to implement it. When it answers {@code false} on a plugged-in car, the
|
||||
* cadence policy sees "parked and not charging" and backs off to
|
||||
* {@link UploadCadence#PARKED_INTERVAL_MS} for the whole session — which is exactly when
|
||||
* ABRP needs a tight SOC curve to size a charging stop. Two fallbacks close that hole:
|
||||
*
|
||||
* 1. the instantaneous charge rate, which is positive only while the pack takes energy;
|
||||
* 2. a rising SOC on a parked car, which nothing but charging can produce.
|
||||
*
|
||||
* The SOC fallback is latched, because SOC only moves a whole percent every few minutes
|
||||
* and a signal that flapped between ticks would flap {@code is_charging} in the payload
|
||||
* too. The latch expires so a car left parked after a charge falls back to the idle
|
||||
* cadence instead of polling all night.
|
||||
*
|
||||
* Stateful and NOT thread-safe: only the {@code abrp-upload} scheduler thread calls it.
|
||||
*/
|
||||
final class ChargingSignal {
|
||||
|
||||
/** Above this the pack is taking energy; below it the reading is noise around zero. */
|
||||
static final float CHARGING_POWER_KW = 0.3f;
|
||||
|
||||
/**
|
||||
* How long a SOC rise keeps counting as "charging". Long enough to cover the slowest
|
||||
* realistic case — a 2 kW granny lead needs ~19 min per percent on a 64 kWh pack —
|
||||
* and short enough that a finished charge returns to the idle cadence promptly.
|
||||
*/
|
||||
static final long SOC_RISE_LATCH_MS = 1_800_000L;
|
||||
|
||||
private Integer lastSocPercent;
|
||||
/** When SOC last rose while parked; 0 when there is no live rise to trust. */
|
||||
private long socRoseAtMs;
|
||||
|
||||
/**
|
||||
* @param nowMs current time
|
||||
* @param portConnected charge port property, null when unreadable
|
||||
* @param chargeRateKw charge rate in kW as the VHAL signs it (+ve while charging),
|
||||
* null when unreadable
|
||||
* @param parked null when the gear could not be read
|
||||
* @param socPercent null when the battery level could not be read
|
||||
* @return TRUE / FALSE when the state can be established, null when nothing could be
|
||||
* read — null must keep counting as "unknown", never as "not charging"
|
||||
*/
|
||||
Boolean evaluate(long nowMs, Boolean portConnected, Float chargeRateKw,
|
||||
Boolean parked, Integer socPercent) {
|
||||
trackSoc(nowMs, parked, socPercent);
|
||||
|
||||
if (Boolean.TRUE.equals(portConnected)) return Boolean.TRUE;
|
||||
if (chargeRateKw != null && chargeRateKw > CHARGING_POWER_KW) return Boolean.TRUE;
|
||||
if (Boolean.TRUE.equals(parked)
|
||||
&& socRoseAtMs > 0L && nowMs - socRoseAtMs < SOC_RISE_LATCH_MS) {
|
||||
return Boolean.TRUE;
|
||||
}
|
||||
// Nothing overrode the port: pass its reading through, including its null.
|
||||
return portConnected;
|
||||
}
|
||||
|
||||
private void trackSoc(long nowMs, Boolean parked, Integer socPercent) {
|
||||
if (!Boolean.TRUE.equals(parked)) {
|
||||
// Regen while driving also raises SOC. Keep the latch clear so parking right
|
||||
// after a downhill run does not read as charging.
|
||||
socRoseAtMs = 0L;
|
||||
lastSocPercent = socPercent;
|
||||
return;
|
||||
}
|
||||
if (socPercent == null) return; // keep the previous reading to compare against
|
||||
if (lastSocPercent != null) {
|
||||
if (socPercent > lastSocPercent) socRoseAtMs = nowMs;
|
||||
else if (socPercent < lastSocPercent) socRoseAtMs = 0L; // draining, not charging
|
||||
}
|
||||
lastSocPercent = socPercent;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
/**
|
||||
* Parses an ABRP configuration text file so a user can set the app up without typing on the
|
||||
* car's on-screen keyboard — the api_key and token in particular are long and error-prone to
|
||||
* enter by hand. The file is a plain {@code key = value} list, one entry per line:
|
||||
*
|
||||
* <pre>
|
||||
* # ABRP Uploader config
|
||||
* api_key = 1234abcd-...
|
||||
* token = 5678efgh-...
|
||||
* interval_sec = 60
|
||||
* boost_low_soc = true
|
||||
* low_soc_percent = 20
|
||||
* </pre>
|
||||
*
|
||||
* Only api_key and token are commonly needed; the cadence keys are optional. Blank lines and
|
||||
* lines starting with {@code #} are ignored, keys are case-insensitive, and surrounding
|
||||
* whitespace is trimmed. Every field is nullable: the caller applies only what the file set,
|
||||
* leaving anything absent untouched.
|
||||
*/
|
||||
final class ConfigImport {
|
||||
|
||||
final String apiKey;
|
||||
final String token;
|
||||
final Integer intervalSec;
|
||||
final Boolean boostLowSoc;
|
||||
final Integer lowSocPercent;
|
||||
|
||||
private ConfigImport(String apiKey, String token, Integer intervalSec,
|
||||
Boolean boostLowSoc, Integer lowSocPercent) {
|
||||
this.apiKey = apiKey;
|
||||
this.token = token;
|
||||
this.intervalSec = intervalSec;
|
||||
this.boostLowSoc = boostLowSoc;
|
||||
this.lowSocPercent = lowSocPercent;
|
||||
}
|
||||
|
||||
/** True when the file yielded nothing usable — an empty or malformed file, not a config. */
|
||||
boolean isEmpty() {
|
||||
return apiKey == null && token == null && intervalSec == null
|
||||
&& boostLowSoc == null && lowSocPercent == null;
|
||||
}
|
||||
|
||||
static ConfigImport parse(String text) {
|
||||
String apiKey = null, token = null;
|
||||
Integer intervalSec = null, lowSocPercent = null;
|
||||
Boolean boostLowSoc = null;
|
||||
|
||||
for (String line : text.split("\n", -1)) {
|
||||
line = line.trim();
|
||||
if (line.isEmpty() || line.charAt(0) == '#') continue;
|
||||
|
||||
int sep = line.indexOf('=');
|
||||
if (sep <= 0) continue;
|
||||
|
||||
String key = line.substring(0, sep).trim().toLowerCase(java.util.Locale.US);
|
||||
String value = line.substring(sep + 1).trim();
|
||||
if (value.isEmpty()) continue;
|
||||
|
||||
switch (key) {
|
||||
case "api_key":
|
||||
case "apikey":
|
||||
apiKey = value;
|
||||
break;
|
||||
case "token":
|
||||
case "user_token":
|
||||
token = value;
|
||||
break;
|
||||
case "interval_sec":
|
||||
case "upload_interval_sec":
|
||||
intervalSec = nearestInterval(value);
|
||||
break;
|
||||
case "boost_low_soc":
|
||||
boostLowSoc = parseBool(value);
|
||||
break;
|
||||
case "low_soc_percent":
|
||||
lowSocPercent = clampPercent(value);
|
||||
break;
|
||||
default:
|
||||
// Unknown keys are ignored rather than rejected, so a file can carry
|
||||
// comments or future keys without breaking an older build.
|
||||
break;
|
||||
}
|
||||
}
|
||||
return new ConfigImport(apiKey, token, intervalSec, boostLowSoc, lowSocPercent);
|
||||
}
|
||||
|
||||
/** Snaps to the nearest offered choice: the UI spinner has no free-form interval. */
|
||||
private static Integer nearestInterval(String value) {
|
||||
int requested;
|
||||
try {
|
||||
requested = Integer.parseInt(value);
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
int best = UploadSettings.INTERVAL_CHOICES_SEC[0];
|
||||
for (int choice : UploadSettings.INTERVAL_CHOICES_SEC) {
|
||||
if (Math.abs(choice - requested) < Math.abs(best - requested)) best = choice;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** Same 1–99 clamp the manual field enforces, so an import cannot set an unusable value. */
|
||||
private static Integer clampPercent(String value) {
|
||||
try {
|
||||
return Math.max(1, Math.min(99, Integer.parseInt(value)));
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static Boolean parseBool(String value) {
|
||||
value = value.toLowerCase(java.util.Locale.US);
|
||||
if (value.equals("true") || value.equals("on") || value.equals("1")) return Boolean.TRUE;
|
||||
if (value.equals("false") || value.equals("off") || value.equals("0")) return Boolean.FALSE;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
/**
|
||||
* Outside temperature from the head unit's own weather app.
|
||||
*
|
||||
* The car does not implement ENV_OUTSIDE_TEMPERATURE, so there is no vehicle sensor to read,
|
||||
* and the vendor map service that answers a weather query by position never bound on this
|
||||
* firmware — {@code ext_temp} was simply absent from every upload. What demonstrably works is
|
||||
* the head unit's status bar, which shows a temperature all the time. It gets it from a
|
||||
* broadcast, and so does this.
|
||||
*
|
||||
* **Everything about the payload here is unverified.** The action and the extra name come from
|
||||
* reading the head unit, not from a published interface, and the shape inside is read by
|
||||
* searching rather than by field offsets: any key whose name mentions a temperature, at any
|
||||
* depth. That is deliberate. A parser written against a guessed layout fails silently and
|
||||
* looks exactly like the bug it was meant to fix, whereas a search either finds a number or
|
||||
* reports what it was given — see {@link #diagnostic()}, which reaches the app's own log.
|
||||
*
|
||||
* Stays in this app rather than in EVHardware for the same reason: the shared library carries
|
||||
* vendor knowledge that has been confirmed on a car. Once a driver's log shows this reading a
|
||||
* real broadcast, it belongs there instead, next to SaicWeather.
|
||||
*/
|
||||
final class HeadUnitWeather {
|
||||
|
||||
/** The head unit's weather app announces each update with this. */
|
||||
static final String ACTION = "com.saicmotor.weather";
|
||||
|
||||
/** Enough of an unreadable payload to recognise its shape, short enough for a log line. */
|
||||
private static final int RAW_SAMPLE_CHARS = 200;
|
||||
|
||||
/** How deep the search descends. A weather payload is nested, not deeply nested. */
|
||||
private static final int MAX_DEPTH = 6;
|
||||
|
||||
private volatile Float temperatureC;
|
||||
/** The place the head unit named — what ABRP's address is checked against. */
|
||||
private volatile String place = "";
|
||||
/** Why the last broadcast produced nothing, for the log. Null once one produced something. */
|
||||
private volatile String problem = "no broadcast yet";
|
||||
private volatile long receivedMs = 0L;
|
||||
|
||||
/** Outside temperature in °C, or null while no broadcast has carried one. */
|
||||
Float temperatureC() {
|
||||
return temperatureC;
|
||||
}
|
||||
|
||||
String place() {
|
||||
return place;
|
||||
}
|
||||
|
||||
long receivedMs() {
|
||||
return receivedMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* One short token for the upload log: the place a reading came from, or the reason there
|
||||
* is none. "no broadcast yet" and "no temperature in {...}" call for different fixes and
|
||||
* are indistinguishable from the ABRP side.
|
||||
*/
|
||||
String diagnostic() {
|
||||
if (temperatureC == null) return problem;
|
||||
String age = (System.currentTimeMillis() - receivedMs) / 60000 + "min";
|
||||
return (place.isEmpty() ? "ok" : place) + "/" + age;
|
||||
}
|
||||
|
||||
/** Reads whatever the head unit just broadcast. Never throws: this runs in a receiver. */
|
||||
void accept(Intent intent) {
|
||||
if (intent == null) {
|
||||
// Only reached from the sticky probe, which answers null when the platform is
|
||||
// holding no such broadcast. Says nothing about whether one will ever be sent.
|
||||
if (temperatureC == null) problem = "none held, none sent yet";
|
||||
return;
|
||||
}
|
||||
acceptPayload(payloadOf(intent));
|
||||
}
|
||||
|
||||
/** Restores the last reading across a restart — see the service's prefs. */
|
||||
void restore(float celsius, String named, long whenMs) {
|
||||
temperatureC = celsius;
|
||||
place = named == null ? "" : named;
|
||||
receivedMs = whenMs;
|
||||
problem = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The half of {@link #accept} that has no Android in it, so the search below is testable
|
||||
* against the payloads it might be handed rather than only against the one it expects.
|
||||
*/
|
||||
void acceptPayload(String payload) {
|
||||
try {
|
||||
if (payload == null) {
|
||||
problem = "broadcast carried no JSON";
|
||||
return;
|
||||
}
|
||||
JSONObject json = new JSONObject(payload);
|
||||
Float celsius = toCelsius(findNumber(json, "temp", 0), findUnit(json, 0));
|
||||
if (celsius == null) {
|
||||
problem = "no temperature in " + truncate(payload);
|
||||
return;
|
||||
}
|
||||
temperatureC = celsius;
|
||||
String named = findText(json, 0);
|
||||
place = named == null ? "" : named;
|
||||
receivedMs = System.currentTimeMillis();
|
||||
problem = null;
|
||||
} catch (Exception e) {
|
||||
// A payload that is not the JSON this expects is a finding, not a crash.
|
||||
problem = e.getClass().getSimpleName() + " reading broadcast";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The JSON the broadcast carries. Named extras first, then any string extra that looks
|
||||
* like an object — the extra's name is as unverified as the rest of the payload, and a
|
||||
* renamed key is not a reason to give up on a broadcast that is otherwise readable.
|
||||
*/
|
||||
private static String payloadOf(Intent intent) {
|
||||
Bundle extras = intent.getExtras();
|
||||
if (extras == null) return null;
|
||||
String named = asJson(extras.get("weather"));
|
||||
if (named != null) return named;
|
||||
for (String key : extras.keySet()) {
|
||||
String candidate = asJson(extras.get(key));
|
||||
if (candidate != null) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String asJson(Object value) {
|
||||
if (!(value instanceof String)) return null;
|
||||
String text = ((String) value).trim();
|
||||
return text.startsWith("{") ? text : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The first number under a key whose name mentions [needle], at any depth.
|
||||
*
|
||||
* A search rather than a path: the payload's own field names are the only thing here that
|
||||
* is self-describing, and they survive a layout change that a field order would not.
|
||||
*/
|
||||
private static Double findNumber(Object node, String needle, int depth) {
|
||||
if (depth > MAX_DEPTH) return null;
|
||||
if (node instanceof JSONObject) {
|
||||
JSONObject object = (JSONObject) node;
|
||||
for (java.util.Iterator<String> keys = object.keys(); keys.hasNext(); ) {
|
||||
String key = keys.next();
|
||||
Object value = object.opt(key);
|
||||
if (key.toLowerCase(java.util.Locale.US).contains(needle)) {
|
||||
Double number = asNumber(value);
|
||||
if (number != null) return number;
|
||||
}
|
||||
Double nested = findNumber(value, needle, depth + 1);
|
||||
if (nested != null) return nested;
|
||||
}
|
||||
} else if (node instanceof JSONArray) {
|
||||
JSONArray array = (JSONArray) node;
|
||||
for (int i = 0; i < array.length(); i++) {
|
||||
Double nested = findNumber(array.opt(i), needle, depth + 1);
|
||||
if (nested != null) return nested;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Numbers arrive as numbers or as strings depending on the producer; both count. */
|
||||
private static Double asNumber(Object value) {
|
||||
if (value instanceof Number) return ((Number) value).doubleValue();
|
||||
if (value instanceof String) {
|
||||
try {
|
||||
return Double.valueOf(((String) value).trim());
|
||||
} catch (NumberFormatException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The unit the payload names, if it names one at all. */
|
||||
private static String findUnit(Object node, int depth) {
|
||||
if (depth > MAX_DEPTH) return null;
|
||||
if (node instanceof JSONObject) {
|
||||
JSONObject object = (JSONObject) node;
|
||||
for (java.util.Iterator<String> keys = object.keys(); keys.hasNext(); ) {
|
||||
String key = keys.next();
|
||||
Object value = object.opt(key);
|
||||
if (key.toLowerCase(java.util.Locale.US).contains("unit")
|
||||
&& value instanceof String) {
|
||||
return (String) value;
|
||||
}
|
||||
String nested = findUnit(value, depth + 1);
|
||||
if (nested != null) return nested;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The place the reading is for — whatever the payload calls it. */
|
||||
private static String findText(Object node, int depth) {
|
||||
if (depth > MAX_DEPTH || !(node instanceof JSONObject)) return null;
|
||||
JSONObject object = (JSONObject) node;
|
||||
for (java.util.Iterator<String> keys = object.keys(); keys.hasNext(); ) {
|
||||
String key = keys.next();
|
||||
String lower = key.toLowerCase(java.util.Locale.US);
|
||||
Object value = object.opt(key);
|
||||
if ((lower.contains("address") || lower.contains("city") || lower.contains("poi"))
|
||||
&& value instanceof String && !((String) value).trim().isEmpty()) {
|
||||
return ((String) value).trim();
|
||||
}
|
||||
String nested = findText(value, depth + 1);
|
||||
if (nested != null) return nested;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Celsius, or null when the payload named a unit this does not know.
|
||||
*
|
||||
* An unnamed unit is taken as Celsius: the head unit displays °C on this car, and the
|
||||
* value is sanity-checked by the caller anyway. A named Fahrenheit is converted, because
|
||||
* passing 81 °F through as 81 °C is worse than reporting nothing.
|
||||
*/
|
||||
private static Float toCelsius(Double value, String unit) {
|
||||
if (value == null) return null;
|
||||
String name = unit == null ? "" : unit.trim().toUpperCase(java.util.Locale.US);
|
||||
if (name.contains("F")) return (float) ((value - 32.0) * 5.0 / 9.0);
|
||||
return value.floatValue();
|
||||
}
|
||||
|
||||
private static String truncate(String payload) {
|
||||
return payload.length() <= RAW_SAMPLE_CHARS
|
||||
? payload
|
||||
: payload.substring(0, RAW_SAMPLE_CHARS) + "…";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,965 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import android.Manifest;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.drawable.ColorDrawable;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
import android.widget.AdapterView;
|
||||
import android.widget.ArrayAdapter;
|
||||
import android.widget.Button;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.Spinner;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.activity.result.ActivityResultLauncher;
|
||||
import androidx.activity.result.contract.ActivityResultContracts;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.core.app.ActivityCompat;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
import androidx.viewpager2.widget.ViewPager2;
|
||||
|
||||
import com.google.android.material.switchmaterial.SwitchMaterial;
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
import com.google.android.material.button.MaterialButton;
|
||||
import com.google.android.material.textfield.TextInputEditText;
|
||||
import com.google.android.material.textfield.TextInputLayout;
|
||||
import com.evsuite.hardware.EVHardware;
|
||||
import com.evsuite.hardware.FirmwareInfo;
|
||||
import com.evsuite.hardware.telemetry.EnergySnapshot;
|
||||
import com.evsuite.hardware.telemetry.EnergyTelemetryReader;
|
||||
|
||||
|
||||
public class MainActivity extends AppCompatActivity {
|
||||
|
||||
private static final int LOCATION_PERMISSION_REQUEST = 100;
|
||||
/** Shared with {@link AbrpUploadService}, which reads it on every tick. */
|
||||
static final String WIFI_AUTO_KEY = "wifi_auto_connect";
|
||||
private static final String REPOSITORY_URL = "https://github.com/fatihdonmezdev/EVABRP";
|
||||
|
||||
/** Top-bar tabs, in page order. Parallel to {@link #panes}. */
|
||||
private static final int[] TAB_IDS =
|
||||
{ R.id.tabAbrp, R.id.tabService, R.id.tabVehicle, R.id.tabWifi, R.id.tabLog };
|
||||
|
||||
// Status colours come from the palette, not from the Material swatches: #4CAF50 and
|
||||
// #F44336 sit around 4:1 on this background, which disappears behind a sunlit
|
||||
// reflection. The palette entries are the lightened variants (7:1 and above).
|
||||
private static final int COLOR_OK = 0xFF8FE6A6;
|
||||
private static final int COLOR_ERROR = 0xFFFF9A9A;
|
||||
private static final int COLOR_PENDING = 0xFFC6CFD8;
|
||||
|
||||
private TextInputLayout apiKeyLayout;
|
||||
private TextInputEditText apiKeyInput;
|
||||
private TextInputLayout tokenLayout;
|
||||
private TextInputEditText tokenInput;
|
||||
private SwitchMaterial serviceSwitch;
|
||||
private SwitchMaterial autostartSwitch;
|
||||
private TextView statusText;
|
||||
private Button testButton;
|
||||
private View connectionStatusRow;
|
||||
private View connectionIndicator;
|
||||
private TextView connectionStatusText;
|
||||
private Spinner intervalSpinner;
|
||||
private SwitchMaterial boostSwitch;
|
||||
private TextInputEditText lowSocInput;
|
||||
private TextInputLayout lowSocLayout;
|
||||
private TextView callLogText;
|
||||
private View abrpPane;
|
||||
private View servicePane;
|
||||
private View logPane;
|
||||
private View vehiclePane;
|
||||
private View wifiPane;
|
||||
|
||||
/** The five pages, in the order the top bar lists them. Parallel to {@link #TAB_IDS}. */
|
||||
private View[] panes;
|
||||
|
||||
/** Wi-Fi page. The helper is the same one the upload service uses on its own tick. */
|
||||
private WifiAutoConnect wifi;
|
||||
private TextView wifiSsid, wifiState, wifiSavedList;
|
||||
|
||||
/**
|
||||
* Value cells of the Vehicle page, looked up once and refreshed on the UI tick.
|
||||
*
|
||||
* Read straight from {@link EnergyTelemetryReader} rather than from the uploader: the
|
||||
* page shows what the car reports, whether or not the upload service is running, and a
|
||||
* signal the vehicle does not publish must show a dash rather than a zero.
|
||||
*/
|
||||
private EnergyTelemetryReader vehicleReader;
|
||||
private TextView vehSoc, vehRange, vehPower, vehSpeed, vehOdometer,
|
||||
vehCharging, vehBattTemp, vehExtTemp, vehCabinTemp, vehHvac,
|
||||
vehCapacity, vehSoe,
|
||||
vehTireFl, vehTireFr, vehTireRl, vehTireRr;
|
||||
|
||||
/**
|
||||
* Inflates a page with no parent, then gives it the MATCH_PARENT/MATCH_PARENT layout
|
||||
* params ViewPager2 requires of its items — inflating detached leaves them null, and
|
||||
* the pager throws rather than guessing.
|
||||
*/
|
||||
private View inflatePane(int layoutRes) {
|
||||
View pane = getLayoutInflater().inflate(layoutRes, null, false);
|
||||
pane.setLayoutParams(new android.view.ViewGroup.LayoutParams(
|
||||
android.view.ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
android.view.ViewGroup.LayoutParams.MATCH_PARENT));
|
||||
return pane;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wires the top bar to the pager: every tab is a page, and every page is a tab.
|
||||
*
|
||||
* Each page has its own view type, so the pager asks for it once and keeps it: these
|
||||
* three views are the activity's own, held in {@link #panes}, not rows to be recycled.
|
||||
*/
|
||||
private void setUpPager() {
|
||||
ViewPager2 pager = findViewById(R.id.content);
|
||||
pager.setAdapter(new RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
@Override public int getItemCount() { return panes.length; }
|
||||
@Override public int getItemViewType(int position) { return position; }
|
||||
|
||||
@NonNull @Override
|
||||
public RecyclerView.ViewHolder onCreateViewHolder(@NonNull android.view.ViewGroup parent,
|
||||
int viewType) {
|
||||
return new RecyclerView.ViewHolder(panes[viewType]) {};
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
|
||||
// Nothing to bind: the pages are built once in onCreate and keep their own
|
||||
// state, exactly as they did when they were siblings in the layout.
|
||||
}
|
||||
});
|
||||
// All four stay alive. They are cheap, and the service switch, the call log and
|
||||
// the vehicle readings are refreshed by the tick whether or not their page is on screen.
|
||||
pager.setOffscreenPageLimit(4);
|
||||
pager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() {
|
||||
@Override public void onPageSelected(int position) { markCurrentPage(position); }
|
||||
});
|
||||
|
||||
for (int i = 0; i < TAB_IDS.length; i++) {
|
||||
final int index = i;
|
||||
// Animated, so the button does the same thing the swipe does: the direction of
|
||||
// travel is what tells the driver where they are in the row.
|
||||
findViewById(TAB_IDS[i]).setOnClickListener(v -> pager.setCurrentItem(index, true));
|
||||
}
|
||||
markCurrentPage(pager.getCurrentItem());
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the top-bar tab of the page on screen.
|
||||
*
|
||||
* Only isSelected is set: fill, text, icon and stroke come from the
|
||||
* res/color/nav_tab_*.xml selectors applied by the Widget.EV.NavTab style. isSelected
|
||||
* also serves TalkBack, which announces the current destination.
|
||||
*/
|
||||
private void markCurrentPage(int position) {
|
||||
for (int i = 0; i < TAB_IDS.length; i++) {
|
||||
findViewById(TAB_IDS[i]).setSelected(i == position);
|
||||
}
|
||||
}
|
||||
|
||||
/** Refreshes state + call log while the screen is visible. */
|
||||
private final android.os.Handler uiHandler = new android.os.Handler(android.os.Looper.getMainLooper());
|
||||
private final Runnable uiRefresh = new Runnable() {
|
||||
@Override public void run() {
|
||||
refreshStatus();
|
||||
refreshCallLog();
|
||||
refreshVehicle();
|
||||
refreshWifi();
|
||||
uiHandler.postDelayed(this, 2_000L);
|
||||
}
|
||||
};
|
||||
|
||||
private SharedPreferences prefs;
|
||||
/** Credentials only — encrypted at rest, see {@link SecurePrefs}. */
|
||||
private SharedPreferences securePrefs;
|
||||
|
||||
/**
|
||||
* Picks an ABRP config text file. Only reachable on devices that ship a document picker —
|
||||
* the MG4 head unit does not, which is why {@link #startConfigImport()} scans the
|
||||
* app-specific folders first and only falls back to this.
|
||||
*/
|
||||
private final ActivityResultLauncher<String[]> configPicker =
|
||||
registerForActivityResult(new ActivityResultContracts.OpenDocument(), uri -> {
|
||||
if (uri != null) importConfig(uri);
|
||||
});
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_main);
|
||||
|
||||
prefs = getSharedPreferences("abrp_prefs", MODE_PRIVATE);
|
||||
securePrefs = SecurePrefs.get(this);
|
||||
|
||||
// The three pages are inflated here, once, and handed to the pager as fixed,
|
||||
// non-recycled items. That is what lets every widget below be looked up now and
|
||||
// held for the life of the activity, the way it was when the panes were siblings
|
||||
// in activity_main.xml — a recycled page would invalidate these references as
|
||||
// soon as the user swiped away from it.
|
||||
abrpPane = inflatePane(R.layout.pane_abrp);
|
||||
servicePane = inflatePane(R.layout.pane_service);
|
||||
vehiclePane = inflatePane(R.layout.pane_vehicle);
|
||||
wifiPane = inflatePane(R.layout.pane_wifi);
|
||||
logPane = inflatePane(R.layout.pane_log);
|
||||
panes = new View[] { abrpPane, servicePane, vehiclePane, wifiPane, logPane };
|
||||
|
||||
bindVehiclePane();
|
||||
bindWifiPane();
|
||||
|
||||
apiKeyLayout = abrpPane.findViewById(R.id.api_key_layout);
|
||||
apiKeyInput = abrpPane.findViewById(R.id.api_key_input);
|
||||
tokenLayout = abrpPane.findViewById(R.id.token_layout);
|
||||
tokenInput = abrpPane.findViewById(R.id.token_input);
|
||||
serviceSwitch = servicePane.findViewById(R.id.service_switch);
|
||||
autostartSwitch = servicePane.findViewById(R.id.autostart_switch);
|
||||
statusText = servicePane.findViewById(R.id.status_text);
|
||||
testButton = abrpPane.findViewById(R.id.test_button);
|
||||
connectionStatusRow = abrpPane.findViewById(R.id.connection_status_row);
|
||||
connectionIndicator = abrpPane.findViewById(R.id.connection_indicator);
|
||||
connectionStatusText = abrpPane.findViewById(R.id.connection_status_text);
|
||||
intervalSpinner = servicePane.findViewById(R.id.interval_spinner);
|
||||
boostSwitch = servicePane.findViewById(R.id.boost_switch);
|
||||
lowSocInput = servicePane.findViewById(R.id.low_soc_input);
|
||||
lowSocLayout = servicePane.findViewById(R.id.low_soc_layout);
|
||||
callLogText = logPane.findViewById(R.id.call_log_text);
|
||||
|
||||
setUpPager();
|
||||
findViewById(R.id.about_button).setOnClickListener(v -> showAbout());
|
||||
|
||||
bindCadenceControls();
|
||||
|
||||
// Unstable builds check for a newer pre-release; the stable flavor's UpdateHook is
|
||||
// a no-op and does not even contain the updater.
|
||||
UpdateHook.checkInBackground(this);
|
||||
|
||||
apiKeyInput.setText(savedApiKeyOrDefault());
|
||||
tokenInput.setText(securePrefs.getString(SecurePrefs.KEY_TOKEN, ""));
|
||||
serviceSwitch.setChecked(prefs.getBoolean("service_enabled", false));
|
||||
|
||||
autostartSwitch.setChecked(
|
||||
prefs.getBoolean(UploadSettings.KEY_AUTOSTART, UploadSettings.DEFAULT_AUTOSTART));
|
||||
autostartSwitch.setOnCheckedChangeListener((btn, checked) ->
|
||||
prefs.edit().putBoolean(UploadSettings.KEY_AUTOSTART, checked).apply());
|
||||
|
||||
// If the user wants the service running, kick it on every activity launch.
|
||||
// startForegroundService is idempotent — if the service is already up this
|
||||
// is a no-op aside from delivering a new intent. We don't trust
|
||||
// service_running as a sole indicator because it can be stale-true if a
|
||||
// previous process was force-killed (e.g. by `adb install -r`) without
|
||||
// onDestroy running.
|
||||
boolean enabled = prefs.getBoolean("service_enabled", false);
|
||||
boolean haveCreds = !securePrefs.getString(SecurePrefs.KEY_TOKEN, "").trim().isEmpty()
|
||||
&& !securePrefs.getString(SecurePrefs.KEY_API_KEY, "").trim().isEmpty();
|
||||
if (enabled && haveCreds) {
|
||||
// Asked for first, and deliberately: the service declares the `location`
|
||||
// foreground type, and since API 34 starting it without the permission behind
|
||||
// that type is a SecurityException in its own onCreate. Started this way round,
|
||||
// the very first launch after an install could never bring the uploader up.
|
||||
requestLocationPermissionIfNeeded();
|
||||
startForegroundService(new Intent(this, AbrpUploadService.class));
|
||||
}
|
||||
|
||||
abrpPane.findViewById(R.id.save_button).setOnClickListener(v -> saveCredentials());
|
||||
testButton.setOnClickListener(v -> testConnection());
|
||||
abrpPane.findViewById(R.id.import_button).setOnClickListener(v -> startConfigImport());
|
||||
wireProbeIfDebug();
|
||||
|
||||
serviceSwitch.setOnCheckedChangeListener((btn, checked) -> {
|
||||
if (checked) {
|
||||
String apiKey = textOf(apiKeyInput);
|
||||
String token = textOf(tokenInput);
|
||||
if (apiKey.isEmpty() || token.isEmpty()) {
|
||||
serviceSwitch.setChecked(false);
|
||||
if (apiKey.isEmpty()) apiKeyLayout.setError(getString(R.string.api_key_required));
|
||||
if (token.isEmpty()) tokenLayout.setError(getString(R.string.token_required));
|
||||
return;
|
||||
}
|
||||
apiKeyLayout.setError(null);
|
||||
tokenLayout.setError(null);
|
||||
prefs.edit().putBoolean("service_enabled", true).apply();
|
||||
startForegroundService(new Intent(this, AbrpUploadService.class));
|
||||
requestLocationPermissionIfNeeded();
|
||||
} else {
|
||||
prefs.edit().putBoolean("service_enabled", false).apply();
|
||||
stopService(new Intent(this, AbrpUploadService.class));
|
||||
}
|
||||
refreshStatus();
|
||||
});
|
||||
}
|
||||
|
||||
private void showAbout() {
|
||||
String version;
|
||||
try {
|
||||
version = getPackageManager().getPackageInfo(getPackageName(), 0).versionName;
|
||||
} catch (PackageManager.NameNotFoundException e) {
|
||||
version = getString(R.string.about_version_unknown);
|
||||
}
|
||||
View content = getLayoutInflater().inflate(R.layout.dialog_about, null);
|
||||
content.<TextView>findViewById(R.id.about_version)
|
||||
.setText(getString(R.string.about_version, version));
|
||||
ImageView qr = content.findViewById(R.id.about_qr_code);
|
||||
android.graphics.Bitmap bitmap = QrCode.generate(REPOSITORY_URL, 416);
|
||||
if (bitmap != null) qr.setImageBitmap(bitmap);
|
||||
content.findViewById(R.id.about_repository).setOnClickListener(v ->
|
||||
startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(REPOSITORY_URL))));
|
||||
androidx.appcompat.app.AlertDialog dialog = new MaterialAlertDialogBuilder(this)
|
||||
.setView(content)
|
||||
.create();
|
||||
content.<MaterialButton>findViewById(R.id.about_close).setOnClickListener(v -> dialog.dismiss());
|
||||
dialog.show();
|
||||
if (dialog.getWindow() != null) {
|
||||
dialog.getWindow().setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onResume() {
|
||||
super.onResume();
|
||||
apiKeyInput.setText(savedApiKeyOrDefault());
|
||||
tokenInput.setText(securePrefs.getString(SecurePrefs.KEY_TOKEN, ""));
|
||||
serviceSwitch.setChecked(prefs.getBoolean("service_enabled", false));
|
||||
autostartSwitch.setChecked(
|
||||
prefs.getBoolean(UploadSettings.KEY_AUTOSTART, UploadSettings.DEFAULT_AUTOSTART));
|
||||
// Poll state and log only while the screen is up; onPause cancels it.
|
||||
uiHandler.post(uiRefresh);
|
||||
}
|
||||
|
||||
// ---------- Credentials ----------
|
||||
|
||||
/**
|
||||
* The SAIC open-source gateway publishes this shared ABRP key for compatible clients.
|
||||
* A user-supplied key saved in encrypted preferences always takes precedence.
|
||||
*/
|
||||
private String savedApiKeyOrDefault() {
|
||||
String savedApiKey = securePrefs.getString(SecurePrefs.KEY_API_KEY, "");
|
||||
return savedApiKey == null || savedApiKey.trim().isEmpty()
|
||||
? getString(R.string.default_abrp_api_key)
|
||||
: savedApiKey;
|
||||
}
|
||||
|
||||
private void saveCredentials() {
|
||||
String apiKey = textOf(apiKeyInput);
|
||||
String token = textOf(tokenInput);
|
||||
boolean valid = true;
|
||||
|
||||
if (apiKey.isEmpty()) {
|
||||
apiKeyLayout.setError(getString(R.string.api_key_required));
|
||||
valid = false;
|
||||
} else {
|
||||
apiKeyLayout.setError(null);
|
||||
}
|
||||
if (token.isEmpty()) {
|
||||
tokenLayout.setError(getString(R.string.token_required));
|
||||
valid = false;
|
||||
} else {
|
||||
tokenLayout.setError(null);
|
||||
}
|
||||
if (!valid) return;
|
||||
|
||||
securePrefs.edit()
|
||||
.putString(SecurePrefs.KEY_API_KEY, apiKey)
|
||||
.putString(SecurePrefs.KEY_TOKEN, token)
|
||||
.apply();
|
||||
|
||||
if (serviceSwitch.isChecked()) {
|
||||
startForegroundService(new Intent(this, AbrpUploadService.class));
|
||||
}
|
||||
refreshStatus();
|
||||
}
|
||||
|
||||
// ---------- Config import ----------
|
||||
|
||||
/**
|
||||
* The MG4 head unit ships no document picker — SAF answers "FileManagement is no supported
|
||||
* on this device" — so the file is found by scanning instead. getExternalFilesDirs() returns
|
||||
* this app's own folder on internal storage and on every mounted volume including a USB
|
||||
* stick, and those need no storage permission at any API level. The picker stays as the
|
||||
* fallback for phones and tablets, where the user may keep the file anywhere.
|
||||
*/
|
||||
private void startConfigImport() {
|
||||
java.io.File found = findConfigFile();
|
||||
if (found != null) {
|
||||
importConfig(found);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Any MIME: the MG4 Files app tags .txt inconsistently, so filtering by type hides
|
||||
// the very file the user is trying to pick. They select it by name instead.
|
||||
configPicker.launch(new String[]{"*/*"});
|
||||
} catch (android.content.ActivityNotFoundException e) {
|
||||
setConnectionStatus(COLOR_ERROR, getString(R.string.import_err_not_found, configDirHint()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* First readable file in an app-specific folder that parses as a config. Names are not
|
||||
* filtered: the user copies one file to the folder, whatever they called it.
|
||||
*/
|
||||
private java.io.File findConfigFile() {
|
||||
for (java.io.File dir : getExternalFilesDirs(null)) {
|
||||
if (dir == null) continue;
|
||||
java.io.File[] files = dir.listFiles();
|
||||
if (files == null) continue;
|
||||
for (java.io.File f : files) {
|
||||
if (f.isFile() && f.canRead() && f.length() > 0 && f.length() <= 64 * 1024
|
||||
&& !ConfigImport.parse(readText(f)).isEmpty()) {
|
||||
return f;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** File text, or "" when unreadable — callers treat that as "not a config". */
|
||||
private String readText(java.io.File file) {
|
||||
try (java.io.InputStream in = new java.io.FileInputStream(file)) {
|
||||
return readCapped(in);
|
||||
} catch (Exception e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cap the read so a wrong file (a huge binary picked by mistake) can't be slurped whole
|
||||
* into memory before we find out it isn't a config.
|
||||
*/
|
||||
private String readCapped(java.io.InputStream in) throws java.io.IOException {
|
||||
java.io.ByteArrayOutputStream buf = new java.io.ByteArrayOutputStream();
|
||||
byte[] chunk = new byte[4096];
|
||||
int n;
|
||||
while ((n = in.read(chunk)) != -1 && buf.size() < 64 * 1024) buf.write(chunk, 0, n);
|
||||
return buf.toString(java.nio.charset.StandardCharsets.UTF_8.name());
|
||||
}
|
||||
|
||||
/** Where the user should drop the file, shown when nothing was found. */
|
||||
private String configDirHint() {
|
||||
java.io.File[] dirs = getExternalFilesDirs(null);
|
||||
return dirs.length > 0 && dirs[0] != null ? dirs[0].getAbsolutePath() : "";
|
||||
}
|
||||
|
||||
private void importConfig(java.io.File file) {
|
||||
String text = readText(file);
|
||||
if (text.isEmpty()) {
|
||||
setConnectionStatus(COLOR_ERROR, getString(R.string.import_err_read));
|
||||
return;
|
||||
}
|
||||
applyConfig(text);
|
||||
}
|
||||
|
||||
private void importConfig(android.net.Uri uri) {
|
||||
String text;
|
||||
try (java.io.InputStream in = getContentResolver().openInputStream(uri)) {
|
||||
if (in == null) throw new java.io.IOException("no stream");
|
||||
text = readCapped(in);
|
||||
} catch (Exception e) {
|
||||
setConnectionStatus(COLOR_ERROR, getString(R.string.import_err_read));
|
||||
return;
|
||||
}
|
||||
applyConfig(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies whatever the config text sets. Credentials go straight to the encrypted store;
|
||||
* cadence keys go to the plain prefs the same way the manual controls write them. Absent
|
||||
* keys are left untouched, so a file carrying only the credentials does not wipe a cadence
|
||||
* the user already tuned.
|
||||
*/
|
||||
private void applyConfig(String text) {
|
||||
ConfigImport config = ConfigImport.parse(text);
|
||||
if (config.isEmpty()) {
|
||||
setConnectionStatus(COLOR_ERROR, getString(R.string.import_err_empty));
|
||||
return;
|
||||
}
|
||||
|
||||
if (config.apiKey != null) {
|
||||
securePrefs.edit().putString(SecurePrefs.KEY_API_KEY, config.apiKey).apply();
|
||||
}
|
||||
if (config.token != null) {
|
||||
securePrefs.edit().putString(SecurePrefs.KEY_TOKEN, config.token).apply();
|
||||
}
|
||||
SharedPreferences.Editor edit = prefs.edit();
|
||||
if (config.intervalSec != null) {
|
||||
edit.putInt(UploadSettings.KEY_INTERVAL_SEC, config.intervalSec);
|
||||
}
|
||||
if (config.boostLowSoc != null) {
|
||||
edit.putBoolean(UploadSettings.KEY_BOOST_LOW_SOC, config.boostLowSoc);
|
||||
}
|
||||
if (config.lowSocPercent != null) {
|
||||
edit.putInt(UploadSettings.KEY_LOW_SOC_PERCENT, config.lowSocPercent);
|
||||
}
|
||||
edit.apply();
|
||||
|
||||
// Re-read every control from prefs so the screen shows what was imported.
|
||||
apiKeyInput.setText(savedApiKeyOrDefault());
|
||||
tokenInput.setText(securePrefs.getString(SecurePrefs.KEY_TOKEN, ""));
|
||||
apiKeyLayout.setError(null);
|
||||
tokenLayout.setError(null);
|
||||
bindCadenceControls();
|
||||
AbrpUploadService.reloadSettings();
|
||||
|
||||
if (serviceSwitch.isChecked()) {
|
||||
startForegroundService(new Intent(this, AbrpUploadService.class));
|
||||
}
|
||||
setConnectionStatus(COLOR_OK, getString(R.string.import_ok));
|
||||
refreshStatus();
|
||||
}
|
||||
|
||||
// ---------- Connection test ----------
|
||||
|
||||
private void testConnection() {
|
||||
String apiKey = textOf(apiKeyInput);
|
||||
String token = textOf(tokenInput);
|
||||
|
||||
if (apiKey.isEmpty() || token.isEmpty()) {
|
||||
if (apiKey.isEmpty()) apiKeyLayout.setError(getString(R.string.api_key_required));
|
||||
if (token.isEmpty()) tokenLayout.setError(getString(R.string.token_required));
|
||||
return;
|
||||
}
|
||||
|
||||
setConnectionStatus(COLOR_PENDING, getString(R.string.conn_testing));
|
||||
testButton.setEnabled(false);
|
||||
|
||||
new Thread(() -> {
|
||||
String result = pingAbrp(apiKey, token);
|
||||
runOnUiThread(() -> {
|
||||
testButton.setEnabled(true);
|
||||
if (result == null) {
|
||||
setConnectionStatus(COLOR_OK, getString(R.string.conn_ok));
|
||||
} else {
|
||||
setConnectionStatus(COLOR_ERROR, result);
|
||||
}
|
||||
});
|
||||
}).start();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a minimal test request to the ABRP API.
|
||||
* Returns null on success, or a short error string on failure.
|
||||
*/
|
||||
private String pingAbrp(String apiKey, String token) {
|
||||
try {
|
||||
// Read-only check. The previous version POSTed {"utc":...,"soc":0} to the LIVE
|
||||
// /tlm/send endpoint, i.e. told ABRP the car was at 0% every time the user
|
||||
// pressed Test, wrecking the route plan it had computed.
|
||||
AbrpApi.Response response = AbrpApi.verifyCredentials(apiKey, token);
|
||||
|
||||
if (response.code == 200) return null;
|
||||
if (response.code == 401) return getString(R.string.conn_err_auth);
|
||||
return getString(R.string.conn_err_http, response.code);
|
||||
|
||||
} catch (java.net.UnknownHostException e) {
|
||||
return getString(R.string.conn_err_no_internet);
|
||||
} catch (java.net.SocketTimeoutException e) {
|
||||
return getString(R.string.conn_err_timeout);
|
||||
} catch (Exception e) {
|
||||
return e.getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPause() {
|
||||
super.onPause();
|
||||
// Stop polling off-screen: this activity has no reason to spend cycles then.
|
||||
uiHandler.removeCallbacks(uiRefresh);
|
||||
}
|
||||
|
||||
// ---------- Cadence configuration ----------
|
||||
|
||||
private void bindCadenceControls() {
|
||||
UploadSettings current = UploadSettings.from(prefs);
|
||||
|
||||
String[] labels = new String[UploadSettings.INTERVAL_CHOICES_SEC.length];
|
||||
int selected = 0;
|
||||
for (int i = 0; i < UploadSettings.INTERVAL_CHOICES_SEC.length; i++) {
|
||||
int sec = UploadSettings.INTERVAL_CHOICES_SEC[i];
|
||||
labels[i] = sec >= 60
|
||||
? getResources().getQuantityString(R.plurals.interval_minutes, sec / 60, sec / 60)
|
||||
: getResources().getQuantityString(R.plurals.interval_seconds, sec, sec);
|
||||
if (sec == current.intervalSec) selected = i;
|
||||
}
|
||||
ArrayAdapter<String> adapter = new ArrayAdapter<>(
|
||||
this, android.R.layout.simple_spinner_item, labels);
|
||||
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
|
||||
intervalSpinner.setAdapter(adapter);
|
||||
intervalSpinner.setSelection(selected);
|
||||
intervalSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
|
||||
@Override
|
||||
public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
|
||||
prefs.edit().putInt(UploadSettings.KEY_INTERVAL_SEC,
|
||||
UploadSettings.INTERVAL_CHOICES_SEC[position]).apply();
|
||||
// Takes effect on the next cycle — no service restart needed.
|
||||
AbrpUploadService.reloadSettings();
|
||||
}
|
||||
@Override public void onNothingSelected(AdapterView<?> parent) { }
|
||||
});
|
||||
|
||||
boostSwitch.setChecked(current.boostLowSoc);
|
||||
lowSocLayout.setEnabled(current.boostLowSoc);
|
||||
lowSocInput.setEnabled(current.boostLowSoc);
|
||||
boostSwitch.setOnCheckedChangeListener((button, checked) -> {
|
||||
prefs.edit().putBoolean(UploadSettings.KEY_BOOST_LOW_SOC, checked).apply();
|
||||
lowSocLayout.setEnabled(checked);
|
||||
lowSocInput.setEnabled(checked);
|
||||
AbrpUploadService.reloadSettings();
|
||||
});
|
||||
|
||||
lowSocInput.setText(String.valueOf(current.lowSocPercent));
|
||||
lowSocInput.setOnFocusChangeListener((view, hasFocus) -> {
|
||||
if (!hasFocus) saveLowSocThreshold();
|
||||
});
|
||||
}
|
||||
|
||||
/** Clamped to 1-99: 0 would never trigger and 100 would boost permanently. */
|
||||
private void saveLowSocThreshold() {
|
||||
CharSequence raw = lowSocInput.getText();
|
||||
int value;
|
||||
try {
|
||||
value = Integer.parseInt(raw == null ? "" : raw.toString().trim());
|
||||
} catch (NumberFormatException e) {
|
||||
value = UploadSettings.DEFAULT_LOW_SOC_PERCENT;
|
||||
}
|
||||
value = Math.max(1, Math.min(99, value));
|
||||
lowSocInput.setText(String.valueOf(value));
|
||||
prefs.edit().putInt(UploadSettings.KEY_LOW_SOC_PERCENT, value).apply();
|
||||
AbrpUploadService.reloadSettings();
|
||||
}
|
||||
|
||||
// ---------- Wi-Fi page ----------
|
||||
|
||||
private void bindWifiPane() {
|
||||
wifi = new WifiAutoConnect(getApplicationContext());
|
||||
wifiSsid = wifiPane.findViewById(R.id.wifi_ssid);
|
||||
wifiState = wifiPane.findViewById(R.id.wifi_state);
|
||||
wifiSavedList = wifiPane.findViewById(R.id.wifi_saved_list);
|
||||
|
||||
SwitchMaterial auto = wifiPane.findViewById(R.id.wifi_auto_switch);
|
||||
auto.setChecked(prefs.getBoolean(WIFI_AUTO_KEY, true));
|
||||
auto.setOnCheckedChangeListener((btn, checked) ->
|
||||
prefs.edit().putBoolean(WIFI_AUTO_KEY, checked).apply());
|
||||
|
||||
wifiPane.findViewById(R.id.wifi_connect_button).setOnClickListener(v -> {
|
||||
// force: the driver is asking now, so the rate limit does not apply.
|
||||
WifiAutoConnect.Result r = wifi.ensureConnected(true);
|
||||
wifiState.setText(r.message);
|
||||
refreshWifi();
|
||||
});
|
||||
}
|
||||
|
||||
private void refreshWifi() {
|
||||
if (wifi == null) return;
|
||||
String ssid = wifi.currentSsid();
|
||||
wifiSsid.setText(ssid == null ? getString(R.string.wifi_not_connected) : ssid);
|
||||
if (ssid != null) {
|
||||
wifiState.setText("Bağlı ♥");
|
||||
} else if (!wifi.isWifiEnabled()) {
|
||||
wifiState.setText("Wi-Fi kapalı");
|
||||
}
|
||||
// Left alone otherwise: a message the button just set says more about what is
|
||||
// happening than "not connected" would.
|
||||
|
||||
java.util.List<String> saved = wifi.savedNetworkNames();
|
||||
wifiSavedList.setText(saved.isEmpty()
|
||||
? getString(R.string.wifi_saved_empty)
|
||||
: android.text.TextUtils.join("\n", saved));
|
||||
}
|
||||
|
||||
// ---------- Vehicle page ----------
|
||||
|
||||
/** Looks up the value cells once; the labels are static and never touched from code. */
|
||||
private void bindVehiclePane() {
|
||||
vehSoc = vehiclePane.findViewById(R.id.vehicle_soc);
|
||||
vehRange = vehiclePane.findViewById(R.id.vehicle_range);
|
||||
vehPower = vehiclePane.findViewById(R.id.vehicle_power);
|
||||
vehSpeed = vehiclePane.findViewById(R.id.vehicle_speed);
|
||||
vehOdometer = vehiclePane.findViewById(R.id.vehicle_odometer);
|
||||
vehCharging = vehiclePane.findViewById(R.id.vehicle_charging);
|
||||
vehBattTemp = vehiclePane.findViewById(R.id.vehicle_batt_temp);
|
||||
vehExtTemp = vehiclePane.findViewById(R.id.vehicle_ext_temp);
|
||||
vehCabinTemp = vehiclePane.findViewById(R.id.vehicle_cabin_temp);
|
||||
vehHvac = vehiclePane.findViewById(R.id.vehicle_hvac);
|
||||
vehCapacity = vehiclePane.findViewById(R.id.vehicle_capacity);
|
||||
vehSoe = vehiclePane.findViewById(R.id.vehicle_soe);
|
||||
vehTireFl = vehiclePane.findViewById(R.id.vehicle_tire_fl);
|
||||
vehTireFr = vehiclePane.findViewById(R.id.vehicle_tire_fr);
|
||||
vehTireRl = vehiclePane.findViewById(R.id.vehicle_tire_rl);
|
||||
vehTireRr = vehiclePane.findViewById(R.id.vehicle_tire_rr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads one snapshot and writes it to the page.
|
||||
*
|
||||
* The reader is built lazily and kept: constructing it binds the vendor services, which
|
||||
* is not work to repeat every two seconds. A read that throws leaves the cells as they
|
||||
* were rather than blanking the page — a transient miss is not news worth showing.
|
||||
*/
|
||||
private void refreshVehicle() {
|
||||
try {
|
||||
if (vehicleReader == null) {
|
||||
vehicleReader = new EnergyTelemetryReader(getApplicationContext());
|
||||
}
|
||||
// The timestamp is passed explicitly: its Kotlin default is not visible from Java.
|
||||
EnergySnapshot s = vehicleReader.read(System.currentTimeMillis());
|
||||
|
||||
vehSoc.setText(fmt(s.getSocPercent(), "%.0f %%"));
|
||||
vehRange.setText(fmt(s.getRangeKm(), "%.0f km"));
|
||||
vehPower.setText(fmt(s.getBatteryPowerKw(), "%.1f kW"));
|
||||
vehSpeed.setText(fmt(s.getSpeedKmh(), "%.0f km/h"));
|
||||
vehOdometer.setText(fmt(s.getOdometerKm(), "%.0f km"));
|
||||
vehBattTemp.setText(fmt(s.getBatteryTempCelsius(), "%.0f °C"));
|
||||
vehExtTemp.setText(fmt(s.getOutsideTempCelsius(), "%.0f °C"));
|
||||
vehCabinTemp.setText(fmt(s.getCabinTempCelsius(), "%.0f °C"));
|
||||
vehHvac.setText(fmt(s.getClimate().getDriverTargetCelsius(), "%.0f °C"));
|
||||
vehCapacity.setText(fmt(s.getBatteryCapacityKwh(), "%.1f kWh"));
|
||||
vehSoe.setText(fmt(s.getBatteryEnergyKwh(), "%.1f kWh"));
|
||||
|
||||
vehCharging.setText(chargingLabel(s));
|
||||
|
||||
vehTireFl.setText(fmt(s.getTirePressures().getFrontLeftKpa(), "%.0f kPa"));
|
||||
vehTireFr.setText(fmt(s.getTirePressures().getFrontRightKpa(), "%.0f kPa"));
|
||||
vehTireRl.setText(fmt(s.getTirePressures().getRearLeftKpa(), "%.0f kPa"));
|
||||
vehTireRr.setText(fmt(s.getTirePressures().getRearRightKpa(), "%.0f kPa"));
|
||||
} catch (Throwable ignored) {
|
||||
// Leave the last good reading on screen.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The charging row: the session state when the car reports one, the port otherwise.
|
||||
*
|
||||
* The codes are the vehicle's own — see EVHardware.PROP_VENDOR_CHARGE_STATUS. An
|
||||
* unrecognised code is shown as itself rather than guessed at.
|
||||
*/
|
||||
private static String chargingLabel(EnergySnapshot s) {
|
||||
Integer status = s.getChargingStatus();
|
||||
if (status != null) {
|
||||
switch (status) {
|
||||
case 1: return "AC şarj ♥";
|
||||
case 10: return "DC hızlı şarj ♥";
|
||||
case 5: return "Bağlanıyor…";
|
||||
case 7: return "Kabloda, şarj yok";
|
||||
case 8: return "Durduruldu";
|
||||
case 0: return "Şarj yok";
|
||||
default: return "Durum " + status;
|
||||
}
|
||||
}
|
||||
Boolean plugged = s.getChargePortConnected();
|
||||
if (plugged != null) return plugged ? "Bağlı ♥" : "Bağlı değil";
|
||||
return "—";
|
||||
}
|
||||
|
||||
/** A dash for a signal the car does not publish — never a zero. See pane_vehicle.xml. */
|
||||
private static String fmt(Float value, String pattern) {
|
||||
if (value == null || value.isNaN() || value.isInfinite()) return "—";
|
||||
return String.format(java.util.Locale.US, pattern, value);
|
||||
}
|
||||
|
||||
// ---------- Call log ----------
|
||||
|
||||
private void refreshCallLog() {
|
||||
java.util.List<UploadLog.Entry> entries = AbrpUploadService.log().recent();
|
||||
if (entries.isEmpty()) {
|
||||
callLogText.setText(R.string.call_log_empty);
|
||||
return;
|
||||
}
|
||||
java.text.SimpleDateFormat format =
|
||||
new java.text.SimpleDateFormat("HH:mm:ss", java.util.Locale.US);
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (UploadLog.Entry entry : entries) {
|
||||
if (sb.length() > 0) sb.append('\n');
|
||||
sb.append(entry.success ? "OK " : "ERR ")
|
||||
.append(format.format(new java.util.Date(entry.timestampMs)))
|
||||
.append(" ")
|
||||
// httpStatus 0 means the request never reached a server.
|
||||
.append(entry.httpStatus > 0 ? String.valueOf(entry.httpStatus) : "---")
|
||||
.append(" ")
|
||||
.append(entry.detail);
|
||||
// What the payload held, under the attempt it belongs to. This is the only view
|
||||
// of it the driver has: the car runs no adb, so "the field is 0 on ABRP" and
|
||||
// "the app never sent the field" look identical from the outside without it.
|
||||
if (entry.summary != null && !entry.summary.isEmpty()) {
|
||||
sb.append("\n ").append(entry.summary);
|
||||
}
|
||||
}
|
||||
callLogText.setText(sb.toString());
|
||||
}
|
||||
|
||||
// ---------- VHAL probe ----------
|
||||
|
||||
/**
|
||||
* Wires the "VHAL Probe" button that dumps the vehicle's own telemetry property
|
||||
* surface into the in-app log. The head unit runs no adb, so the screen's existing
|
||||
* log view is the only channel a diagnostic can reach the driver through.
|
||||
*
|
||||
* Present in every build, not just debug: identifying the SWI69 property ids needs to
|
||||
* happen on a production APK on the car, and EVHardware's probe is read-only, so it is
|
||||
* safe to ship. It deliberately holds no device credentials. Keep it until the
|
||||
* per-generation property map for SWI69 is confirmed, then gate it on debug.
|
||||
*/
|
||||
private void wireProbeIfDebug() {
|
||||
View probeButton = abrpPane.findViewById(R.id.probe_button);
|
||||
if (probeButton == null) return;
|
||||
probeButton.setVisibility(View.VISIBLE);
|
||||
probeButton.setOnClickListener(v -> runProbe());
|
||||
}
|
||||
|
||||
private void runProbe() {
|
||||
// Binder reads are slow enough to be worth keeping off the UI thread; the result
|
||||
// is recorded through the upload log, which the screen already renders.
|
||||
new Thread(() -> {
|
||||
try {
|
||||
EVHardware.INSTANCE.init(getApplicationContext());
|
||||
java.util.List<EVHardware.PropertyReport> reports = EVHardware.INSTANCE.probeTelemetryProperties();
|
||||
String firmware = String.valueOf(FirmwareInfo.INSTANCE.getGeneration());
|
||||
StringBuilder sb = new StringBuilder("VHAL probe on ").append(firmware).append('\n');
|
||||
for (EVHardware.PropertyReport r : reports) sb.append(" ").append(r).append('\n');
|
||||
sb.append("TOTAL ").append(reports.size()).append(" properties probed");
|
||||
runOnUiThread(() -> AbrpUploadService.log().record(
|
||||
new UploadLog.Entry(System.currentTimeMillis(), 200, true, "VHAL Probe", sb.toString())));
|
||||
} catch (Throwable t) {
|
||||
String msg = "Probe failed: " + (t.getMessage() != null ? t.getMessage() : t.getClass().getSimpleName());
|
||||
runOnUiThread(() -> AbrpUploadService.log().record(
|
||||
new UploadLog.Entry(System.currentTimeMillis(), 0, false, "VHAL Probe", msg)));
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
private void setConnectionStatus(int color, String message) {
|
||||
connectionStatusRow.setVisibility(View.VISIBLE);
|
||||
connectionIndicator.getBackground().setTint(color);
|
||||
connectionStatusText.setText(message);
|
||||
connectionStatusText.setTextColor(color);
|
||||
}
|
||||
|
||||
// ---------- Service status ----------
|
||||
|
||||
private void refreshStatus() {
|
||||
// Live signal, not the preference: "service_running" stays stale-true after a
|
||||
// force-kill because onDestroy never ran.
|
||||
UploadLog.State state = AbrpUploadService.state();
|
||||
|
||||
switch (state) {
|
||||
case ERROR:
|
||||
// Derived from the log, not from the last status alone: a single failed
|
||||
// upload is normal (tunnel, dead spot), three in a row is a problem.
|
||||
statusText.setText(getString(R.string.state_error,
|
||||
AbrpUploadService.log().consecutiveFailures()));
|
||||
statusText.setTextColor(COLOR_ERROR);
|
||||
break;
|
||||
case RUNNING:
|
||||
String lastTime = prefs.getString("last_upload_time", null);
|
||||
statusText.setText(lastTime != null
|
||||
? getString(R.string.status_last_upload, lastTime,
|
||||
getString(R.string.status_ok))
|
||||
: getString(R.string.state_running));
|
||||
statusText.setTextColor(COLOR_OK);
|
||||
break;
|
||||
case STARTING:
|
||||
statusText.setText(R.string.state_starting);
|
||||
statusText.setTextColor(COLOR_OK);
|
||||
break;
|
||||
case STOPPED:
|
||||
default:
|
||||
statusText.setText(R.string.state_stopped);
|
||||
statusText.setTextColor(COLOR_PENDING);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Helpers ----------
|
||||
|
||||
private String textOf(TextInputEditText field) {
|
||||
CharSequence text = field.getText();
|
||||
return text != null ? text.toString().trim() : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Permissions that need a runtime grant (vs being granted at install time
|
||||
* via the platform signature). CAR_SPEED and CAR_ENERGY are dangerous-level
|
||||
* AAOS permissions — without these, the corresponding property reads throw
|
||||
* SecurityException and we have no SOC / speed data.
|
||||
*/
|
||||
private static final String[] RUNTIME_PERMISSIONS = {
|
||||
Manifest.permission.ACCESS_FINE_LOCATION,
|
||||
"android.car.permission.CAR_SPEED",
|
||||
"android.car.permission.CAR_ENERGY",
|
||||
"android.car.permission.CAR_ENERGY_PORTS",
|
||||
// Outside temperature and cabin temperature: declared and read since 2.1.0, but
|
||||
// never asked for, so both came back unreadable and were dropped from every
|
||||
// payload on a car that would have answered them.
|
||||
"android.car.permission.CAR_EXTERIOR_ENVIRONMENT",
|
||||
"android.car.permission.CONTROL_CAR_CLIMATE",
|
||||
};
|
||||
|
||||
/**
|
||||
* Asked for alongside the telemetry permissions from API 33, and separately because a
|
||||
* denial costs something different: the foreground notification is the only status this
|
||||
* service has, and without this it is dropped silently while the service runs on.
|
||||
*/
|
||||
private static String[] runtimePermissions() {
|
||||
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.TIRAMISU) {
|
||||
return RUNTIME_PERMISSIONS;
|
||||
}
|
||||
String[] all = java.util.Arrays.copyOf(RUNTIME_PERMISSIONS, RUNTIME_PERMISSIONS.length + 1);
|
||||
all[RUNTIME_PERMISSIONS.length] = Manifest.permission.POST_NOTIFICATIONS;
|
||||
return all;
|
||||
}
|
||||
|
||||
private void requestLocationPermissionIfNeeded() {
|
||||
java.util.List<String> missing = new java.util.ArrayList<>();
|
||||
for (String p : runtimePermissions()) {
|
||||
if (ContextCompat.checkSelfPermission(this, p)
|
||||
!= PackageManager.PERMISSION_GRANTED) {
|
||||
missing.add(p);
|
||||
}
|
||||
}
|
||||
if (!missing.isEmpty()) {
|
||||
ActivityCompat.requestPermissions(this,
|
||||
missing.toArray(new String[0]),
|
||||
LOCATION_PERMISSION_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A denied permission used to be silent: the service kept running and simply uploaded
|
||||
* nothing useful, with the UI claiming everything was fine. Say what was denied and
|
||||
* what it costs.
|
||||
*/
|
||||
@Override
|
||||
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions,
|
||||
@NonNull int[] grantResults) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
|
||||
if (requestCode != LOCATION_PERMISSION_REQUEST) return;
|
||||
|
||||
boolean locationDenied = false;
|
||||
boolean carDataDenied = false;
|
||||
for (int i = 0; i < permissions.length && i < grantResults.length; i++) {
|
||||
if (grantResults[i] == PackageManager.PERMISSION_GRANTED) continue;
|
||||
if (Manifest.permission.ACCESS_FINE_LOCATION.equals(permissions[i])) {
|
||||
locationDenied = true;
|
||||
} else {
|
||||
carDataDenied = true;
|
||||
}
|
||||
}
|
||||
|
||||
// A grant that arrives after the service is already up changes which foreground
|
||||
// types it may hold, and which properties it may read. Starting it again is what
|
||||
// applies both without waiting for the next boot.
|
||||
if (prefs.getBoolean("service_enabled", false)) {
|
||||
startForegroundService(new Intent(this, AbrpUploadService.class));
|
||||
}
|
||||
|
||||
if (locationDenied && carDataDenied) {
|
||||
setConnectionStatus(COLOR_ERROR, getString(R.string.perm_denied_all));
|
||||
} else if (locationDenied) {
|
||||
setConnectionStatus(COLOR_ERROR, getString(R.string.perm_denied_location));
|
||||
} else if (carDataDenied) {
|
||||
setConnectionStatus(COLOR_ERROR, getString(R.string.perm_denied_car));
|
||||
}
|
||||
refreshStatus();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.Color;
|
||||
|
||||
import com.google.zxing.BarcodeFormat;
|
||||
import com.google.zxing.EncodeHintType;
|
||||
import com.google.zxing.MultiFormatWriter;
|
||||
import com.google.zxing.common.BitMatrix;
|
||||
|
||||
import java.util.EnumMap;
|
||||
import java.util.Map;
|
||||
|
||||
/** Generates the repository QR locally; no network capability is involved. */
|
||||
final class QrCode {
|
||||
private QrCode() {}
|
||||
|
||||
static Bitmap generate(String content, int sizePx) {
|
||||
try {
|
||||
Map<EncodeHintType, Object> hints = new EnumMap<>(EncodeHintType.class);
|
||||
hints.put(EncodeHintType.MARGIN, 1);
|
||||
hints.put(EncodeHintType.CHARACTER_SET, "UTF-8");
|
||||
BitMatrix matrix = new MultiFormatWriter().encode(
|
||||
content, BarcodeFormat.QR_CODE, sizePx, sizePx, hints);
|
||||
Bitmap bitmap = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.RGB_565);
|
||||
for (int x = 0; x < sizePx; x++) {
|
||||
for (int y = 0; y < sizePx; y++) {
|
||||
bitmap.setPixel(x, y, matrix.get(x, y) ? Color.BLACK : Color.WHITE);
|
||||
}
|
||||
}
|
||||
return bitmap;
|
||||
} catch (Exception ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.security.crypto.EncryptedSharedPreferences;
|
||||
import androidx.security.crypto.MasterKey;
|
||||
|
||||
/**
|
||||
* Storage for the ABRP credentials (api_key, token).
|
||||
*
|
||||
* They used to sit in plaintext in the regular SharedPreferences file, which
|
||||
* `adb backup` hands out. They now live in an AndroidKeyStore-backed encrypted
|
||||
* file, and the plaintext copy is wiped on first access.
|
||||
*/
|
||||
final class SecurePrefs {
|
||||
|
||||
private static final String TAG = "SecurePrefs";
|
||||
private static final String FILE_NAME = "abrp_secure_prefs";
|
||||
private static final String PLAIN_NAME = "abrp_prefs";
|
||||
|
||||
static final String KEY_API_KEY = "api_key";
|
||||
static final String KEY_TOKEN = "token";
|
||||
|
||||
private static SharedPreferences instance;
|
||||
|
||||
private SecurePrefs() { }
|
||||
|
||||
/**
|
||||
* Encrypted preferences, or the plaintext file if the crypto stack is
|
||||
* unavailable. Falling back keeps the app usable on a device with a broken
|
||||
* keystore instead of crashing it in a vehicle.
|
||||
*/
|
||||
static synchronized SharedPreferences get(Context context) {
|
||||
if (instance != null) return instance;
|
||||
Context app = context.getApplicationContext();
|
||||
try {
|
||||
MasterKey masterKey = new MasterKey.Builder(app)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build();
|
||||
instance = EncryptedSharedPreferences.create(
|
||||
app,
|
||||
FILE_NAME,
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM);
|
||||
migratePlaintext(app, instance);
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Encrypted preferences unavailable, falling back to plaintext", e);
|
||||
instance = app.getSharedPreferences(PLAIN_NAME, Context.MODE_PRIVATE);
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
/** Moves credentials written by an older version, then deletes the plaintext copy. */
|
||||
private static void migratePlaintext(Context context, SharedPreferences secure) {
|
||||
SharedPreferences plain = context.getSharedPreferences(PLAIN_NAME, Context.MODE_PRIVATE);
|
||||
String apiKey = plain.getString(KEY_API_KEY, null);
|
||||
String token = plain.getString(KEY_TOKEN, null);
|
||||
if (apiKey == null && token == null) return;
|
||||
|
||||
SharedPreferences.Editor secureEdit = secure.edit();
|
||||
if (apiKey != null) secureEdit.putString(KEY_API_KEY, apiKey);
|
||||
if (token != null) secureEdit.putString(KEY_TOKEN, token);
|
||||
secureEdit.commit();
|
||||
|
||||
plain.edit().remove(KEY_API_KEY).remove(KEY_TOKEN).commit();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
/**
|
||||
* Decides whether a given scheduler tick should actually upload.
|
||||
*
|
||||
* The service ticks at a fixed short rate; this class thins the uploads out. A car parked
|
||||
* overnight and not charging does not need a sample every minute — that is wasted data and
|
||||
* power — but nothing may be lost while driving or charging, where ABRP needs the
|
||||
* resolution to keep a route plan accurate.
|
||||
*
|
||||
* Free of Android types so the policy can be unit-tested directly.
|
||||
*/
|
||||
final class UploadCadence {
|
||||
|
||||
/**
|
||||
* Parked and not charging: one keep-alive sample every 15 minutes. The car is not
|
||||
* moving and its SOC is not changing, so anything more is spent for nothing.
|
||||
*/
|
||||
static final long PARKED_INTERVAL_MS = 900_000L;
|
||||
|
||||
private UploadCadence() { }
|
||||
|
||||
/**
|
||||
* @param nowMs current time
|
||||
* @param lastUploadMs when the last upload was attempted, 0 if never
|
||||
* @param parked null when the gear could not be read
|
||||
* @param charging null when the charge port state could not be read
|
||||
* @param socPercent null when the battery level could not be read
|
||||
* @param stateChanged true if parked/charging differ from the previous tick
|
||||
* @param settings user-configured cadence
|
||||
*/
|
||||
static boolean shouldUpload(long nowMs, long lastUploadMs,
|
||||
Boolean parked, Boolean charging, Integer socPercent,
|
||||
boolean stateChanged, UploadSettings settings) {
|
||||
// Never uploaded yet: send immediately so ABRP sees the car come online.
|
||||
if (lastUploadMs <= 0L) return true;
|
||||
|
||||
// A transition (just parked, plugged in, unplugged) is exactly what ABRP must
|
||||
// learn straight away — never make it wait for the slow interval.
|
||||
if (stateChanged) return true;
|
||||
|
||||
return nowMs - lastUploadMs >= intervalFor(parked, charging, socPercent, settings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Interval that applies in the given state.
|
||||
*
|
||||
* Unknown state counts as active: if the gear or charge port could not be read we do
|
||||
* not know the car is idle, and under-reporting a moving car is worse than a few
|
||||
* extra requests from a stationary one.
|
||||
*/
|
||||
static long intervalFor(Boolean parked, Boolean charging, Integer socPercent,
|
||||
UploadSettings settings) {
|
||||
boolean idle = Boolean.TRUE.equals(parked) && Boolean.FALSE.equals(charging);
|
||||
if (idle) return PARKED_INTERVAL_MS;
|
||||
|
||||
// Low battery: the driver is picking a charge stop and ABRP needs a tighter SOC
|
||||
// curve to get the arrival estimate right. Only while actually in use — a car
|
||||
// parked at 5 % is still parked and gains nothing from fast polling.
|
||||
if (settings.boostLowSoc && socPercent != null
|
||||
&& socPercent <= settings.lowSocPercent) {
|
||||
return Math.min(settings.intervalMs(), UploadSettings.BOOSTED_INTERVAL_SEC * 1000L);
|
||||
}
|
||||
return settings.intervalMs();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Deque;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Rolling record of the last {@link #MAX_ENTRIES} upload attempts, and the service health
|
||||
* derived from them.
|
||||
*
|
||||
* Process-local on purpose: this is a diagnostic view, not persisted state. Written from
|
||||
* the scheduler thread and read from the UI thread, so every access is synchronized.
|
||||
*/
|
||||
final class UploadLog {
|
||||
|
||||
static final int MAX_ENTRIES = 20;
|
||||
|
||||
/** Consecutive failures that flip the service into the error state. */
|
||||
static final int FAILURES_FOR_ERROR = 3;
|
||||
|
||||
enum State {
|
||||
/** Service is off. */
|
||||
STOPPED,
|
||||
/** Running, no upload attempted yet. */
|
||||
STARTING,
|
||||
/** Running and the recent uploads are going through. */
|
||||
RUNNING,
|
||||
/** Running but the last {@link #FAILURES_FOR_ERROR} attempts failed. */
|
||||
ERROR
|
||||
}
|
||||
|
||||
/** One upload attempt. */
|
||||
static final class Entry {
|
||||
final long timestampMs;
|
||||
/** HTTP status, or 0 when the request never got a response (offline, timeout). */
|
||||
final int httpStatus;
|
||||
final boolean success;
|
||||
/** Short human-readable reason, e.g. "OK", "No internet", "HTTP 401". */
|
||||
final String detail;
|
||||
/**
|
||||
* What the payload actually contained — see TelemetryPayload.summarize(). Null for
|
||||
* an attempt recorded before a payload existed.
|
||||
*
|
||||
* This is the whole point of the log for anything other than "is it uploading":
|
||||
* a value that looks wrong on the ABRP side is either a field this app never sent,
|
||||
* or one it sent with the wrong number in it, and nothing else distinguishes them.
|
||||
*/
|
||||
final String summary;
|
||||
|
||||
Entry(long timestampMs, int httpStatus, boolean success, String detail) {
|
||||
this(timestampMs, httpStatus, success, detail, null);
|
||||
}
|
||||
|
||||
Entry(long timestampMs, int httpStatus, boolean success, String detail, String summary) {
|
||||
this.timestampMs = timestampMs;
|
||||
this.httpStatus = httpStatus;
|
||||
this.success = success;
|
||||
this.detail = detail;
|
||||
this.summary = summary;
|
||||
}
|
||||
}
|
||||
|
||||
private final Deque<Entry> entries = new ArrayDeque<>(MAX_ENTRIES);
|
||||
private final Object lock = new Object();
|
||||
|
||||
void record(Entry entry) {
|
||||
synchronized (lock) {
|
||||
while (entries.size() >= MAX_ENTRIES) entries.removeFirst();
|
||||
entries.addLast(entry);
|
||||
}
|
||||
}
|
||||
|
||||
void clear() {
|
||||
synchronized (lock) { entries.clear(); }
|
||||
}
|
||||
|
||||
/** Most recent first — that is the order the UI shows them in. */
|
||||
List<Entry> recent() {
|
||||
synchronized (lock) {
|
||||
List<Entry> copy = new ArrayList<>(entries);
|
||||
java.util.Collections.reverse(copy);
|
||||
return copy;
|
||||
}
|
||||
}
|
||||
|
||||
Entry latest() {
|
||||
synchronized (lock) {
|
||||
return entries.isEmpty() ? null : entries.peekLast();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Number of failures at the end of the log. Reset by any success, so a single
|
||||
* recovered upload clears the error state.
|
||||
*/
|
||||
int consecutiveFailures() {
|
||||
synchronized (lock) {
|
||||
int count = 0;
|
||||
java.util.Iterator<Entry> it = entries.descendingIterator();
|
||||
while (it.hasNext()) {
|
||||
if (it.next().success) break;
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Service state.
|
||||
*
|
||||
* Needs [running] from the service itself: an empty log means "not started yet" while
|
||||
* running, but means nothing at all when stopped.
|
||||
*/
|
||||
State state(boolean running) {
|
||||
if (!running) return State.STOPPED;
|
||||
synchronized (lock) {
|
||||
if (entries.isEmpty()) return State.STARTING;
|
||||
}
|
||||
return consecutiveFailures() >= FAILURES_FOR_ERROR ? State.ERROR : State.RUNNING;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import android.content.SharedPreferences;
|
||||
|
||||
/**
|
||||
* User-configurable upload policy.
|
||||
*
|
||||
* Defaults are deliberately conservative. The head unit is awake whenever the car is on,
|
||||
* and both the upload tick and the GPS subscription cost power the whole time, so the
|
||||
* default cadence is the slowest one that still keeps an ABRP route plan usable.
|
||||
*/
|
||||
final class UploadSettings {
|
||||
|
||||
static final String KEY_INTERVAL_SEC = "upload_interval_sec";
|
||||
static final String KEY_BOOST_LOW_SOC = "boost_low_soc";
|
||||
static final String KEY_LOW_SOC_PERCENT = "low_soc_percent";
|
||||
static final String KEY_AUTOSTART = "autostart";
|
||||
|
||||
/**
|
||||
* On by default: an ABRP plan is only useful if it is fed from the moment the drive
|
||||
* starts, and nobody opens this app before setting off. Kept as a switch because a
|
||||
* service that starts itself must also be switchable off.
|
||||
*/
|
||||
static final boolean DEFAULT_AUTOSTART = true;
|
||||
|
||||
/**
|
||||
* Default cadence while driving. 60 s is roughly 1.5 km between points at motorway
|
||||
* speed, which ABRP can still track; going much slower makes the live plan lag
|
||||
* reality. Parked and idle backs off far further on its own — see UploadCadence.
|
||||
*/
|
||||
static final int DEFAULT_INTERVAL_SEC = 60;
|
||||
|
||||
/** Offered in the UI. Kept short so the list stays a single glance on a car screen. */
|
||||
static final int[] INTERVAL_CHOICES_SEC = { 15, 30, 60, 120, 300 };
|
||||
|
||||
static final boolean DEFAULT_BOOST_LOW_SOC = true;
|
||||
|
||||
/** Below this, range planning gets tight and ABRP needs a finer SOC curve. */
|
||||
static final int DEFAULT_LOW_SOC_PERCENT = 20;
|
||||
|
||||
/** Cadence used while SOC is low and the boost is enabled. */
|
||||
static final int BOOSTED_INTERVAL_SEC = 15;
|
||||
|
||||
final int intervalSec;
|
||||
final boolean boostLowSoc;
|
||||
final int lowSocPercent;
|
||||
|
||||
UploadSettings(int intervalSec, boolean boostLowSoc, int lowSocPercent) {
|
||||
this.intervalSec = intervalSec;
|
||||
this.boostLowSoc = boostLowSoc;
|
||||
this.lowSocPercent = lowSocPercent;
|
||||
}
|
||||
|
||||
static UploadSettings from(SharedPreferences prefs) {
|
||||
return new UploadSettings(
|
||||
prefs.getInt(KEY_INTERVAL_SEC, DEFAULT_INTERVAL_SEC),
|
||||
prefs.getBoolean(KEY_BOOST_LOW_SOC, DEFAULT_BOOST_LOW_SOC),
|
||||
prefs.getInt(KEY_LOW_SOC_PERCENT, DEFAULT_LOW_SOC_PERCENT));
|
||||
}
|
||||
|
||||
static UploadSettings defaults() {
|
||||
return new UploadSettings(
|
||||
DEFAULT_INTERVAL_SEC, DEFAULT_BOOST_LOW_SOC, DEFAULT_LOW_SOC_PERCENT);
|
||||
}
|
||||
|
||||
long intervalMs() {
|
||||
return intervalSec * 1000L;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import android.content.Context;
|
||||
import android.net.wifi.WifiConfiguration;
|
||||
import android.net.wifi.WifiInfo;
|
||||
import android.net.wifi.WifiManager;
|
||||
import android.util.Log;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Brings Wi-Fi up and joins a network the head unit already knows.
|
||||
*
|
||||
* The point is the phone hotspot: the car has the credentials saved, but the driver still
|
||||
* has to walk into Settings and tap the network every time. This does that tap.
|
||||
*
|
||||
* <b>Why this works here and would not on a newer phone.</b> {@code getConfiguredNetworks}
|
||||
* and {@code enableNetwork} were deprecated in API 29 and, from that release, return an
|
||||
* empty list and refuse the call for ordinary apps — Android handed network choice to the
|
||||
* user through a system picker. This head unit runs Android 9 (API 28), where both are
|
||||
* still honoured, and the build is platform-signed, so it is the same UID family the
|
||||
* Settings app itself uses. Neither fact is portable: on a Pixel this class would find
|
||||
* nothing and change nothing. It is deliberately written to fail quietly if so.
|
||||
*
|
||||
* Nothing here creates or edits a network. It only enables one the unit was already
|
||||
* configured with, which is why no password ever passes through this app.
|
||||
*/
|
||||
final class WifiAutoConnect {
|
||||
|
||||
private static final String TAG = "EVABRP.Wifi";
|
||||
|
||||
/** Re-checking faster than this achieves nothing: a join takes seconds to settle. */
|
||||
private static final long MIN_ATTEMPT_INTERVAL_MS = 30_000L;
|
||||
|
||||
private final WifiManager wifi;
|
||||
private long lastAttemptMs;
|
||||
|
||||
WifiAutoConnect(Context context) {
|
||||
this.wifi = (WifiManager) context.getApplicationContext()
|
||||
.getSystemService(Context.WIFI_SERVICE);
|
||||
}
|
||||
|
||||
/** The SSID the unit is associated with, or null when it is not on Wi-Fi. */
|
||||
String currentSsid() {
|
||||
try {
|
||||
if (wifi == null || !wifi.isWifiEnabled()) return null;
|
||||
WifiInfo info = wifi.getConnectionInfo();
|
||||
if (info == null) return null;
|
||||
String ssid = info.getSSID();
|
||||
if (ssid == null) return null;
|
||||
// The framework quotes the SSID and reports this placeholder when it will not
|
||||
// say — usually because location is off. Neither is a network name.
|
||||
ssid = ssid.replace("\"", "").trim();
|
||||
if (ssid.isEmpty() || "<unknown ssid>".equals(ssid) || "0x".equals(ssid)) return null;
|
||||
return ssid;
|
||||
} catch (Throwable t) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
boolean isWifiEnabled() {
|
||||
try {
|
||||
return wifi != null && wifi.isWifiEnabled();
|
||||
} catch (Throwable t) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Saved network names, for the UI to show what it can reach. Empty if the OS refuses. */
|
||||
List<String> savedNetworkNames() {
|
||||
java.util.List<String> names = new java.util.ArrayList<>();
|
||||
try {
|
||||
if (wifi == null) return names;
|
||||
List<WifiConfiguration> saved = wifi.getConfiguredNetworks();
|
||||
if (saved == null) return names;
|
||||
for (WifiConfiguration c : saved) {
|
||||
if (c == null || c.SSID == null) continue;
|
||||
String name = c.SSID.replace("\"", "").trim();
|
||||
if (!name.isEmpty() && !names.contains(name)) names.add(name);
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
Log.w(TAG, "getConfiguredNetworks refused: " + t.getMessage());
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* One attempt to get onto a saved network, rate-limited.
|
||||
*
|
||||
* @param force ignore the rate limit — for the button, which a driver only presses
|
||||
* because the automatic attempt has not worked yet.
|
||||
* @return what happened, for the log and the UI. Never null.
|
||||
*/
|
||||
Result ensureConnected(boolean force) {
|
||||
if (wifi == null) return Result.UNSUPPORTED;
|
||||
|
||||
String ssid = currentSsid();
|
||||
if (ssid != null) return Result.alreadyOn(ssid);
|
||||
|
||||
long now = System.currentTimeMillis();
|
||||
if (!force && now - lastAttemptMs < MIN_ATTEMPT_INTERVAL_MS) return Result.WAITING;
|
||||
lastAttemptMs = now;
|
||||
|
||||
try {
|
||||
if (!wifi.isWifiEnabled()) {
|
||||
// Also gone for ordinary apps from API 29, and also still honoured here.
|
||||
if (!wifi.setWifiEnabled(true)) return Result.CANNOT_ENABLE;
|
||||
// The radio needs a moment before it will associate; the next tick retries.
|
||||
return Result.ENABLING;
|
||||
}
|
||||
|
||||
List<WifiConfiguration> saved = wifi.getConfiguredNetworks();
|
||||
if (saved == null || saved.isEmpty()) return Result.NO_SAVED_NETWORKS;
|
||||
|
||||
// A scan would let us prefer a network that is actually in range, but the results
|
||||
// are themselves throttled and location-gated. Asking the supplicant to pick from
|
||||
// what it knows is both simpler and what the Settings screen effectively does.
|
||||
wifi.reconnect();
|
||||
for (WifiConfiguration c : saved) {
|
||||
if (c == null) continue;
|
||||
// Second argument true: disable the others for this attempt, so the
|
||||
// supplicant commits to this one rather than drifting between them.
|
||||
if (wifi.enableNetwork(c.networkId, true)) {
|
||||
String name = c.SSID == null ? "?" : c.SSID.replace("\"", "").trim();
|
||||
Log.i(TAG, "enableNetwork accepted for " + name);
|
||||
return Result.attempting(name);
|
||||
}
|
||||
}
|
||||
return Result.REFUSED;
|
||||
} catch (Throwable t) {
|
||||
Log.w(TAG, "connect attempt failed: " + t.getMessage());
|
||||
return Result.REFUSED;
|
||||
}
|
||||
}
|
||||
|
||||
/** Outcome of one attempt. {@link #connected} is the only one that means we are online. */
|
||||
static final class Result {
|
||||
final boolean connected;
|
||||
final String ssid;
|
||||
final String message;
|
||||
|
||||
private Result(boolean connected, String ssid, String message) {
|
||||
this.connected = connected;
|
||||
this.ssid = ssid;
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
static Result alreadyOn(String ssid) { return new Result(true, ssid, "Bağlı: " + ssid); }
|
||||
static Result attempting(String ssid) { return new Result(false, ssid, "Bağlanılıyor: " + ssid); }
|
||||
|
||||
static final Result UNSUPPORTED = new Result(false, null, "Wi-Fi yönetilemiyor");
|
||||
static final Result WAITING = new Result(false, null, "Bekleniyor…");
|
||||
static final Result ENABLING = new Result(false, null, "Wi-Fi açılıyor…");
|
||||
static final Result CANNOT_ENABLE = new Result(false, null, "Wi-Fi açılamadı");
|
||||
static final Result NO_SAVED_NETWORKS = new Result(false, null, "Kayıtlı ağ yok");
|
||||
static final Result REFUSED = new Result(false, null, "Sistem izin vermedi");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user