From ed65c963195fc8f8cb2eb4d3626066971ac64498 Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Sun, 25 Oct 2026 20:12:11 +0100 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6 --- AGENTS.md | 12 ++-- README.md | 2 +- SPECS.md | 14 +++++ app/build.gradle | 4 +- .../com/evsuite/abrp/AbrpUploadService.java | 56 +++++++++++++++++++ .../com/evsuite/abrp/DriveModeSetter.java | 38 +++++++++++++ .../java/com/evsuite/abrp/DriveModeWrite.java | 37 +++++++++++- .../java/com/evsuite/abrp/MainActivity.java | 49 +++++++++++----- .../main/res/layout-w1000dp/pane_settings.xml | 15 ++--- app/src/main/res/layout/pane_settings.xml | 15 ++--- app/src/main/res/values/strings.xml | 10 +++- app/src/main/res/values/styles_app.xml | 1 + .../com/evsuite/abrp/DriveModeWriteTest.java | 26 +++++++++ 13 files changed, 241 insertions(+), 38 deletions(-) create mode 100644 app/src/main/java/com/evsuite/abrp/DriveModeSetter.java diff --git a/AGENTS.md b/AGENTS.md index 63ddf16..41d01b4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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, diff --git a/README.md b/README.md index d25d679..f4dee9f 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** | 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 diff --git a/SPECS.md b/SPECS.md index a53de74..eb20ec0 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.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, diff --git a/app/build.gradle b/app/build.gradle index 96cc34b..5f6327b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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. diff --git a/app/src/main/java/com/evsuite/abrp/AbrpUploadService.java b/app/src/main/java/com/evsuite/abrp/AbrpUploadService.java index 6f0f086..7fd3a54 100644 --- a/app/src/main/java/com/evsuite/abrp/AbrpUploadService.java +++ b/app/src/main/java/com/evsuite/abrp/AbrpUploadService.java @@ -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() { diff --git a/app/src/main/java/com/evsuite/abrp/DriveModeSetter.java b/app/src/main/java/com/evsuite/abrp/DriveModeSetter.java new file mode 100644 index 0000000..4aa9e31 --- /dev/null +++ b/app/src/main/java/com/evsuite/abrp/DriveModeSetter.java @@ -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); + } +} diff --git a/app/src/main/java/com/evsuite/abrp/DriveModeWrite.java b/app/src/main/java/com/evsuite/abrp/DriveModeWrite.java index aa4923f..8d1d4a8 100644 --- a/app/src/main/java/com/evsuite/abrp/DriveModeWrite.java +++ b/app/src/main/java/com/evsuite/abrp/DriveModeWrite.java @@ -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; diff --git a/app/src/main/java/com/evsuite/abrp/MainActivity.java b/app/src/main/java/com/evsuite/abrp/MainActivity.java index 76e3af2..b885c44 100644 --- a/app/src/main/java/com/evsuite/abrp/MainActivity.java +++ b/app/src/main/java/com/evsuite/abrp/MainActivity.java @@ -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); } diff --git a/app/src/main/res/layout-w1000dp/pane_settings.xml b/app/src/main/res/layout-w1000dp/pane_settings.xml index 687dd5f..67f45c2 100644 --- a/app/src/main/res/layout-w1000dp/pane_settings.xml +++ b/app/src/main/res/layout-w1000dp/pane_settings.xml @@ -47,16 +47,17 @@ - - + + - - - - - + + + + + + diff --git a/app/src/main/res/layout/pane_settings.xml b/app/src/main/res/layout/pane_settings.xml index ae57788..68c6e06 100644 --- a/app/src/main/res/layout/pane_settings.xml +++ b/app/src/main/res/layout/pane_settings.xml @@ -32,16 +32,17 @@ - - + + - - - - - + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9b30145..685d0f8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -39,7 +39,7 @@ over the last 5 minutes Usable battery capacity Save 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. + For range estimates. Usable kWh: 50.8 (51 kWh), 61.7 (64), 74.4 (77). Usable capacity saved Enter a value between 10 and 150 kWh Speed @@ -308,7 +308,13 @@ Sport Snow Custom - Changes only in P, at a standstill + Only in P at a standstill. At start: your last confirmed mode. + Set at start + At start (%1$s): %2$s set. + At start (%1$s): %2$s was already set. + At start (%1$s): %2$s not applied, the car reports %3$s. + At start (%1$s): %2$s sent, not read back. + At start (%1$s): %2$s not accepted by the car. Setting %1$s… %1$s set: the car reports it now. %1$s not applied: the car still reports %2$s. diff --git a/app/src/main/res/values/styles_app.xml b/app/src/main/res/values/styles_app.xml index 0cb2ba5..296cb0a 100644 --- a/app/src/main/res/values/styles_app.xml +++ b/app/src/main/res/values/styles_app.xml @@ -28,6 +28,7 @@ 4dp 4dp center + 56dp @color/choice_stroke