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
275 lines
12 KiB
Java
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));
|
|
}
|
|
}
|