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,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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user