chore
This commit is contained in:
@@ -5,55 +5,33 @@ 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.
|
||||
* Foreground service that polls vehicle telemetry every 15 s, integrates consumption locally,
|
||||
* and syncs it to the cloud (MongoDB) in 30-minute delta chunks. Never sends anything 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.
|
||||
* Always-on by design: started at boot (BootReceiver) and on app open, never gated on
|
||||
* credentials or a toggle. The only thing it needs is a network for the cloud sync, and a
|
||||
* missing one is handled by caching chunks locally until the connection returns.
|
||||
*/
|
||||
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
|
||||
@@ -74,117 +52,33 @@ public class AbrpUploadService extends Service {
|
||||
|
||||
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}. */
|
||||
/** Scheduler tick — vehicle telemetry is sampled this often. */
|
||||
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.
|
||||
*/
|
||||
/** Warm-up before the first sample. */
|
||||
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;
|
||||
private ConsumptionCloudClient consumptionCloudClient;
|
||||
private ChargeSessionTracker chargeSessionTracker;
|
||||
/**
|
||||
* 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 WifiAutoConnect wifi;
|
||||
private ScheduledExecutorService scheduler;
|
||||
private SharedPreferences prefs;
|
||||
/** Credentials only — encrypted at rest, see {@link SecurePrefs}. */
|
||||
private SharedPreferences securePrefs;
|
||||
private Handler mainHandler;
|
||||
private SharedPreferences prefs;
|
||||
|
||||
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);
|
||||
prefs = getSharedPreferences("abrp_prefs", MODE_PRIVATE);
|
||||
uploadLog.clear();
|
||||
mainHandler = new Handler(Looper.getMainLooper());
|
||||
scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
|
||||
scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
|
||||
Thread t = new Thread(r, "abrp-upload");
|
||||
t.setDaemon(false);
|
||||
return t;
|
||||
@@ -195,73 +89,40 @@ public class AbrpUploadService extends Service {
|
||||
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);
|
||||
consumptionCloudClient = new ConsumptionCloudClient(getApplicationContext());
|
||||
chargeSessionTracker = ChargeSessionTracker.get(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.
|
||||
|
||||
if (settings.abrpUploadEnabled) 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");
|
||||
uploadLog.record(new UploadLog.Entry(System.currentTimeMillis(), 0, true,
|
||||
"Tüketim servisi başlatıldı"));
|
||||
Log.i(TAG, "Service started, consumption tracking 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.
|
||||
* Enters the foreground. No location type — this service no longer reads GPS. Foreground
|
||||
* service types do not exist before API 29, so on this API-28 head unit a plain
|
||||
* startForeground is the only correct call.
|
||||
*/
|
||||
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;
|
||||
Notification notification = buildNotification(lastStatus);
|
||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
|
||||
ServiceCompat.startForeground(
|
||||
this, NOTIF_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC);
|
||||
} else {
|
||||
startForeground(NOTIF_ID, notification);
|
||||
}
|
||||
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
|
||||
@@ -269,57 +130,18 @@ public class AbrpUploadService extends Service {
|
||||
|
||||
@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();
|
||||
if (scheduler != null) scheduler.shutdownNow();
|
||||
Log.i(TAG, "Service stopped");
|
||||
}
|
||||
|
||||
// ---------- Upload cycle (runs on scheduler thread) ----------
|
||||
// ---------- Sampling 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 + "%"
|
||||
+ " abrpUpload=" + settings.abrpUploadEnabled);
|
||||
}
|
||||
// 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 (settings.abrpUploadEnabled && !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 (settings.abrpUploadEnabled && 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);
|
||||
}
|
||||
}
|
||||
@@ -331,388 +153,13 @@ public class AbrpUploadService extends Service {
|
||||
chargeSessionTracker.sample(vehicle);
|
||||
consumptionCloudClient.syncIfDue(consumptionTracker, chargeSessionTracker, sampleMs);
|
||||
|
||||
// Keep local consumption history alive while another app owns the ABRP feed.
|
||||
if (!settings.abrpUploadEnabled) {
|
||||
updateNotification(getString(R.string.notif_tracking_locally));
|
||||
return;
|
||||
// Keep the head unit on the phone hotspot — the sync needs a network, and this is
|
||||
// exactly when being offline costs something. Rate-limited internally.
|
||||
if (wifi != null && prefs.getBoolean(MainActivity.WIFI_AUTO_KEY, true)) {
|
||||
wifi.ensureConnected(false);
|
||||
}
|
||||
|
||||
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 (!settings.abrpUploadEnabled) {
|
||||
lastLocation = null;
|
||||
Log.i(TAG, "GPS updates stopped while ABRP upload is disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
updateNotification("Tüketim takibi aktif");
|
||||
}
|
||||
|
||||
// ---------- Notification ----------
|
||||
@@ -731,22 +178,17 @@ public class AbrpUploadService extends Service {
|
||||
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) {
|
||||
if (status.equals(lastStatus)) return;
|
||||
lastStatus = status;
|
||||
getSystemService(NotificationManager.class).notify(NOTIF_ID, buildNotification(status));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user