Files
mg4/app/src/main/java/com/evsuite/abrp/AbrpUploadService.java
T
nilsandClaude Opus 5.5 ed65c96319 App 2.34.0: set the chosen drive mode again when the car starts
The car falls back to its own default mode at every start. With "Set at
start" (on by default), the service sets the mode last chosen on
Settings and confirmed by the car again, once, while the car is in P at
0 km/h, and gives up as soon as the car leaves P or after two minutes
(DriveModeWrite.atStart, unit-tested). The buttons and the start share
the write and read-back (DriveModeSetter). Not tried on the car.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-10-25 20:12:11 +01:00

274 lines
12 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.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.ServiceInfo;
import android.os.IBinder;
import android.util.Log;
import androidx.core.app.NotificationCompat;
import androidx.core.app.ServiceCompat;
import com.evsuite.hardware.telemetry.EnergySnapshot;
import com.evsuite.hardware.telemetry.EnergyTelemetryReader;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
/**
* Foreground service that polls vehicle telemetry every 15 s, integrates consumption locally,
* syncs it to the car server in 2-minute delta chunks, and sends live telemetry samples that
* the server stores and, with ABRP credentials set, that go straight to ABRP
* ({@link TelemetryUploader}).
*
* 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, and a missing one is
* handled by queueing locally until the connection returns.
*/
public class AbrpUploadService extends Service {
/**
* 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); }
private static final String TAG = "AbrpUploadService";
private static final String CHANNEL_ID = "abrp_uploader";
private static final int NOTIF_ID = 1;
/** Scheduler tick — vehicle telemetry is sampled this often. */
private static final long UPLOAD_INTERVAL_SEC = 15;
/** Warm-up before the first sample. */
private static final long FIRST_UPLOAD_DELAY_SEC = 20;
private EnergyTelemetryReader energyReader;
private ConsumptionTracker consumptionTracker;
private ConsumptionCloudClient consumptionCloudClient;
private ChargeSessionTracker chargeSessionTracker;
private TelemetryUploader telemetryUploader;
private SteeringKeyListener steeringKeys;
private ScheduledExecutorService scheduler;
/** Sets the chosen drive mode at start, then ends (DriveModeWrite.atStart). */
private Thread driveModeAtStart;
private SharedPreferences prefs;
/** What the foreground notification currently says, so re-asserting it does not blank it. */
private volatile String lastStatus = "Starting…";
// ---------- Lifecycle ----------
@Override
public void onCreate() {
super.onCreate();
prefs = getSharedPreferences("abrp_prefs", MODE_PRIVATE);
uploadLog.clear();
UpdateLog.noteVersion(this, BuildConfig.VERSION_NAME);
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();
energyReader = new EnergyTelemetryReader(getApplicationContext());
consumptionTracker = ConsumptionTracker.get(this);
consumptionCloudClient = new ConsumptionCloudClient(getApplicationContext());
chargeSessionTracker = ChargeSessionTracker.get(getApplicationContext());
telemetryUploader = new TelemetryUploader(getApplicationContext());
steeringKeys = new SteeringKeyListener(this, prefs);
steeringKeys.register();
startDriveModeAtStart();
scheduler.scheduleWithFixedDelay(
this::safeUploadCycle,
FIRST_UPLOAD_DELAY_SEC, UPLOAD_INTERVAL_SEC, TimeUnit.SECONDS);
uploadLog.record(new UploadLog.Entry(System.currentTimeMillis(), 0, true,
"Consumption service started"));
Log.i(TAG, "Service started, consumption tracking armed");
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
startInForeground();
return START_STICKY;
}
/**
* Enters the foreground. Foreground service types do not exist before API 29, so on this
* API-28 head unit a plain startForeground is the only correct call; from API 29 the
* location type is what lets the GPS subscription run in the background.
*/
private void startInForeground() {
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 | ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION);
} else {
startForeground(NOTIF_ID, notification);
}
}
@Override
public IBinder onBind(Intent intent) { return null; }
@Override
public void onDestroy() {
running = false;
prefs.edit().putBoolean("service_running", false).apply();
if (scheduler != null) scheduler.shutdownNow();
if (telemetryUploader != null) {
telemetryUploader.persist(); // what is still queued survives the restart
telemetryUploader.stop();
}
if (steeringKeys != null) steeringKeys.unregister();
if (driveModeAtStart != null) driveModeAtStart.interrupt();
Log.i(TAG, "Service stopped");
}
// ---------- The drive mode at start ----------
/**
* The service starts with the head unit, i.e. when the car is switched on: the mode the
* driver chose on Settings (and the car confirmed) is set again, once, while the car is in
* P at 0 km/h — readings checked every second, for at most two minutes, and never after
* the car has left P. Its own thread: the upload scheduler may be busy with the network.
*/
private void startDriveModeAtStart() {
driveModeAtStart = new Thread(() -> {
long started = android.os.SystemClock.elapsedRealtime();
try {
while (true) {
Integer chosen = prefs.contains(DriveModeWrite.PREF_CHOSEN)
? prefs.getInt(DriveModeWrite.PREF_CHOSEN, 0) : null;
boolean enabled = prefs.getBoolean(DriveModeWrite.PREF_AT_START, true);
Integer gear = com.evsuite.hardware.EVHardware.INSTANCE.getGearRaw();
Float speed = com.evsuite.hardware.EVHardware.INSTANCE.getVehicleSpeedKmh();
Integer current = com.evsuite.hardware.EVHardware.INSTANCE.getDriveModeRaw();
DriveModeWrite.StartStep step = DriveModeWrite.atStart(chosen, enabled, gear, speed,
current, android.os.SystemClock.elapsedRealtime() - started);
if (step == DriveModeWrite.StartStep.WAIT) {
Thread.sleep(1000);
continue;
}
if (step == DriveModeWrite.StartStep.WRITE) {
DriveModeSetter.Result r = DriveModeSetter.set(chosen);
noteDriveModeAtStart(chosen, !r.written ? "REFUSED"
: DriveModeWrite.outcome(chosen, r.reported).name(), r.reported);
} else if (step == DriveModeWrite.StartStep.ALREADY) {
noteDriveModeAtStart(chosen, "ALREADY", current);
}
// GIVE_UP: nothing chosen, switched off, driven off or too late — nothing to say.
return;
}
} catch (InterruptedException ignored) {
// The service is stopping.
} catch (Throwable t) {
Log.w(TAG, "Drive mode at start failed: " + t);
}
}, "drive-mode-at-start");
driveModeAtStart.start();
}
/** Kept for the status line on Settings and logged: "25.10. 20:31|4|APPLIED|4". */
private void noteDriveModeAtStart(int code, String outcome, Integer reported) {
String line = new java.text.SimpleDateFormat("dd.MM. HH:mm", java.util.Locale.US).format(new java.util.Date())
+ "|" + code + "|" + outcome + "|" + (reported == null ? "" : reported);
prefs.edit().putString(DriveModeWrite.PREF_START_RESULT, line).apply();
Log.i(TAG, "Drive mode at start: " + line);
}
// ---------- Sampling cycle (runs on scheduler thread) ----------
private void safeUploadCycle() {
try {
doUpload();
} catch (Throwable t) {
Log.e(TAG, "upload cycle threw", t);
}
}
private void doUpload() {
long sampleMs = System.currentTimeMillis();
EnergySnapshot vehicle = energyReader.read(sampleMs);
// Keeps the 5-minute range average going while the app is in the background.
RangeEstimator.shared().add(android.os.SystemClock.elapsedRealtime(),
vehicle.getBatteryPowerKw(), vehicle.getSpeedKmh());
consumptionTracker.sample(vehicle);
chargeSessionTracker.sample(vehicle);
consumptionCloudClient.syncIfDue(consumptionTracker, chargeSessionTracker, sampleMs);
UploadSettings settings = UploadSettings.from(prefs);
// Live data goes to the server, to ABRP directly, or both; with neither set up there is
// nowhere to send it.
if (!ServerConfig.get(this).isConfigured() && !AbrpConfig.get(this).isConfigured()) {
telemetryUploader.stop(); // no live telemetry, so no GPS either
updateNotification(getString(R.string.server_not_configured));
} else if (!settings.abrpUploadEnabled) {
telemetryUploader.stop();
updateNotification(getString(R.string.notif_tracking_locally));
} else {
telemetryUploader.tick(vehicle, chargeSessionTracker.snapshot(), settings);
int queued = telemetryUploader.queued();
updateNotification(queued > 0 ? "Sending telemetry — " + queued + " queued" : "Sending telemetry");
}
}
// ---------- 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);
return new NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(getString(R.string.notif_title))
.setContentText(status)
.setContentIntent(openApp)
.setOngoing(true)
.build();
}
private void updateNotification(String status) {
if (status.equals(lastStatus)) return;
lastStatus = status;
getSystemService(NotificationManager.class).notify(NOTIF_ID, buildNotification(status));
}
}