The Vehicle tab gets two tiles next to the car's own range: - Range at current power: remaining energy / (power / speed) right now. - Range at 5-min average: remaining energy / (energy used / distance driven) over the last 5 minutes, integrated time-weighted from both the service tick (15 s) and the open screen (2 s), so it is ready as soon as the app opens. Each shows the consumption it is based on (kWh/100 km). Remaining energy is SOC x usable capacity, because the car publishes no pack energy; the capacity is a new setting in the Consumption tab (default 50.8 kWh, the 51 kWh battery). Estimates read "—" with the reason when they cannot mean anything: standing still, regenerating, too little driving, or across a gap in the samples; over 999 km shows "> 999 km". RangeEstimator is Android-free and has 7 tests. Also fixes the battery card's note, which still said the SoH goes to MongoDB. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
213 lines
8.5 KiB
Java
213 lines
8.5 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 forwards to ABRP ({@link TelemetryUploader}). The car itself never
|
|
* talks to ABRP and holds no ABRP credentials.
|
|
*
|
|
* 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 WifiAutoConnect wifi;
|
|
private ScheduledExecutorService scheduler;
|
|
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();
|
|
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());
|
|
wifi = new WifiAutoConnect(getApplicationContext());
|
|
telemetryUploader = new TelemetryUploader(getApplicationContext());
|
|
|
|
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.stop();
|
|
Log.i(TAG, "Service stopped");
|
|
}
|
|
|
|
// ---------- 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(sampleMs, vehicle.getBatteryPowerKw(), vehicle.getSpeedKmh());
|
|
consumptionTracker.sample(vehicle);
|
|
chargeSessionTracker.sample(vehicle);
|
|
consumptionCloudClient.syncIfDue(consumptionTracker, chargeSessionTracker, sampleMs);
|
|
|
|
// 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);
|
|
}
|
|
|
|
UploadSettings settings = UploadSettings.from(prefs);
|
|
if (!ServerConfig.get(this).isConfigured()) {
|
|
updateNotification(getString(R.string.server_not_configured));
|
|
} else if (!settings.abrpUploadEnabled) {
|
|
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));
|
|
}
|
|
}
|