From ce2ebd7f5828bb5a44d43fd64bf03f8304543eb3 Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Sun, 25 Oct 2026 20:02:53 +0100 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6 --- AGENTS.md | 12 ++- .../java/com/evsuite/hardware/EVHardware.kt | 10 +++ LICENSE.md | 3 +- README.md | 2 +- SPECS.md | 14 +++ app/build.gradle | 4 +- .../java/com/evsuite/abrp/DriveModeWrite.java | 44 +++++++++ .../java/com/evsuite/abrp/MainActivity.java | 89 +++++++++++++++++++ app/src/main/res/color/choice_stroke.xml | 3 + .../main/res/layout-w1000dp/pane_settings.xml | 19 +++- app/src/main/res/layout/pane_settings.xml | 14 +++ app/src/main/res/values/strings.xml | 16 ++++ app/src/main/res/values/strings_design.xml | 1 + app/src/main/res/values/styles_app.xml | 7 ++ .../com/evsuite/abrp/DriveModeWriteTest.java | 42 +++++++++ 15 files changed, 268 insertions(+), 12 deletions(-) create mode 100644 app/src/main/java/com/evsuite/abrp/DriveModeWrite.java create mode 100644 app/src/main/res/color/choice_stroke.xml create mode 100644 app/src/test/java/com/evsuite/abrp/DriveModeWriteTest.java diff --git a/AGENTS.md b/AGENTS.md index ca065a1..63ddf16 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,9 +35,13 @@ Repository: `ssh://git@git.stinnesbeck.com:2222/nils/mg4.git` (remote `origin`), `secrets.example.yaml` holds `CHANGE-ME` placeholders only. Stage files explicitly, check the 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 app never writes a vehicle setting. 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. +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 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 shown as "—". Sending 0 for SOC tells ABRP the battery is empty. 5. **Fail closed.** Updates (origin, SHA-256, package, signature), credentials, unknown state. @@ -73,7 +77,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 | 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, charging price, 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`), 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, diff --git a/EVHardware/lib/src/main/java/com/evsuite/hardware/EVHardware.kt b/EVHardware/lib/src/main/java/com/evsuite/hardware/EVHardware.kt index 90733a5..0b9e3a8 100644 --- a/EVHardware/lib/src/main/java/com/evsuite/hardware/EVHardware.kt +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/EVHardware.kt @@ -2432,6 +2432,16 @@ object EVHardware { return ok } + /** + * The drive mode write Nils' MG4 uses: the CarPropertyManager write alone, behind + * [VehicleWriteGate] like every write here, without [setDriveMode]'s fallback binder + * transaction or its remembered "last mode". True means the write call went through, not + * that the car took it: read [getDriveModeRaw] back. + */ + @RequiresStandstill + fun writeDriveModeProperty(mode: DriveMode): Boolean = + setIntPropertyCPM(PROP_DRIVE_MODE, AREA_GLOBAL, mode.value) + @RequiresStandstill fun setRegenLevel(level: RegenLevel): Boolean { if (logEnabled) AppLogger.i(TAG, "setRegenLevel → ${level.label} (${level.value})") diff --git a/LICENSE.md b/LICENSE.md index e3073fe..a4aa48e 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -31,7 +31,8 @@ top. Those are Fatih's reads the car needs and upstream does not have — the ve ids `0x21401553`–`56`, the vendor mileage `0x21401566` for the odometer, the charging status from `0x2140F409`, SoC, range, power, pack voltage and current on SWI69 as on SWI68, and SWI69 speed already in km/h — plus translated comments and, from this fork, the gear's raw code -(`EVHardware.getGearRaw`, `EnergySnapshot.gearRaw`). To take upstream again, merge three ways with +(`EVHardware.getGearRaw`, `EnergySnapshot.gearRaw`) and a drive mode write without the binder +fallback (`writeDriveModeProperty`). To take upstream again, merge three ways with `e4e1158` as the base. The car app links EVHardware into its APK, so the **built app** may only be used and passed on diff --git a/README.md b/README.md index 0d5104f..d25d679 100644 --- a/README.md +++ b/README.md @@ -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** | 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), 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 diff --git a/SPECS.md b/SPECS.md index be55c75..a53de74 100644 --- a/SPECS.md +++ b/SPECS.md @@ -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.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, + 6, 7 at `0x2140A17C`; the 2.32.0 recording read 2, Eco). The lit button is the mode the car + reports, from every 0.2 s reading. A tap reads gear and speed fresh and writes only in P at + 0 km/h (`DriveModeWrite`, pure and unit-tested; unreadable gear or speed is a refusal), through + the new `EVHardware.writeDriveModeProperty`: the CarPropertyManager write behind + `VehicleWriteGate` (0 km/h, supported firmware), without `setDriveMode`'s fallback binder + transaction. Then the mode is read back for up to 3 s, and the status beside the title says + what the car reports ("set", "not applied: the car still reports …", "could not be read + back"). One change at a time, on the settings thread; each attempt goes to logcat. To fit the + page, the wide layout's charging price row moved into the battery card ("Battery and charging + price"). Emulator: the card fits, a tap is refused (no gear or speed there). Whether the car + takes the write at all is untested: the first try on the car decides. - 2026-10-25 — App 2.32.1 (branch `styles-retro-future`): the cruise control work ends. The ACC recording of 2.32.0 (ACC set to 101, 90 and 80 km/h, a car ahead) found nothing: see "Signals the car does not provide". Removed: `CruiseLog` and its section on the Logs tab, and in diff --git a/app/build.gradle b/app/build.gradle index 4ee7695..96cc34b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -34,8 +34,8 @@ android { applicationId "com.stinnesbeck.nilsmg4" minSdk 27 targetSdk 36 - versionCode 131 - versionName "2.32.1" + versionCode 132 + versionName "2.33.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. diff --git a/app/src/main/java/com/evsuite/abrp/DriveModeWrite.java b/app/src/main/java/com/evsuite/abrp/DriveModeWrite.java new file mode 100644 index 0000000..aa4923f --- /dev/null +++ b/app/src/main/java/com/evsuite/abrp/DriveModeWrite.java @@ -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; + } +} diff --git a/app/src/main/java/com/evsuite/abrp/MainActivity.java b/app/src/main/java/com/evsuite/abrp/MainActivity.java index 79eab4a..76e3af2 100644 --- a/app/src/main/java/com/evsuite/abrp/MainActivity.java +++ b/app/src/main/java/com/evsuite/abrp/MainActivity.java @@ -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. } diff --git a/app/src/main/res/color/choice_stroke.xml b/app/src/main/res/color/choice_stroke.xml new file mode 100644 index 0000000..ebccae9 --- /dev/null +++ b/app/src/main/res/color/choice_stroke.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/layout-w1000dp/pane_settings.xml b/app/src/main/res/layout-w1000dp/pane_settings.xml index 62b585c..687dd5f 100644 --- a/app/src/main/res/layout-w1000dp/pane_settings.xml +++ b/app/src/main/res/layout-w1000dp/pane_settings.xml @@ -44,8 +44,22 @@ + + + + + + + + + + + + + + - + @@ -60,9 +74,6 @@ - - - diff --git a/app/src/main/res/layout/pane_settings.xml b/app/src/main/res/layout/pane_settings.xml index c596939..ae57788 100644 --- a/app/src/main/res/layout/pane_settings.xml +++ b/app/src/main/res/layout/pane_settings.xml @@ -29,6 +29,20 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9e5cdc5..9b30145 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -302,6 +302,22 @@ Recent uploads + DRIVE MODE + Eco + Normal + Sport + Snow + Custom + Changes only in P, at a standstill + Setting %1$s… + %1$s set: the car reports it now. + %1$s not applied: the car still reports %2$s. + %1$s sent, but the car\'s mode could not be read back. + %1$s not set: the car did not accept the change. + Not changed: shift to P first. + Not changed: only at a standstill. + Not changed: speed or gear unreadable. + an unknown mode (%1$s) Signal recorder 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). Record 3 minutes diff --git a/app/src/main/res/values/strings_design.xml b/app/src/main/res/values/strings_design.xml index a58d8f9..1ea2c88 100644 --- a/app/src/main/res/values/strings_design.xml +++ b/app/src/main/res/values/strings_design.xml @@ -16,6 +16,7 @@ BATTERY APP CHARGING PRICE + BATTERY AND CHARGING PRICE ON YOUR PHONE UPDATE FREQUENCY Tire pressure positions on the vehicle diff --git a/app/src/main/res/values/styles_app.xml b/app/src/main/res/values/styles_app.xml index 7c74ff9..0cb2ba5 100644 --- a/app/src/main/res/values/styles_app.xml +++ b/app/src/main/res/values/styles_app.xml @@ -23,6 +23,13 @@ 18dp 0dp + +