App 2.33.0: set the drive mode from Settings, only in P at a standstill
The owner lifted the read-only rule for the drive mode alone. Settings has Eco, Normal, Sport, Snow and Custom; the lit button is what the car reports. A tap reads gear and speed fresh and writes only in P at 0 km/h (DriveModeWrite, unit-tested; unreadable is a refusal), through a CPM-only write behind EVHardware's standstill gate, then reads the mode back and says what the car reports. The wide layout's charging price joined the battery card so the page still fits. 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:
@@ -0,0 +1,44 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
/**
|
||||
* Changing the drive mode from Settings — the one vehicle setting this app writes (owner's
|
||||
* decision, 2026-10-25; AGENTS.md rule 3). The rules, kept here so they are tested:
|
||||
*
|
||||
* - 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.
|
||||
*
|
||||
* Codes are the car's (`0x2140A17C`, EVHardware's {@code DriveMode}): Eco 2, Normal 3, Sport 4,
|
||||
* Snow 6, Custom 7. Pure Java, unit-tested.
|
||||
*/
|
||||
final class DriveModeWrite {
|
||||
|
||||
/** The modes offered, in the order of the buttons. */
|
||||
static final int[] CODES = { 2, 3, 4, 6, 7 };
|
||||
static final int P = 1;
|
||||
|
||||
enum Verdict { ALLOWED, NOT_IN_P, MOVING, UNREADABLE }
|
||||
|
||||
enum Outcome { APPLIED, NOT_APPLIED, UNREAD }
|
||||
|
||||
private DriveModeWrite() {}
|
||||
|
||||
static Verdict check(Float speedKmh, Integer gear) {
|
||||
if (speedKmh == null || speedKmh.isNaN() || speedKmh < 0f || gear == null) return Verdict.UNREADABLE;
|
||||
if (gear != P) return Verdict.NOT_IN_P;
|
||||
if (speedKmh != 0f) return Verdict.MOVING;
|
||||
return Verdict.ALLOWED;
|
||||
}
|
||||
|
||||
static Outcome outcome(int asked, Integer readBack) {
|
||||
if (readBack == null) return Outcome.UNREAD;
|
||||
return readBack == asked ? Outcome.APPLIED : Outcome.NOT_APPLIED;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
for (int i = 0; i < CODES.length; i++) if (CODES[i] == code) return i;
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
@@ -1177,6 +1177,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
prefs.edit().putBoolean(UploadSettings.KEY_VOICE_KEY_OPENS, checked).apply());
|
||||
bindTireUnit();
|
||||
bindMascotCharacter();
|
||||
bindDriveMode();
|
||||
bindOverviewStyle();
|
||||
bindAccentColor();
|
||||
fullScreenSwitch = settingsPane.findViewById(R.id.full_screen_switch);
|
||||
@@ -1318,6 +1319,93 @@ public class MainActivity extends AppCompatActivity {
|
||||
}
|
||||
|
||||
/** Who sits in the Mascot style; the drop-down shows only while that style is chosen. */
|
||||
private static final int[] DRIVE_MODE_IDS = { R.id.drive_mode_eco, R.id.drive_mode_normal,
|
||||
R.id.drive_mode_sport, R.id.drive_mode_snow, R.id.drive_mode_custom };
|
||||
private static final int[] DRIVE_MODE_LABELS = { R.string.drive_mode_eco, R.string.drive_mode_normal,
|
||||
R.string.drive_mode_sport, R.string.drive_mode_snow, R.string.drive_mode_custom };
|
||||
/** True from a tap on a drive mode until the car's answer is in: one change at a time. */
|
||||
private final java.util.concurrent.atomic.AtomicBoolean driveModeBusy =
|
||||
new java.util.concurrent.atomic.AtomicBoolean();
|
||||
|
||||
/** The drive mode buttons on Settings: a tap asks the car, the highlight shows what it reports. */
|
||||
private void bindDriveMode() {
|
||||
for (int i = 0; i < DRIVE_MODE_IDS.length; i++) {
|
||||
final int code = DriveModeWrite.CODES[i];
|
||||
settingsPane.findViewById(DRIVE_MODE_IDS[i]).setOnClickListener(v -> requestDriveMode(code));
|
||||
}
|
||||
}
|
||||
|
||||
/** The highlight follows the car's reported mode, from every reading. */
|
||||
private void showDriveMode(Integer code) {
|
||||
if (settingsPane == null) return;
|
||||
int selected = DriveModeWrite.indexOf(code);
|
||||
for (int i = 0; i < DRIVE_MODE_IDS.length; i++) {
|
||||
settingsPane.findViewById(DRIVE_MODE_IDS[i]).setSelected(i == selected);
|
||||
}
|
||||
}
|
||||
|
||||
private String driveModeName(Integer code) {
|
||||
int i = DriveModeWrite.indexOf(code);
|
||||
return i >= 0 ? getString(DRIVE_MODE_LABELS[i]) : getString(R.string.drive_mode_unknown, String.valueOf(code));
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the drive mode, on the settings thread: gear and speed read fresh (P, 0 km/h, or
|
||||
* nothing), the write itself behind EVHardware's standstill gate, then the car's mode read
|
||||
* back for up to three seconds. The status line says what the car reports, not what was asked.
|
||||
*/
|
||||
private void requestDriveMode(int code) {
|
||||
if (!driveModeBusy.compareAndSet(false, true)) return;
|
||||
TextView status = settingsPane.findViewById(R.id.drive_mode_status);
|
||||
String name = driveModeName(code);
|
||||
status.setText(getString(R.string.drive_mode_setting, name));
|
||||
try {
|
||||
settingsWriter.execute(() -> {
|
||||
String message;
|
||||
try {
|
||||
message = writeDriveMode(code, name);
|
||||
} catch (Throwable t) {
|
||||
message = getString(R.string.drive_mode_write_failed, name);
|
||||
}
|
||||
android.util.Log.i("NilsMG4.DriveMode", "Drive mode " + code + ": " + message);
|
||||
final String result = message;
|
||||
runOnUiThread(() -> {
|
||||
driveModeBusy.set(false);
|
||||
if (!isDestroyed()) status.setText(result);
|
||||
});
|
||||
});
|
||||
} catch (java.util.concurrent.RejectedExecutionException e) {
|
||||
driveModeBusy.set(false); // the activity is going away
|
||||
}
|
||||
}
|
||||
|
||||
private String writeDriveMode(int code, String name) throws InterruptedException {
|
||||
switch (DriveModeWrite.check(com.evsuite.hardware.EVHardware.INSTANCE.getVehicleSpeedKmh(),
|
||||
com.evsuite.hardware.EVHardware.INSTANCE.getGearRaw())) {
|
||||
case NOT_IN_P: return getString(R.string.drive_mode_not_in_p);
|
||||
case MOVING: return getString(R.string.drive_mode_moving);
|
||||
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));
|
||||
switch (DriveModeWrite.outcome(code, now)) {
|
||||
case APPLIED: 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);
|
||||
}
|
||||
}
|
||||
|
||||
private void bindMascotCharacter() {
|
||||
MascotCharacter[] characters = MascotCharacter.values();
|
||||
String[] labels = { getString(R.string.mascot_character_classic), getString(R.string.mascot_character_chibi),
|
||||
@@ -1808,6 +1896,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
showGauges(s);
|
||||
showCockpit(s);
|
||||
showDrawnStyles(s);
|
||||
showDriveMode(s.getDriveModeRaw());
|
||||
} catch (Throwable ignored) {
|
||||
// Leave the last good reading on screen.
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- A choice's outline: the accent when it is the one in effect, the plain outline otherwise. -->
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android"><item android:state_selected="true" android:color="?attr/evAccentStroke"/><item android:color="@color/ev_outline"/></selector>
|
||||
@@ -44,8 +44,22 @@
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:layout_marginEnd="16dp">
|
||||
<!-- 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" />
|
||||
</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" />
|
||||
</LinearLayout>
|
||||
</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_settings_title" android:textAppearance="@style/Text.EV.Eyebrow" />
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_battery_price_title" android:textAppearance="@style/Text.EV.Eyebrow" />
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:orientation="horizontal" android:gravity="center_vertical" android:baselineAligned="false">
|
||||
<com.google.android.material.textfield.TextInputLayout android:id="@+id/consumption_soh_layout" style="@style/Widget.Material3.TextInputLayout.OutlinedBox" android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content" android:hint="@string/settings_manual_soh" app:suffixText="@string/unit_percent">
|
||||
<com.google.android.material.textfield.TextInputEditText android:id="@+id/consumption_soh_input" android:layout_width="match_parent" android:layout_height="wrap_content" android:inputType="numberDecimal" android:maxLength="5" android:minHeight="72dp"/>
|
||||
@@ -60,9 +74,6 @@
|
||||
<com.google.android.material.button.MaterialButton android:id="@+id/usable_capacity_save" style="@style/Widget.EV.Button" android:layout_width="wrap_content" android:layout_height="72dp" android:layout_marginStart="12dp" android:text="@string/btn_save_capacity"/>
|
||||
</LinearLayout>
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:text="@string/helper_usable_capacity" 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" android:layout_marginTop="12dp">
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_price_title" android:textAppearance="@style/Text.EV.Eyebrow" />
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:orientation="horizontal" android:gravity="center_vertical" android:baselineAligned="false">
|
||||
<com.google.android.material.textfield.TextInputLayout android:id="@+id/charge_price_layout" style="@style/Widget.Material3.TextInputLayout.OutlinedBox" android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content" android:hint="@string/settings_unit_price" app:suffixText="@string/unit_eur_per_kwh">
|
||||
<com.google.android.material.textfield.TextInputEditText android:id="@+id/charge_price_input" android:layout_width="match_parent" android:layout_height="wrap_content" android:inputType="numberDecimal" android:maxLength="10" android:minHeight="72dp"/>
|
||||
|
||||
@@ -29,6 +29,20 @@
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
<!-- 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" />
|
||||
</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" />
|
||||
</LinearLayout>
|
||||
</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" />
|
||||
<com.google.android.material.textfield.TextInputLayout android:id="@+id/consumption_soh_layout" style="@style/Widget.Material3.TextInputLayout.OutlinedBox" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:hint="@string/settings_manual_soh" app:suffixText="@string/unit_percent">
|
||||
|
||||
@@ -302,6 +302,22 @@
|
||||
|
||||
<!-- Call log -->
|
||||
<string name="label_call_log">Recent uploads</string>
|
||||
<string name="label_drive_mode">DRIVE MODE</string>
|
||||
<string name="drive_mode_eco">Eco</string>
|
||||
<string name="drive_mode_normal">Normal</string>
|
||||
<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_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>
|
||||
<string name="drive_mode_unread_back">%1$s sent, but the car\'s mode could not be read back.</string>
|
||||
<string name="drive_mode_write_failed">%1$s not set: the car did not accept the change.</string>
|
||||
<string name="drive_mode_not_in_p">Not changed: shift to P first.</string>
|
||||
<string name="drive_mode_moving">Not changed: only at a standstill.</string>
|
||||
<string name="drive_mode_unreadable">Not changed: speed or gear unreadable.</string>
|
||||
<string name="drive_mode_unknown">an unknown mode (%1$s)</string>
|
||||
<string name="label_signal_recorder">Signal recorder</string>
|
||||
<string name="signal_recorder_hint">Records every value the head unit offers for 3 minutes, plus the steering-wheel keys it reports, and lists what changed, the rarest first. To find a signal: start it, note the times you switch the feature on and off or change it, and look for the values that changed then. With a USB stick plugged in, the whole recording is saved on it too (Android/data/…/files).</string>
|
||||
<string name="btn_signal_record">Record 3 minutes</string>
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
<string name="design_battery_settings_title">BATTERY</string>
|
||||
<string name="design_app_title">APP</string>
|
||||
<string name="design_price_title">CHARGING PRICE</string>
|
||||
<string name="design_battery_price_title">BATTERY AND CHARGING PRICE</string>
|
||||
<string name="design_phone_title">ON YOUR PHONE</string>
|
||||
<string name="design_service_timing">UPDATE FREQUENCY</string>
|
||||
<string name="design_car_desc">Tire pressure positions on the vehicle</string>
|
||||
|
||||
@@ -23,6 +23,13 @@
|
||||
<item name="cornerRadius">18dp</item>
|
||||
<item name="elevation">0dp</item>
|
||||
</style>
|
||||
<!-- One of a row of choices, highlighted (isSelected) like the navigation's current page. -->
|
||||
<style name="Widget.EV.Choice" parent="Widget.EV.NavTab">
|
||||
<item name="android:paddingStart">4dp</item>
|
||||
<item name="android:paddingEnd">4dp</item>
|
||||
<item name="android:gravity">center</item>
|
||||
<item name="strokeColor">@color/choice_stroke</item>
|
||||
</style>
|
||||
<style name="Widget.EV.NavTab.Compact">
|
||||
<item name="android:paddingStart">2dp</item>
|
||||
<item name="android:paddingEnd">2dp</item>
|
||||
|
||||
Reference in New Issue
Block a user