App 2.22.2: gear badge shows P, R, N and D

Codes read on the owner's car with 2.22.1: 1 P, 2 R, 3 N, 4 D. Gear
maps them (unit-tested); other codes show no letter, unread "—". The
temporary gear list on the Logs tab is removed.

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-24 19:10:03 +02:00
co-authored by Claude Opus 5.5
parent 16cc2746ef
commit c6643e7df2
12 changed files with 33 additions and 94 deletions
+1
View File
@@ -53,6 +53,7 @@ Repository: `ssh://git@git.stinnesbeck.com:2222/nils/mg4.git` (remote `origin`),
- Display 1920×720 px at **160 dpi**; the app window is **1778×720 px** (the car's own sidebar), - Display 1920×720 px at **160 dpi**; the app window is **1778×720 px** (the car's own sidebar),
about **1778×640 dp** for layouts. Pages must fit that without scrolling where possible. about **1778×640 dp** for layouts. Pages must fit that without scrolling where possible.
- Signed with the public **AOSP test platform key**; the car build runs as `android.uid.system`. - Signed with the public **AOSP test platform key**; the car build runs as `android.uid.system`.
- Gear codes (`EVHardware.getGearRaw`): 1 P, 2 R, 3 N, 4 D (read off the lever).
- Steering-wheel keys arrive as the broadcast `com.saic.keyevent.hardkey.report` - Steering-wheel keys arrive as the broadcast `com.saic.keyevent.hardkey.report`
(`android.intent.extra.hardkey.keycode` / `.down` / `.longpress`); the voice key is **287**. (`android.intent.extra.hardkey.keycode` / `.down` / `.longpress`); the voice key is **287**.
- Not published by the car: battery, cabin and outside temperature, pack energy and capacity, - Not published by the car: battery, cabin and outside temperature, pack energy and capacity,
@@ -2118,9 +2118,9 @@ object EVHardware {
fun isVehicleInPark(): Boolean? = getGearRaw()?.let { it == GEAR_PARK_VALUE } fun isVehicleInPark(): Boolean? = getGearRaw()?.let { it == GEAR_PARK_VALUE }
/** /**
* The gear as the car's own code, null if unreadable. Only P = 1 is established (see * The gear as the car's own code, null if unreadable. P = 1 on every studied firmware (see
* [isVehicleInPark]); the other codes are passed on unnamed so the app can show them and * [isVehicleInPark]); on an MG4 with SWI68 (SWI68-29958-1300R69) the owner read 1 P, 2 R,
* the driver can match them to R, N and D. * 3 N, 4 D by shifting through the lever.
*/ */
fun getGearRaw(): Int? { fun getGearRaw(): Int? {
val gen = FirmwareInfo.getGeneration() val gen = FirmwareInfo.getGeneration()
@@ -585,7 +585,8 @@ object SaicVehicleControl {
* own `GEAR_PARK_VALUE`, two head-unit apps using `carGear == 1` as the condition for playing * own `GEAR_PARK_VALUE`, two head-unit apps using `carGear == 1` as the condition for playing
* video on the centre screen (`UsbVideoView`, `VideoListFragment`), and * video on the centre screen (`UsbVideoView`, `VideoListFragment`), and
* `IBackUpBinder.isSecureForBackUp`, which accepts gears 1 and 3 alongside `speed == 0`. * `IBackUpBinder.isSecureForBackUp`, which accepts gears 1 and 3 alongside `speed == 0`.
* Nothing pins 2, 3 and 4, so nothing here names them. * Nothing in the code pins 2, 3 and 4; on an MG4 with SWI68-29958-1300R69 the owner read them
* off the lever as 2 R, 3 N, 4 D (the app's `Gear` names them; this layer passes codes on).
* *
* `getCarSpeed` is on this interface too and is deliberately unused: the binder returns a * `getCarSpeed` is on this interface too and is deliberately unused: the binder returns a
* float with no unit in any source, and a speed read in the wrong unit would open the write * float with no unit in any source, and a speed read in the wrong unit would open the write
@@ -48,7 +48,7 @@ data class EnergySnapshot(
*/ */
val driveModeRaw: Int? = null, val driveModeRaw: Int? = null,
val regenLevelRaw: Int? = null, val regenLevelRaw: Int? = null,
/** The gear's raw code, unmapped beyond P = 1; null where the car did not answer. */ /** The gear's raw code (MG4 SWI68: 1 P, 2 R, 3 N, 4 D); null where the car did not answer. */
val gearRaw: Int? = null, val gearRaw: Int? = null,
) { ) {
val hasVehicleData: Boolean val hasVehicleData: Boolean
@@ -27,7 +27,7 @@ interface EnergySignalSource {
fun vehicleConsumedKwh(): Float? = null fun vehicleConsumedKwh(): Float? = null
fun vehicleRegeneratedKwh(): Float? = null fun vehicleRegeneratedKwh(): Float? = null
fun parked(): Boolean? fun parked(): Boolean?
/** The gear's raw code; only 1 (P) is named. Null where unread. */ /** The gear's raw code (MG4 SWI68: 1 P, 2 R, 3 N, 4 D). Null where unread. */
fun gearRaw(): Int? = null fun gearRaw(): Int? = null
fun batteryVoltageV(): Float? = null fun batteryVoltageV(): Float? = null
fun batteryCurrentA(): Float? = null fun batteryCurrentA(): Float? = null
+6
View File
@@ -131,6 +131,12 @@ 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 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` 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). (Android 9 AOSP at 1920×720, 160 dpi; the car build installs as the system user — checked).
- 2026-10-24 — App 2.22.2 (branch `dashboard-cockpit`): the gear badge names all four gears.
The owner shifted through the lever with 2.22.1 and read the codes off the Logs tab: 1 P,
2 R, 3 N, 4 D (firmware SWI68-29958-1300R69). `Gear.label` maps them (unit-tested); any
other code shows no letter, unread "—". The gear list on the Logs tab, which existed only for
this, is gone again. Codes recorded in AGENTS.md and EVHardware's comments. Unit tests only;
the mapping itself comes from the car.
- 2026-10-24 — App 2.22.1 (branch `dashboard-cockpit`): the gear badge on the Dashboard and - 2026-10-24 — App 2.22.1 (branch `dashboard-cockpit`): the gear badge on the Dashboard and
Cockpit no longer guesses. It showed "D" for anything not in P, so N and R read as D. The app Cockpit no longer guesses. It showed "D" for anything not in P, so N and R read as D. The app
only knew "in P or not"; EVHardware now passes on the car's raw gear code only knew "in P or not"; EVHardware now passes on the car's raw gear code
+2 -2
View File
@@ -34,8 +34,8 @@ android {
applicationId "com.stinnesbeck.nilsmg4" applicationId "com.stinnesbeck.nilsmg4"
minSdk 27 minSdk 27
targetSdk 36 targetSdk 36
versionCode 112 versionCode 113
versionName "2.22.1" versionName "2.22.2"
// Nothing about the server ships in the APK: its URL and ingest token are entered in // 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. // the app's Server tab (ServerConfig), and the database is only reachable by the server.
+6 -9
View File
@@ -1,23 +1,20 @@
package com.evsuite.abrp; package com.evsuite.abrp;
/** /**
* The gear letter for the dashboards, from the car's raw gear code. * The gear letter for the dashboards, from the car's raw gear code: 1 P, 2 R, 3 N, 4 D, read
* * on the owner's car (firmware SWI68-29958-1300R69) by shifting through the lever. Any other
* Only code 1 is known to be P (EVHardware, two head-unit apps and the reversing camera * code shows no letter rather than a guess; unread is "—".
* agree). The codes for R, N and D are not established yet, so any other code shows no letter
* rather than a guessed "D": the Logs tab lists the codes the car reports, for the driver to
* match them to the lever. Unread is "—".
*/ */
final class Gear { final class Gear {
static final int PARK = 1; private static final String[] LETTERS = { null, "P", "R", "N", "D" };
private Gear() {} private Gear() {}
/** "P", "" for a gear not yet named, "—" when unread. */ /** "P", "R", "N" or "D"; "" for an unknown code, "—" when unread. */
static String label(Integer raw) { static String label(Integer raw) {
if (raw == null) return "—"; if (raw == null) return "—";
if (raw == PARK) return "P"; if (raw > 0 && raw < LETTERS.length) return LETTERS[raw];
return ""; return "";
} }
} }
@@ -1,63 +0,0 @@
package com.evsuite.abrp;
import android.content.Context;
import android.content.SharedPreferences;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.List;
import java.util.Locale;
/**
* Every change of the car's raw gear code with the time and speed, the last {@link #MAX_LINES},
* shown on the Logs tab. It is how the codes for R, N and D get matched to the lever: shift
* through them and read which code each one gave. Kept in preferences so the list survives
* the app being closed in between. Written only when the code changes.
*/
final class GearLog {
static final int MAX_LINES = 12;
private static final String PREFS = "gear_log";
private static final String KEY = "lines";
/** The last code seen in this process; MIN_VALUE before the first reading. */
private static int last = Integer.MIN_VALUE;
private GearLog() {}
/** Called with every reading; records a line when the code differs from the last one. */
static void note(Context context, Integer raw, Float speedKmh) {
int code = raw == null ? -1 : raw;
synchronized (GearLog.class) {
if (context == null || code == last) return;
last = code;
String line = new SimpleDateFormat("dd.MM. HH:mm:ss", Locale.US).format(new Date())
+ " code " + (raw == null ? "unread" : String.valueOf(raw))
+ (raw != null && raw == Gear.PARK ? " (P)" : "")
+ " at " + (speedKmh == null ? "—" : String.format(Locale.US, "%.0f km/h", speedKmh));
SharedPreferences prefs = context.getApplicationContext()
.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
List<String> lines = UpdateLog.append(split(prefs.getString(KEY, null)), line, MAX_LINES);
prefs.edit().putString(KEY, String.join("\n", lines)).apply();
}
}
/** The code now, null when unread or not read yet. */
static synchronized Integer current() {
return last < 0 ? null : last;
}
/** Newest first. */
static List<String> lines(Context context) {
SharedPreferences prefs = context.getApplicationContext()
.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
return split(prefs.getString(KEY, null));
}
private static List<String> split(String stored) {
if (stored == null || stored.isEmpty()) return new ArrayList<>();
return new ArrayList<>(Arrays.asList(stored.split("\n")));
}
}
@@ -1575,7 +1575,6 @@ public class MainActivity extends AppCompatActivity {
vehTireFr.setText(unit.format(s.getTirePressures().getFrontRightKpa())); vehTireFr.setText(unit.format(s.getTirePressures().getFrontRightKpa()));
vehTireRl.setText(unit.format(s.getTirePressures().getRearLeftKpa())); vehTireRl.setText(unit.format(s.getTirePressures().getRearLeftKpa()));
vehTireRr.setText(unit.format(s.getTirePressures().getRearRightKpa())); vehTireRr.setText(unit.format(s.getTirePressures().getRearRightKpa()));
GearLog.note(this, s.getGearRaw(), s.getSpeedKmh());
showDashboard(s); showDashboard(s);
showCockpit(s); showCockpit(s);
} catch (Throwable ignored) { } catch (Throwable ignored) {
@@ -1900,14 +1899,6 @@ public class MainActivity extends AppCompatActivity {
// Update steps first: after a failed install, they are what the driver came here for. // Update steps first: after a failed install, they are what the driver came here for.
java.util.List<String> updates = UpdateLog.lines(this); java.util.List<String> updates = UpdateLog.lines(this);
StringBuilder sb = new StringBuilder(); StringBuilder sb = new StringBuilder();
// The car's gear codes, for matching R, N and D to the lever (only P = 1 is known).
java.util.List<String> gears = GearLog.lines(this);
if (!gears.isEmpty()) {
Integer now = GearLog.current();
sb.append(getString(R.string.gear_log_title, now == null ? "—" : String.valueOf(now)));
for (String line : gears) sb.append('\n').append(line);
sb.append("\n\n");
}
if (!updates.isEmpty()) { if (!updates.isEmpty()) {
sb.append(getString(R.string.update_log_title)); sb.append(getString(R.string.update_log_title));
for (String line : updates) sb.append('\n').append(line); for (String line : updates) sb.append('\n').append(line);
-1
View File
@@ -187,7 +187,6 @@
<string name="label_call_log">Recent uploads</string> <string name="label_call_log">Recent uploads</string>
<string name="call_log_empty">No upload attempted yet.</string> <string name="call_log_empty">No upload attempted yet.</string>
<string name="update_log_title">Updates, newest first:</string> <string name="update_log_title">Updates, newest first:</string>
<string name="gear_log_title">Gear codes, newest first (now: %1$s; only 1 = P is known):</string>
<string name="btn_flush_now">Upload now</string> <string name="btn_flush_now">Upload now</string>
<string name="status_starting">Starting…</string> <string name="status_starting">Starting…</string>
<string name="status_running_no_upload">Running — waiting for first upload</string> <string name="status_running_no_upload">Running — waiting for first upload</string>
@@ -8,11 +8,18 @@ import org.junit.Test;
public class GearTest { public class GearTest {
@Test @Test
public void onlyParkIsNamed() { public void theFourCodesReadOnTheCar() {
assertEquals("P", Gear.label(1)); assertEquals("P", Gear.label(1));
assertEquals("", Gear.label(2)); assertEquals("R", Gear.label(2));
assertEquals("", Gear.label(3)); assertEquals("N", Gear.label(3));
assertEquals("", Gear.label(4)); assertEquals("D", Gear.label(4));
}
@Test
public void unknownCodesShowNoLetter() {
assertEquals("", Gear.label(0));
assertEquals("", Gear.label(5));
assertEquals("", Gear.label(-1));
} }
@Test @Test