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
This commit is contained in:
2026-10-25 20:12:11 +01:00
co-authored by Claude Opus 5.5
parent ce2ebd7f58
commit ed65c96319
13 changed files with 241 additions and 38 deletions
@@ -69,6 +69,8 @@ public class AbrpUploadService extends Service {
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. */
@@ -100,6 +102,7 @@ public class AbrpUploadService extends Service {
telemetryUploader = new TelemetryUploader(getApplicationContext());
steeringKeys = new SteeringKeyListener(this, prefs);
steeringKeys.register();
startDriveModeAtStart();
scheduler.scheduleWithFixedDelay(
this::safeUploadCycle,
@@ -145,9 +148,62 @@ public class AbrpUploadService extends Service {
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() {
@@ -0,0 +1,38 @@
package com.evsuite.abrp;
import com.evsuite.hardware.EVHardware;
import com.evsuite.hardware.model.DriveMode;
/**
* The drive mode write and its read-back, shared by the Settings buttons and the start
* (DriveModeWrite has the rules; the caller checks them first). Off the main thread: it
* waits up to three seconds for the car to report the new mode.
*/
final class DriveModeSetter {
private DriveModeSetter() {}
static final class Result {
/** False when the write was refused or failed: nothing was sent. */
final boolean written;
/** The mode the car reports afterwards; null when it could not be read. */
final Integer reported;
Result(boolean written, Integer reported) {
this.written = written;
this.reported = reported;
}
}
static Result set(int code) throws InterruptedException {
DriveMode mode = DriveMode.Companion.ofRaw(code);
if (mode == null || !EVHardware.INSTANCE.writeDriveModeProperty(mode)) return new Result(false, null);
Integer now = null;
for (int i = 0; i < 10; i++) {
Thread.sleep(300);
now = EVHardware.INSTANCE.getDriveModeRaw();
if (now != null && now == code) break;
}
return new Result(true, now);
}
}
@@ -6,7 +6,10 @@ package com.evsuite.abrp;
*
* - only in P and at 0 km/h, and nothing when the gear or the speed cannot be read (fail
* closed); EVHardware's own {@code VehicleWriteGate} checks the speed again at the write;
* - the result is what the car reports afterwards, never what was asked for.
* - the result is what the car reports afterwards, never what was asked for;
* - at start ({@link #atStart}), the mode the driver last chose and the car confirmed is set
* again, once, while the car is still in P before it first drives off (owner's decision,
* 2026-10-25): the car falls back to its own default mode when it is switched on.
*
* Codes are the car's (`0x2140A17C`, EVHardware's {@code DriveMode}): Eco 2, Normal 3, Sport 4,
* Snow 6, Custom 7. Pure Java, unit-tested.
@@ -21,6 +24,26 @@ final class DriveModeWrite {
enum Outcome { APPLIED, NOT_APPLIED, UNREAD }
/** The mode last chosen on Settings that the car confirmed; absent until one is. */
static final String PREF_CHOSEN = "drive_mode_chosen";
/** Whether the chosen mode is set again at start (the switch on Settings, on by default). */
static final String PREF_AT_START = "drive_mode_at_start";
/** The last start's result, with its time, for the status line on Settings. */
static final String PREF_START_RESULT = "drive_mode_start_result";
/** How long after the head unit starts the start check keeps waiting for P and readings. */
static final long START_WINDOW_MS = 2 * 60_000L;
/** What the start check does with one reading. */
enum StartStep {
/** Readings not in yet: look again. */
WAIT,
/** The car already reports the chosen mode: nothing to write. */
ALREADY,
/** Write the chosen mode, once. */
WRITE,
/** Nothing chosen, switched off, out of P or moving (driven off), or too late. */
GIVE_UP
}
private DriveModeWrite() {}
static Verdict check(Float speedKmh, Integer gear) {
@@ -35,6 +58,18 @@ final class DriveModeWrite {
return readBack == asked ? Outcome.APPLIED : Outcome.NOT_APPLIED;
}
/**
* The start check, one reading at a time. Out of P or moving means the drive has begun,
* and then the driver's own choice on the car stands: no write until the next start.
*/
static StartStep atStart(Integer chosen, boolean enabled, Integer gear, Float speedKmh,
Integer current, long sinceStartMs) {
if (!enabled || indexOf(chosen) < 0 || sinceStartMs > START_WINDOW_MS) return StartStep.GIVE_UP;
if (gear == null || speedKmh == null || speedKmh.isNaN() || current == null) return StartStep.WAIT;
if (check(speedKmh, gear) != Verdict.ALLOWED) return StartStep.GIVE_UP;
return current.equals(chosen) ? StartStep.ALREADY : StartStep.WRITE;
}
/** The button for a reported code, or -1 when the code is unread or not one offered. */
static int indexOf(Integer code) {
if (code == null) return -1;
@@ -1333,6 +1333,34 @@ public class MainActivity extends AppCompatActivity {
final int code = DriveModeWrite.CODES[i];
settingsPane.findViewById(DRIVE_MODE_IDS[i]).setOnClickListener(v -> requestDriveMode(code));
}
SwitchMaterial atStart = settingsPane.findViewById(R.id.drive_mode_at_start_switch);
atStart.setChecked(prefs.getBoolean(DriveModeWrite.PREF_AT_START, true));
atStart.setOnCheckedChangeListener((b, checked) ->
prefs.edit().putBoolean(DriveModeWrite.PREF_AT_START, checked).apply());
String started = driveModeAtStartText();
if (started != null) ((TextView) settingsPane.findViewById(R.id.drive_mode_status)).setText(started);
}
/** The last start's result as a sentence, from "25.10. 20:31|4|APPLIED|4"; null when none. */
private String driveModeAtStartText() {
String stored = prefs.getString(DriveModeWrite.PREF_START_RESULT, null);
if (stored == null) return null;
String[] p = stored.split("\\|", -1);
if (p.length < 4) return null;
try {
String when = p[0];
String name = driveModeName(Integer.valueOf(p[1]));
Integer reported = p[3].isEmpty() ? null : Integer.valueOf(p[3]);
switch (p[2]) {
case "APPLIED": return getString(R.string.drive_mode_start_applied, when, name);
case "ALREADY": return getString(R.string.drive_mode_start_already, when, name);
case "NOT_APPLIED": return getString(R.string.drive_mode_start_not_applied, when, name, driveModeName(reported));
case "UNREAD": return getString(R.string.drive_mode_start_unread, when, name);
default: return getString(R.string.drive_mode_start_refused, when, name);
}
} catch (NumberFormatException e) {
return null;
}
}
/** The highlight follows the car's reported mode, from every reading. */
@@ -1387,20 +1415,15 @@ public class MainActivity extends AppCompatActivity {
case UNREADABLE: return getString(R.string.drive_mode_unreadable);
default: break;
}
com.evsuite.hardware.model.DriveMode mode = com.evsuite.hardware.model.DriveMode.Companion.ofRaw(code);
if (mode == null || !com.evsuite.hardware.EVHardware.INSTANCE.writeDriveModeProperty(mode)) {
return getString(R.string.drive_mode_write_failed, name);
}
Integer now = null;
for (int i = 0; i < 10; i++) {
Thread.sleep(300);
now = com.evsuite.hardware.EVHardware.INSTANCE.getDriveModeRaw();
if (now != null && now == code) break;
}
final Integer reported = now;
runOnUiThread(() -> showDriveMode(reported));
DriveModeSetter.Result result = DriveModeSetter.set(code);
if (!result.written) return getString(R.string.drive_mode_write_failed, name);
final Integer now = result.reported;
runOnUiThread(() -> showDriveMode(now));
switch (DriveModeWrite.outcome(code, now)) {
case APPLIED: return getString(R.string.drive_mode_applied, name);
case APPLIED:
// Confirmed by the car: this is the mode set again at start.
prefs.edit().putInt(DriveModeWrite.PREF_CHOSEN, code).apply();
return getString(R.string.drive_mode_applied, name);
case NOT_APPLIED: return getString(R.string.drive_mode_not_applied, name, driveModeName(now));
default: return getString(R.string.drive_mode_unread_back, name);
}
@@ -47,16 +47,17 @@
<!-- The drive mode: the one vehicle setting the app writes, only in P (DriveModeWrite). -->
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:paddingStart="24dp" android:paddingEnd="24dp" android:paddingTop="16dp" android:paddingBottom="16dp" android:background="@drawable/bg_glass" android:layout_marginBottom="16dp">
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/label_drive_mode" android:textAppearance="@style/Text.EV.Eyebrow" />
<TextView android:id="@+id/drive_mode_status" android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content" android:layout_marginStart="16dp" android:gravity="end" android:text="@string/drive_mode_hint" android:textAppearance="@style/Text.EV.Secondary" />
<TextView android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content" android:text="@string/label_drive_mode" android:textAppearance="@style/Text.EV.Eyebrow" />
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/drive_mode_at_start_switch" style="@style/Widget.EV.Switch" android:layout_width="wrap_content" android:layout_height="48dp" android:minHeight="48dp" android:layout_marginStart="16dp" android:text="@string/drive_mode_at_start" />
</LinearLayout>
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:layout_marginTop="8dp">
<com.google.android.material.button.MaterialButton android:id="@+id/drive_mode_eco" style="@style/Widget.EV.Choice" android:layout_width="0dp" android:layout_height="64dp" android:layout_weight="1" android:layout_marginEnd="6dp" android:text="@string/drive_mode_eco" />
<com.google.android.material.button.MaterialButton android:id="@+id/drive_mode_normal" style="@style/Widget.EV.Choice" android:layout_width="0dp" android:layout_height="64dp" android:layout_weight="1" android:layout_marginEnd="6dp" android:text="@string/drive_mode_normal" />
<com.google.android.material.button.MaterialButton android:id="@+id/drive_mode_sport" style="@style/Widget.EV.Choice" android:layout_width="0dp" android:layout_height="64dp" android:layout_weight="1" android:layout_marginEnd="6dp" android:text="@string/drive_mode_sport" />
<com.google.android.material.button.MaterialButton android:id="@+id/drive_mode_snow" style="@style/Widget.EV.Choice" android:layout_width="0dp" android:layout_height="64dp" android:layout_weight="1" android:layout_marginEnd="6dp" android:text="@string/drive_mode_snow" />
<com.google.android.material.button.MaterialButton android:id="@+id/drive_mode_custom" style="@style/Widget.EV.Choice" android:layout_width="0dp" android:layout_height="64dp" android:layout_weight="1" android:text="@string/drive_mode_custom" />
<com.google.android.material.button.MaterialButton android:id="@+id/drive_mode_eco" style="@style/Widget.EV.Choice" android:layout_width="0dp" android:layout_height="56dp" android:layout_weight="1" android:layout_marginEnd="6dp" android:text="@string/drive_mode_eco" />
<com.google.android.material.button.MaterialButton android:id="@+id/drive_mode_normal" style="@style/Widget.EV.Choice" android:layout_width="0dp" android:layout_height="56dp" android:layout_weight="1" android:layout_marginEnd="6dp" android:text="@string/drive_mode_normal" />
<com.google.android.material.button.MaterialButton android:id="@+id/drive_mode_sport" style="@style/Widget.EV.Choice" android:layout_width="0dp" android:layout_height="56dp" android:layout_weight="1" android:layout_marginEnd="6dp" android:text="@string/drive_mode_sport" />
<com.google.android.material.button.MaterialButton android:id="@+id/drive_mode_snow" style="@style/Widget.EV.Choice" android:layout_width="0dp" android:layout_height="56dp" android:layout_weight="1" android:layout_marginEnd="6dp" android:text="@string/drive_mode_snow" />
<com.google.android.material.button.MaterialButton android:id="@+id/drive_mode_custom" style="@style/Widget.EV.Choice" android:layout_width="0dp" android:layout_height="56dp" android:layout_weight="1" android:text="@string/drive_mode_custom" />
</LinearLayout>
<TextView android:id="@+id/drive_mode_status" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:maxLines="2" android:ellipsize="end" android:text="@string/drive_mode_hint" android:textAppearance="@style/Text.EV.Secondary" />
</LinearLayout>
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:paddingStart="24dp" android:paddingEnd="24dp" android:paddingTop="16dp" android:paddingBottom="16dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_battery_price_title" android:textAppearance="@style/Text.EV.Eyebrow" />
+8 -7
View File
@@ -32,16 +32,17 @@
<!-- The drive mode: the one vehicle setting the app writes, only in P (DriveModeWrite). -->
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="24dp" android:background="@drawable/bg_glass" android:layout_marginTop="16dp">
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/label_drive_mode" android:textAppearance="@style/Text.EV.Eyebrow" />
<TextView android:id="@+id/drive_mode_status" android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content" android:layout_marginStart="16dp" android:gravity="end" android:text="@string/drive_mode_hint" android:textAppearance="@style/Text.EV.Secondary" />
<TextView android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content" android:text="@string/label_drive_mode" android:textAppearance="@style/Text.EV.Eyebrow" />
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/drive_mode_at_start_switch" style="@style/Widget.EV.Switch" android:layout_width="wrap_content" android:layout_height="48dp" android:minHeight="48dp" android:layout_marginStart="16dp" android:text="@string/drive_mode_at_start" />
</LinearLayout>
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:layout_marginTop="8dp">
<com.google.android.material.button.MaterialButton android:id="@+id/drive_mode_eco" style="@style/Widget.EV.Choice" android:layout_width="0dp" android:layout_height="64dp" android:layout_weight="1" android:layout_marginEnd="6dp" android:text="@string/drive_mode_eco" />
<com.google.android.material.button.MaterialButton android:id="@+id/drive_mode_normal" style="@style/Widget.EV.Choice" android:layout_width="0dp" android:layout_height="64dp" android:layout_weight="1" android:layout_marginEnd="6dp" android:text="@string/drive_mode_normal" />
<com.google.android.material.button.MaterialButton android:id="@+id/drive_mode_sport" style="@style/Widget.EV.Choice" android:layout_width="0dp" android:layout_height="64dp" android:layout_weight="1" android:layout_marginEnd="6dp" android:text="@string/drive_mode_sport" />
<com.google.android.material.button.MaterialButton android:id="@+id/drive_mode_snow" style="@style/Widget.EV.Choice" android:layout_width="0dp" android:layout_height="64dp" android:layout_weight="1" android:layout_marginEnd="6dp" android:text="@string/drive_mode_snow" />
<com.google.android.material.button.MaterialButton android:id="@+id/drive_mode_custom" style="@style/Widget.EV.Choice" android:layout_width="0dp" android:layout_height="64dp" android:layout_weight="1" android:text="@string/drive_mode_custom" />
<com.google.android.material.button.MaterialButton android:id="@+id/drive_mode_eco" style="@style/Widget.EV.Choice" android:layout_width="0dp" android:layout_height="56dp" android:layout_weight="1" android:layout_marginEnd="6dp" android:text="@string/drive_mode_eco" />
<com.google.android.material.button.MaterialButton android:id="@+id/drive_mode_normal" style="@style/Widget.EV.Choice" android:layout_width="0dp" android:layout_height="56dp" android:layout_weight="1" android:layout_marginEnd="6dp" android:text="@string/drive_mode_normal" />
<com.google.android.material.button.MaterialButton android:id="@+id/drive_mode_sport" style="@style/Widget.EV.Choice" android:layout_width="0dp" android:layout_height="56dp" android:layout_weight="1" android:layout_marginEnd="6dp" android:text="@string/drive_mode_sport" />
<com.google.android.material.button.MaterialButton android:id="@+id/drive_mode_snow" style="@style/Widget.EV.Choice" android:layout_width="0dp" android:layout_height="56dp" android:layout_weight="1" android:layout_marginEnd="6dp" android:text="@string/drive_mode_snow" />
<com.google.android.material.button.MaterialButton android:id="@+id/drive_mode_custom" style="@style/Widget.EV.Choice" android:layout_width="0dp" android:layout_height="56dp" android:layout_weight="1" android:text="@string/drive_mode_custom" />
</LinearLayout>
<TextView android:id="@+id/drive_mode_status" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:maxLines="2" android:ellipsize="end" android:text="@string/drive_mode_hint" android:textAppearance="@style/Text.EV.Secondary" />
</LinearLayout>
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="24dp" android:background="@drawable/bg_glass" android:layout_marginTop="16dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_battery_settings_title" android:textAppearance="@style/Text.EV.Eyebrow" />
+8 -2
View File
@@ -39,7 +39,7 @@
<string name="usage_last_5_min">over the last 5 minutes</string>
<string name="hint_usable_capacity">Usable battery capacity</string>
<string name="btn_save_capacity">Save capacity</string>
<string name="helper_usable_capacity">Used for the range estimates on the Overview. The 51 kWh MG4 battery has 50.8 kWh usable, the 64 kWh one 61.7, the 77 kWh one 74.4.</string>
<string name="helper_usable_capacity">For range estimates. Usable kWh: 50.8 (51 kWh), 61.7 (64), 74.4 (77).</string>
<string name="usable_capacity_saved">Usable capacity saved</string>
<string name="usable_capacity_invalid">Enter a value between 10 and 150 kWh</string>
<string name="vehicle_row_speed">Speed</string>
@@ -308,7 +308,13 @@
<string name="drive_mode_sport">Sport</string>
<string name="drive_mode_snow">Snow</string>
<string name="drive_mode_custom">Custom</string>
<string name="drive_mode_hint">Changes only in P, at a standstill</string>
<string name="drive_mode_hint">Only in P at a standstill. At start: your last confirmed mode.</string>
<string name="drive_mode_at_start">Set at start</string>
<string name="drive_mode_start_applied">At start (%1$s): %2$s set.</string>
<string name="drive_mode_start_already">At start (%1$s): %2$s was already set.</string>
<string name="drive_mode_start_not_applied">At start (%1$s): %2$s not applied, the car reports %3$s.</string>
<string name="drive_mode_start_unread">At start (%1$s): %2$s sent, not read back.</string>
<string name="drive_mode_start_refused">At start (%1$s): %2$s not accepted by the car.</string>
<string name="drive_mode_setting">Setting %1$s…</string>
<string name="drive_mode_applied">%1$s set: the car reports it now.</string>
<string name="drive_mode_not_applied">%1$s not applied: the car still reports %2$s.</string>
+1
View File
@@ -28,6 +28,7 @@
<item name="android:paddingStart">4dp</item>
<item name="android:paddingEnd">4dp</item>
<item name="android:gravity">center</item>
<item name="android:minHeight">56dp</item>
<item name="strokeColor">@color/choice_stroke</item>
</style>
<style name="Widget.EV.NavTab.Compact">