734 lines
35 KiB
Java
734 lines
35 KiB
Java
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;
|
|
private ConsumptionTracker consumptionTracker;
|
|
/**
|
|
* 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());
|
|
consumptionTracker = ConsumptionTracker.get(this);
|
|
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.
|
|
|
|
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 (consumptionTracker != null) consumptionTracker.persist();
|
|
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() {
|
|
long sampleMs = System.currentTimeMillis();
|
|
EnergySnapshot vehicle = energyReader.read(sampleMs);
|
|
consumptionTracker.sample(vehicle);
|
|
|
|
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.
|
|
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 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.
|
|
TelemetryPayload tlm = new TelemetryPayload(System.currentTimeMillis() / 1000);
|
|
tlm.soc = soc;
|
|
tlm.speedKmh = speedKmh;
|
|
tlm.rangeKm = rangeKm;
|
|
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);
|
|
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.
|
|
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));
|
|
}
|
|
}
|