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
+7 -5
View File
@@ -36,10 +36,12 @@ Repository: `ssh://git@git.stinnesbeck.com:2222/nils/mg4.git` (remote `origin`),
index for changes that are not yours (the owner works in parallel), and scan the staged diff
for secrets before every commit. Never print a token.
3. **Telemetry is read-only; the drive mode is the one exception.** The app writes no vehicle
setting except the drive mode (owner's decision, 2026-10-25): from Settings only, a tap by the
driver, only in P at 0 km/h with both read fresh (unreadable means no write), through
EVHardware's standstill gate, and the result shown is what the car reports back
(`DriveModeWrite`). Nothing else gets written, and nothing writes on its own. The only system
setting except the drive mode (owner's decision, 2026-10-25): a tap on Settings, or — with
"Set at start" on — once when the head unit starts, the mode last chosen there and confirmed
by the car, and only before the car first leaves P. Every write only in P at 0 km/h with both
read fresh (unreadable means no write), through EVHardware's standstill gate; the result
shown is what the car reports back (`DriveModeWrite`, `DriveModeSetter`). Nothing else is
written. The only system
setting it touches is `policy_control`, and only to remove the CarPlay entry the withdrawn
full-screen experiment (2.19.2–2.19.5) may have left.
4. **A failed read is not a zero.** Unreadable values are `null`, omitted from payloads and
@@ -77,7 +79,7 @@ Repository: `ssh://git@git.stinnesbeck.com:2222/nils/mg4.git` (remote `origin`),
| Consumption / Charging | period totals, daily log, charging sessions and curve |
| Live data | on/off, ABRP API key and user token, upload frequency |
| Server | URL, pairing by QR, ingest token, installation ID (read-only), config import |
| Settings | drive mode (Eco, Normal, Sport, Snow, Custom; the lit one is what the car reports; changes only in P at a standstill, `DriveModeWrite`), full screen, voice key, Overview style with the accent colour beside it, the Mascot character (only for that style) (offered only for styles drawn in it, `OverviewStyle.usesAccent`; Retro and Terminal add "Original" for their own palette, `hasOriginal`), tyre unit, battery and charging price (one card on wide screens), version, update check, install from USB, About |
| Settings | drive mode (Eco, Normal, Sport, Snow, Custom; the lit one is what the car reports; changes only in P at a standstill, `DriveModeWrite`; "Set at start" sets the last confirmed choice again when the service starts with the head unit), full screen, voice key, Overview style with the accent colour beside it, the Mascot character (only for that style) (offered only for styles drawn in it, `OverviewStyle.usesAccent`; Retro and Terminal add "Original" for their own palette, `hasOriginal`), tyre unit, battery and charging price (one card on wide screens), version, update check, install from USB, About |
| Logs | signal recorder (`SignalRecorder`/`SignalRecording`: every property and vendor getter via `VendorSurfaceProbe.capture`, again and again for 3 minutes, plus steering-wheel key broadcasts; lists what changed, rarest first; saved to the app and a USB stick; how to find where the car publishes something), update log (every install step, with the platform's words), upload log |
Key classes (`app/src/main/java/com/evsuite/abrp/`): `AbrpUploadService` (foreground service,
+1 -1
View File
@@ -84,7 +84,7 @@ with the server using a token of its own, obtained by pairing with a phone (belo
| **Charging** | The current session (SOC, voltage, current, power, energy into the battery and from the grid, time, cost) and its SOC/power curve |
| **Live data** | Live data on/off, ABRP credentials for sending directly from the car, upload frequency |
| **Server** | Server URL, pairing by QR code, ingest token, installation ID, config import |
| **Settings** | Drive mode (Eco, Normal, Sport, Snow, Custom: changes only in P at a standstill; the highlighted one is what the car reports), full screen, voice key, tyre pressure unit, accent colour, battery (manual SoH, usable capacity), charging price, entry on the phone, version, update check, install from USB stick, About |
| **Settings** | Drive mode (Eco, Normal, Sport, Snow, Custom: changes only in P at a standstill; the highlighted one is what the car reports; with "Set at start" the last confirmed choice is set again when the car starts, while it is still in P), full screen, voice key, tyre pressure unit, accent colour, battery (manual SoH, usable capacity), charging price, entry on the phone, version, update check, install from USB stick, About |
| **Logs** | Update log (every install step), upload log |
- **Opening the app:** the MG4 launcher does not list third-party apps. **Double-press the
+14
View File
@@ -138,6 +138,20 @@ tyre pressures, GPS (lat, lon, elevation, heading) and the is_* flags. It does *
confirmed car facts, app structure, UI conventions, build/test/ship, documents. It replaces
upstream's EVABRPUploader text. The test emulator is now in the flake: `nix run .#car-emulator`
(Android 9 AOSP at 1920×720, 160 dpi; the car build installs as the system user — checked).
- 2026-10-25 — App 2.34.0 (branch `styles-retro-future`): **drive mode at start.** The car falls
back to its own default mode when switched on; with "Set at start" (on by default, the switch
in the drive mode card) the app sets the mode last chosen on Settings again. Only a choice the
car confirmed counts (`PREF_CHOSEN`, saved on APPLIED). `AbrpUploadService` starts with the
head unit and runs `DriveModeWrite.atStart` (pure, tested) once a second on its own thread:
wait for gear, speed and mode; nothing when the car already reports the mode; one write, in P
at 0 km/h, through the same `DriveModeSetter` as the buttons (write, read back up to 3 s); give
up for this start as soon as the car is out of P or moving, or after 2 minutes. So a mode set
with the car's own button after driving off stands. The result is kept with its time and
shown under the buttons ("At start (25.10. 20:31): Sport set."). Assumes the head unit starts
(and the service with it) when the car is switched on; a start without that (head unit kept
running) is not caught. A service restart (app update, after a kill) counts as a start, still
only in P at 0 km/h. Hints shortened to fit the page. Emulator: page fits, the start check
waits and gives up (no readings), no crash. Not tried on the car.
- 2026-10-25 — App 2.33.0 (branch `styles-retro-future`): **drive mode from Settings**, the first
vehicle setting the app writes. The owner lifted rule 3 for the drive mode only (AGENTS.md).
A card at the top of Settings' second column: Eco, Normal, Sport, Snow, Custom (codes 2, 3, 4,
+2 -2
View File
@@ -34,8 +34,8 @@ android {
applicationId "com.stinnesbeck.nilsmg4"
minSdk 27
targetSdk 36
versionCode 132
versionName "2.33.0"
versionCode 133
versionName "2.34.0"
// Nothing about the server ships in the APK: its URL and ingest token are entered in
// the app's Server tab (ServerConfig), and the database is only reachable by the server.
@@ -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">
@@ -31,6 +31,32 @@ public class DriveModeWriteTest {
assertEquals(DriveModeWrite.Outcome.UNREAD, DriveModeWrite.outcome(4, null));
}
@Test
public void atStartWritesOnceTheChosenModeInP() {
assertEquals(DriveModeWrite.StartStep.WRITE, DriveModeWrite.atStart(4, true, 1, 0f, 2, 5_000));
assertEquals(DriveModeWrite.StartStep.ALREADY, DriveModeWrite.atStart(4, true, 1, 0f, 4, 5_000));
}
@Test
public void atStartWaitsForReadings() {
assertEquals(DriveModeWrite.StartStep.WAIT, DriveModeWrite.atStart(4, true, null, 0f, 2, 1_000));
assertEquals(DriveModeWrite.StartStep.WAIT, DriveModeWrite.atStart(4, true, 1, null, 2, 1_000));
assertEquals(DriveModeWrite.StartStep.WAIT, DriveModeWrite.atStart(4, true, 1, 0f, null, 1_000));
}
@Test
public void atStartGivesUp() {
// Nothing chosen (or a code not offered), switched off, too late.
assertEquals(DriveModeWrite.StartStep.GIVE_UP, DriveModeWrite.atStart(null, true, 1, 0f, 2, 1_000));
assertEquals(DriveModeWrite.StartStep.GIVE_UP, DriveModeWrite.atStart(5, true, 1, 0f, 2, 1_000));
assertEquals(DriveModeWrite.StartStep.GIVE_UP, DriveModeWrite.atStart(4, false, 1, 0f, 2, 1_000));
assertEquals(DriveModeWrite.StartStep.GIVE_UP,
DriveModeWrite.atStart(4, true, 1, 0f, 2, DriveModeWrite.START_WINDOW_MS + 1));
// Driven off: out of P, or moving.
assertEquals(DriveModeWrite.StartStep.GIVE_UP, DriveModeWrite.atStart(4, true, 4, 0f, 2, 1_000));
assertEquals(DriveModeWrite.StartStep.GIVE_UP, DriveModeWrite.atStart(4, true, 1, 3f, 2, 1_000));
}
@Test
public void buttonsByCode() {
assertEquals(0, DriveModeWrite.indexOf(2));