From 36cc41763824ff494d40b7721545e0a53163b127 Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Sun, 25 Oct 2026 09:33:17 +0100 Subject: [PATCH] App 2.31.1: log the cruise control while driving, with every assist value The 2.27.3 log wrote a line only when the target speed changed, so the car showed one line at the start. Now a line is written on any change of a mode or the target and every 2 s while driving, with the raw driver-assistance properties (target speed, assist and speed-assist modes, the followed object) and the reason for any unread value. The last 600 lines are kept in an appended file; the Logs tab shows 150. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6 --- AGENTS.md | 2 +- .../evsuite/hardware/CarPropertyEvidence.kt | 3 + .../java/com/evsuite/hardware/EVHardware.kt | 52 +++++- LICENSE.md | 3 +- SPECS.md | 14 ++ app/build.gradle | 4 +- .../main/java/com/evsuite/abrp/CruiseLog.java | 160 +++++++++++++----- .../java/com/evsuite/abrp/MainActivity.java | 12 +- app/src/main/res/values/strings.xml | 2 +- .../java/com/evsuite/abrp/CruiseLogTest.java | 50 +++++- 10 files changed, 246 insertions(+), 56 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0a10dcc..e13254d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,7 +74,7 @@ Repository: `ssh://git@git.stinnesbeck.com:2222/nils/mg4.git` (remote `origin`), | 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 | -| Logs | update log (every install step, with the platform's words), upload log | +| Logs | adaptive cruise control debug log (`CruiseLog`: the raw driver-assistance properties from `EVHardware.readAccDebug`, once a second; a line on change and every 2 s while driving; last 600 in a file), update log (every install step, with the platform's words), upload log | Key classes (`app/src/main/java/com/evsuite/abrp/`): `AbrpUploadService` (foreground service, 15 s tick), `TelemetryUploader` (+ `TelemetryQueue`, persisted), `TelemetryPayload` (server diff --git a/EVHardware/lib/src/main/java/com/evsuite/hardware/CarPropertyEvidence.kt b/EVHardware/lib/src/main/java/com/evsuite/hardware/CarPropertyEvidence.kt index d894e51..7c57009 100644 --- a/EVHardware/lib/src/main/java/com/evsuite/hardware/CarPropertyEvidence.kt +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/CarPropertyEvidence.kt @@ -172,6 +172,9 @@ object ReadFailureLog { lastReason.put(key, reason) != reason } + /** Why [key] last failed, or null when it answered (or was never read). */ + fun reason(key: String): String? = synchronized(lock) { lastReason[key] } + /** A property that starts answering again must not silence its next failure. */ fun clear(key: String) { synchronized(lock) { lastReason.remove(key) } 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 f30c588..0153836 100644 --- a/EVHardware/lib/src/main/java/com/evsuite/hardware/EVHardware.kt +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/EVHardware.kt @@ -434,6 +434,53 @@ object EVHardware { */ private const val PROP_CRUISE_TARGET_SPEED = 0x2160A15B // vendor float + /** + * The other driver-assistance properties of `CarAdvancedAssistedDrivingManager` that may + * say what the adaptive cruise control is doing, read raw for the Logs tab while their + * meaning is established on the car: the operation mode of traffic-jam / intelligent + * cruise assist (`LANE_TJAICAOPMD`, by the manager 0 no request, 1 object following, + * 2 lane centre, 3 combined), the speed assist (`SPEED_ASST_SYS`, `SPEED_ASST_MODE`, by the + * manager 0 off, 2 manual limiter, 3 intelligent), the traffic-jam assist switch, and the + * object the ACC follows (`ACC_OBJ_*`: existence probability, longitudinal distance and + * relative speed). + */ + private val ACC_DEBUG_PROPS = listOf( + AccDebugProp("cc", PROP_CRUISE_TARGET_SPEED, float = true), + AccDebugProp("ica", 0x2140A12A, float = false), // AAD_LANE_TJAICAOPMD + AccDebugProp("tja", 0x2140A107, float = false), // AAD_TRAFFIC_JAM_ASST_SYS + AccDebugProp("sas", 0x2140A101, float = false), // AAD_SPEED_ASST_SYS + AccDebugProp("sam", 0x2140A102, float = false), // AAD_SPEED_ASST_MODE + AccDebugProp("obj", 0x2160A12F, float = true), // AAD_ACC_OBJ_EXIST_PRBLTY + AccDebugProp("dist", 0x2160A12C, float = true), // AAD_ACC_OBJ_LONG_RLTV_DIST + AccDebugProp("rel", 0x2160A12E, float = true), // AAD_ACC_OBJ_LONG_RLTV_SPD + ) + + private class AccDebugProp(val name: String, val id: Int, val float: Boolean) + + /** The cruise control target speed as the car reports it, unmapped; null when unread. */ + fun getCruiseTargetSpeedRaw(): Float? = supportedTelemetryRead { + getFloatPropertyCPM(PROP_CRUISE_TARGET_SPEED, AREA_GLOBAL)?.takeIf { it.isFinite() } + } + + /** + * The driver-assistance properties above, one raw reading each, for debugging the adaptive + * cruise control on the car: name to value (floats to two decimals), or to "unread" with + * the reason the car gave. Read only; about eight calls, so not on the main thread. + */ + fun readAccDebug(): List> = ACC_DEBUG_PROPS.map { prop -> + val value: Any? = supportedTelemetryRead { + if (prop.float) getFloatPropertyCPM(prop.id, AREA_GLOBAL)?.takeIf { it.isFinite() } + else readPropertyCPM(prop.id, AREA_GLOBAL, Int::class.javaObjectType) { sCpmGetIntMethod } as? Int + } + prop.name to when (value) { + is Float -> String.format(java.util.Locale.US, "%.2f", value) + null -> "unread" + (ReadFailureLog.reason( + "0x${Integer.toHexString(prop.id)}@0x${Integer.toHexString(AREA_GLOBAL)}") + ?.let { " ($it)" } ?: "") + else -> value.toString() + } + } + /** * `HVAC_AMBIENT_TEMPERATURE`, the one in-car candidate left on this firmware. * @@ -538,11 +585,6 @@ object EVHardware { * answers, and this is what is left on a unit where it does not. * See [PROP_BMS_CHRG_STS] for the values. */ - /** The cruise control target speed as the car reports it, unmapped; null when unread. */ - fun getCruiseTargetSpeedRaw(): Float? = supportedTelemetryRead { - getFloatPropertyCPM(PROP_CRUISE_TARGET_SPEED, AREA_GLOBAL)?.takeIf { it.isFinite() } - } - fun getVendorChargeStatus(): Int? = getIntPropertyCPM(PROP_BMS_CHRG_STS, AREA_GLOBAL).takeIf { it >= 0 } diff --git a/LICENSE.md b/LICENSE.md index ff980ae..a73c0c3 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -32,7 +32,8 @@ ids `0x21401553`–`56`, the vendor mileage `0x21401566` for the odometer, the c 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`) and the cruise control target speed -(`getCruiseTargetSpeedRaw`, `EnergySnapshot.cruiseTargetSpeedRaw`). To take upstream again, merge three ways with +(`getCruiseTargetSpeedRaw`, `EnergySnapshot.cruiseTargetSpeedRaw`) with the other driver-assistance +properties read raw for debugging (`readAccDebug`, `ReadFailureLog.reason`). 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/SPECS.md b/SPECS.md index 8b72fab..402c5fb 100644 --- a/SPECS.md +++ b/SPECS.md @@ -131,6 +131,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.31.1 (branch `styles-retro-future`): the cruise control log now logs while + driving. 2.27.3 wrote a line only when the target speed changed, so on the car it showed one + line at the start. Now `CruiseLog` writes a line at once when a mode or the target changes and + every 2 s while driving (D or R, or moving), with every driver-assistance property that may + describe the ACC, raw (`EVHardware.readAccDebug`, read once a second on the reader thread): + the target speed (`cc`, `0x2160A15B`), the traffic-jam / intelligent cruise assist operation + mode (`ica`, `AAD_LANE_TJAICAOPMD` `0x2140A12A`), the traffic-jam assist (`tja`, `0x2140A107`), + the speed assist system and mode (`sas`/`sam`, `0x2140A101`/`0x2140A102`) and the object the + ACC follows (`obj`/`dist`/`rel`, `AAD_ACC_OBJ_*` `0x2160A12F`/`0x2160A12C`/`0x2160A12E`; these + alone never write a line). An unread value carries the reason the car gave + (`ReadFailureLog.reason`). The last 600 lines live in `files/cruise_log.txt`, appended to; the + Logs tab shows the newest 150. The old change-only log in preferences is dropped. Only while the + app is open (the reader runs with the screen). Emulator: every value unread, the file written + and kept over a restart. Not tried on the car. - 2026-10-25 — App 2.31.0 (branch `styles-retro-future`): the Overview's full screen moved into the navigation: its button now sits beside the MG4 logo (at the end of the bottom bar on narrow screens) instead of over the page's top-right corner, and only shows on the Overview. A swipe diff --git a/app/build.gradle b/app/build.gradle index bdb8dc0..05bdf3d 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -34,8 +34,8 @@ android { applicationId "com.stinnesbeck.nilsmg4" minSdk 27 targetSdk 36 - versionCode 128 - versionName "2.31.0" + versionCode 129 + versionName "2.31.1" // 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/CruiseLog.java b/app/src/main/java/com/evsuite/abrp/CruiseLog.java index 2108189..740b8a4 100644 --- a/app/src/main/java/com/evsuite/abrp/CruiseLog.java +++ b/app/src/main/java/com/evsuite/abrp/CruiseLog.java @@ -1,69 +1,153 @@ package com.evsuite.abrp; import android.content.Context; -import android.content.SharedPreferences; +import java.io.BufferedReader; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.nio.charset.StandardCharsets; import java.text.SimpleDateFormat; +import java.util.ArrayDeque; import java.util.ArrayList; -import java.util.Arrays; import java.util.Date; +import java.util.Iterator; import java.util.List; import java.util.Locale; /** - * Every change of the cruise control target speed the car reports (`0x2160A15B`, raw, unit not - * established) with the time, speed and gear, the last {@link #MAX_LINES}, shown on the Logs - * tab. It is how the value gets matched to what the cluster shows: set the cruise control, change - * the speed, switch it off, and read what the car reported. Kept in preferences; a line is - * written only when the value (to 0.1) changes. + * The adaptive cruise control debug log on the Logs tab: the driver-assistance properties the + * car reports (`EVHardware.readAccDebug`, raw: the cruise control target speed, the assist + * modes, the object the ACC follows) with the time, speed and gear. It is how they get matched + * to what the cluster shows: drive with the app open, set the cruise control, change the speed + * and the distance, switch it off, and read what the car reported. + * + * A line is written at once when a mode or the target speed changes, and every + * {@link #DRIVING_EVERY_MS} while driving (the followed object's values change all the time, + * so they alone never write one). The last {@link #MAX_LINES} lines are kept in a file in the + * app's own storage, appended to, so a drive does not rewrite it every few seconds. */ final class CruiseLog { - static final int MAX_LINES = 12; - private static final String PREFS = "cruise_log"; - private static final String KEY = "lines"; + /** About 20 minutes of driving at one line per two seconds. */ + static final int MAX_LINES = 600; + /** The newest lines the Logs tab shows. */ + static final int SHOWN_LINES = 150; + static final long DRIVING_EVERY_MS = 2000; + /** Values that change all the time while following a car: they never write a line alone. */ + private static final List MOVING = java.util.Arrays.asList("obj", "dist", "rel"); + private static final String FILE = "cruise_log.txt"; - /** The last value seen in this process, as text ("unread" when null); null before the first. */ - private static String last; + private static ArrayDeque lines; + private static String lastSteady; + private static long lastWriteMs; + private static String current = "—"; private CruiseLog() {} - /** The value as the log writes it: one decimal, or "unread". */ - static String format(Float raw) { - return raw == null || !Float.isFinite(raw) ? "unread" : String.format(Locale.US, "%.1f", raw); + /** The values that write a line when they change: everything but the followed object. */ + static String steady(List readings) { + StringBuilder sb = new StringBuilder(); + for (String[] r : readings) { + if (!MOVING.contains(r[0])) sb.append(r[0]).append(' ').append(r[1]).append(" "); + } + return sb.toString().trim(); } - /** Called with every reading; records a line when the value differs from the last one. */ - static void note(Context context, Float raw, Float speedKmh, Integer gear) { - String value = format(raw); + /** All readings as the log writes them. */ + static String values(List readings) { + StringBuilder sb = new StringBuilder(); + for (String[] r : readings) sb.append(r[0]).append(' ').append(r[1]).append(" "); + return sb.toString().trim(); + } + + /** Whether this reading gets a line: a changed mode or target, or time while driving. */ + static boolean due(String steady, String lastSteady, long nowMs, long lastWriteMs, boolean driving) { + if (!steady.equals(lastSteady)) return true; + return driving && nowMs - lastWriteMs >= DRIVING_EVERY_MS; + } + + /** Driving: in D or R, or moving. Unread values are not driving. */ + static boolean driving(Float speedKmh, Integer gear) { + return (gear != null && (gear == 4 || gear == 2)) || (speedKmh != null && speedKmh > 0.5f); + } + + /** Called with each reading, off the main thread. */ + static void note(Context context, List readings, Float speedKmh, Integer gear) { + if (context == null || readings == null || readings.isEmpty()) return; + long now = System.currentTimeMillis(); + String steady = steady(readings); synchronized (CruiseLog.class) { - if (context == null || value.equals(last)) return; - last = value; - String line = new SimpleDateFormat("dd.MM. HH:mm:ss", Locale.US).format(new Date()) - + " target " + value - + " at " + (speedKmh == null ? "—" : String.format(Locale.US, "%.0f km/h", speedKmh)) - + " gear " + (Gear.label(gear).isEmpty() ? "?" : Gear.label(gear)); - SharedPreferences prefs = context.getApplicationContext() - .getSharedPreferences(PREFS, Context.MODE_PRIVATE); - List lines = UpdateLog.append(split(prefs.getString(KEY, null)), line, MAX_LINES); - prefs.edit().putString(KEY, String.join("\n", lines)).apply(); + current = values(readings); + if (!due(steady, lastSteady, now, lastWriteMs, driving(speedKmh, gear))) return; + lastSteady = steady; + lastWriteMs = now; + String label = Gear.label(gear); + String line = new SimpleDateFormat("dd.MM. HH:mm:ss", Locale.US).format(new Date(now)) + + " " + (speedKmh == null ? "—" : String.format(Locale.US, "%.0f km/h", speedKmh)) + + " " + (label.isEmpty() ? "?" : label) + + " " + current; + load(context); + lines.addLast(line); + File file = file(context); + if (lines.size() > MAX_LINES + 100) { + // Trim now and then: the file is otherwise only appended to. + while (lines.size() > MAX_LINES) lines.removeFirst(); + write(file, lines, false); + } else { + List one = new ArrayList<>(); + one.add(line); + write(file, one, true); + } } } - /** The value now as text, "—" before the first reading. */ + /** All readings now, "—" before the first. */ static synchronized String current() { - return last == null ? "—" : last; + return current; } - /** Newest first. */ - static List lines(Context context) { - SharedPreferences prefs = context.getApplicationContext() - .getSharedPreferences(PREFS, Context.MODE_PRIVATE); - return split(prefs.getString(KEY, null)); + /** The newest {@link #SHOWN_LINES}, newest first. */ + static synchronized List lines(Context context) { + load(context); + List newest = new ArrayList<>(); + Iterator it = lines.descendingIterator(); + while (it.hasNext() && newest.size() < SHOWN_LINES) newest.add(it.next()); + return newest; } - private static List split(String stored) { - if (stored == null || stored.isEmpty()) return new ArrayList<>(); - return new ArrayList<>(Arrays.asList(stored.split("\n"))); + private static File file(Context context) { + return new File(context.getApplicationContext().getFilesDir(), FILE); + } + + /** Reads the file once per process; drops the change-only log of 2.27.3. */ + private static void load(Context context) { + if (lines != null) return; + lines = new ArrayDeque<>(); + context.getApplicationContext().deleteSharedPreferences("cruise_log"); + File file = file(context); + if (!file.exists()) return; + try (BufferedReader in = new BufferedReader(new InputStreamReader( + new FileInputStream(file), StandardCharsets.UTF_8))) { + String line; + while ((line = in.readLine()) != null) { + if (line.isEmpty()) continue; + lines.addLast(line); + if (lines.size() > MAX_LINES) lines.removeFirst(); + } + } catch (Exception ignored) { + // An unreadable log starts again empty. + } + } + + private static void write(File file, Iterable text, boolean append) { + try (Writer out = new OutputStreamWriter(new FileOutputStream(file, append), StandardCharsets.UTF_8)) { + for (String line : text) out.write(line + "\n"); + } catch (Exception ignored) { + // A debug log that cannot be written is not worth disturbing the screen for. + } } } diff --git a/app/src/main/java/com/evsuite/abrp/MainActivity.java b/app/src/main/java/com/evsuite/abrp/MainActivity.java index 1d29879..893ef65 100644 --- a/app/src/main/java/com/evsuite/abrp/MainActivity.java +++ b/app/src/main/java/com/evsuite/abrp/MainActivity.java @@ -1503,6 +1503,9 @@ public class MainActivity extends AppCompatActivity { * About twenty calls into the car's services: on a background thread, so the screen never * waits for them; the values are shown on the main thread once they are in. */ + /** Counts readings on the reader thread, for the once-a-second cruise control log. */ + private int accDebugTick; + private void readVehicle() { if (!reading.compareAndSet(false, true)) return; try { @@ -1514,6 +1517,14 @@ public class MainActivity extends AppCompatActivity { // The timestamp is passed explicitly: its Kotlin default is not visible from Java. EnergySnapshot s = vehicleReader.read(System.currentTimeMillis()); latestSnapshot = s; + // The cruise control debug log, once a second: about eight more calls. + if (accDebugTick++ % 5 == 0) { + java.util.List acc = new java.util.ArrayList<>(); + for (kotlin.Pair r : com.evsuite.hardware.EVHardware.INSTANCE.readAccDebug()) { + acc.add(new String[] { r.getFirst(), r.getSecond() }); + } + CruiseLog.note(getApplicationContext(), acc, s.getSpeedKmh(), s.getGearRaw()); + } runOnUiThread(() -> { if (!isDestroyed()) showVehicle(s); }); @@ -1797,7 +1808,6 @@ public class MainActivity extends AppCompatActivity { vehTireFr.setText(unit.format(s.getTirePressures().getFrontRightKpa())); vehTireRl.setText(unit.format(s.getTirePressures().getRearLeftKpa())); vehTireRr.setText(unit.format(s.getTirePressures().getRearRightKpa())); - CruiseLog.note(this, s.getCruiseTargetSpeedRaw(), s.getSpeedKmh(), s.getGearRaw()); showGauges(s); showCockpit(s); showDrawnStyles(s); diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9fcfabe..92aea8e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -304,7 +304,7 @@ Recent uploads No upload attempted yet. Updates, newest first: - Cruise control target speed, newest first (now: %1$s; raw, unit not known yet): + Adaptive cruise control as the car reports it, raw, newest first: a line when a mode or the target changes, and every 2 s while driving with this app open. cc target speed, ica assist mode, tja traffic jam assist, sas/sam speed assist, obj/dist/rel the car ahead (presence, distance, relative speed).\nNow: %1$s Upload now Starting… Running — waiting for first upload diff --git a/app/src/test/java/com/evsuite/abrp/CruiseLogTest.java b/app/src/test/java/com/evsuite/abrp/CruiseLogTest.java index 9bf32fe..3e0f098 100644 --- a/app/src/test/java/com/evsuite/abrp/CruiseLogTest.java +++ b/app/src/test/java/com/evsuite/abrp/CruiseLogTest.java @@ -1,18 +1,54 @@ package com.evsuite.abrp; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.util.Arrays; +import java.util.List; import org.junit.Test; -/** The cruise control log's value format — pure JVM. */ +/** The cruise control debug log's lines and when it writes one — pure JVM. */ public class CruiseLogTest { + private static List readings(String cc, String ica, String dist) { + return Arrays.asList(new String[] { "cc", cc }, new String[] { "ica", ica }, + new String[] { "dist", dist }); + } + @Test - public void oneDecimalOrUnread() { - assertEquals("120.0", CruiseLog.format(120f)); - assertEquals("33.3", CruiseLog.format(33.33f)); - assertEquals("0.0", CruiseLog.format(0f)); - assertEquals("unread", CruiseLog.format(null)); - assertEquals("unread", CruiseLog.format(Float.NaN)); + public void lineHasEveryValue() { + assertEquals("cc 120.00 ica 1 dist 35.20", CruiseLog.values(readings("120.00", "1", "35.20"))); + assertEquals("cc unread (not available) ica 0 dist unread", + CruiseLog.values(readings("unread (not available)", "0", "unread"))); + } + + @Test + public void followedObjectAloneIsNoChange() { + assertEquals(CruiseLog.steady(readings("120.00", "1", "35.20")), + CruiseLog.steady(readings("120.00", "1", "12.00"))); + assertFalse(CruiseLog.steady(readings("120.00", "1", "1")).contains("dist")); + } + + @Test + public void writesOnChangeAndEveryTwoSecondsWhileDriving() { + String a = CruiseLog.steady(readings("120.00", "1", "35")); + String b = CruiseLog.steady(readings("110.00", "1", "35")); + assertTrue(CruiseLog.due(a, null, 0, 0, false)); + assertTrue(CruiseLog.due(b, a, 1000, 500, false)); + assertFalse(CruiseLog.due(a, a, 1000, 500, true)); + assertTrue(CruiseLog.due(a, a, 2500, 500, true)); + // Parked: nothing new, nothing written. + assertFalse(CruiseLog.due(a, a, 60_000, 500, false)); + } + + @Test + public void drivingIsDOrROrMoving() { + assertTrue(CruiseLog.driving(null, 4)); + assertTrue(CruiseLog.driving(0f, 2)); + assertTrue(CruiseLog.driving(30f, 3)); + assertFalse(CruiseLog.driving(0f, 1)); + assertFalse(CruiseLog.driving(null, null)); } }