Files
mg4/app/src/main/java/com/evsuite/abrp/AbrpUploadService.java
T
nilsandClaude Opus 5.5 eebe840b6a App 2.41.0, server 2.9.0: recordings reach the server
A finished signal recording goes to an outbox and is sent gzipped with
the car's token (PUT /v1/logs/{name}); the service retries every 2
minutes while the car is offline. The server keeps the newest 200 on a
new volume (LOGS_DIR); reading takes a token of its own (LOGS_TOKEN).
fetch-logs in the dev shell fetches new ones into logs/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-10-27 10:22:40 +01:00

275 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);
LogOutbox.sendIfDue(this); // recordings the car could not send when they ended
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));
}
}