App 2.32.0: an ACC recorder that records every value the head unit offers

The 2.31.1 log on the car showed the named assist properties constant
while ACC was engaged behind another car: they are not fed. The recorder
reads every property and vendor getter (read only, at most half the time
busy) for 3 minutes, plus steering-wheel key broadcasts, and lists what
changed, the rarest first. The recording is saved in the app and on a
USB stick if one is plugged in.

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-25 19:27:43 +01:00
co-authored by Claude Opus 5.5
parent 36cc417638
commit 2e6bb9cb83
9 changed files with 406 additions and 3 deletions
+1 -1
View File
@@ -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 | 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 |
| Logs | ACC recorder (`AccRecorder`/`AccRecording`: 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), 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
+16
View File
@@ -131,6 +131,22 @@ 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.32.0 (branch `styles-retro-future`): the **ACC recorder** on the Logs tab.
The 2.31.1 log on the car (a drive with ACC engaged behind another car) showed every assist
property constant: `cc 0.00`, `ica 0`, `tja 1`, `sas 0`, `sam 0`, `obj 0.00`, `dist 0.00`,
`rel -128.00` (the field's minimum, i.e. no data). The `AAD_*` properties named in the head
unit's manager are not fed on this car. The recorder looks everywhere instead: for 3 minutes it
reads every value the head unit offers (`VendorSurfaceProbe.capture`: every CarPropertyManager
property and every value getter of the vendor objects, read only), waiting at least 2 s and at
least as long as the last capture took (at most half the time busy), and every
`com.saic.keyevent.hardkey.report` broadcast. `AccRecording` (pure, unit-tested) keeps the
first capture whole, then what changed and when, and per value how often and through which
values; the Logs tab shows the keys, then the changed values, the rarest first (the leads).
At the end the whole recording goes to `files/acc_recording.txt` and to the app's folder on
every removable volume (`Android/data/<package>/files/acc-recording-<time>.txt`). The ACC
buttons do nothing on the head unit's screen (owner). Emulator: the recorder starts, stops,
records a sent key broadcast and saves (its CarPropertyManager is not connected there, so no
values); the USB stick copy was not tried. Not tried on the car.
- 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
+2 -2
View File
@@ -34,8 +34,8 @@ android {
applicationId "com.stinnesbeck.nilsmg4"
minSdk 27
targetSdk 36
versionCode 129
versionName "2.31.1"
versionCode 130
versionName "2.32.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.
@@ -0,0 +1,182 @@
package com.evsuite.abrp;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.os.Bundle;
import android.os.Environment;
import com.evsuite.hardware.probe.VendorSurfaceProbe;
import java.io.File;
import java.io.FileOutputStream;
import java.io.OutputStreamWriter;
import java.io.Writer;
import java.nio.charset.StandardCharsets;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import java.util.Map;
/**
* The ACC change recorder on the Logs tab: for {@link #DURATION_MS} it reads every value the
* head unit offers (`VendorSurfaceProbe.capture`: every CarPropertyManager property and every
* value getter of the vendor objects, read only) again and again, and every steering-wheel key
* the head unit reports, into an {@link AccRecording}. The driver notes the times they switch
* the cruise control on, change the set speed and the distance, and switch it off; the values
* that change then are the leads.
*
* A full capture is far heavier than the Overview's reading, so the next one waits at least
* {@link #MIN_INTERVAL_MS} and at least as long as the last one took: the recorder uses at most
* half the time. It runs on its own thread, on after the app leaves the screen (the service
* keeps the process), and stops by itself. At the end the recording goes to the app's storage
* and to the app's folder on every USB stick plugged in.
*/
final class AccRecorder {
static final long DURATION_MS = 3 * 60_000L;
static final long MIN_INTERVAL_MS = 2_000L;
static final String FILE = "acc_recording.txt";
private static AccRecording recording;
private static volatile boolean running;
private static long startedMs;
private static String result;
private static Thread thread;
private AccRecorder() {}
static synchronized boolean running() {
return running;
}
static synchronized void start(Context context) {
if (running) return;
Context app = context.getApplicationContext();
recording = new AccRecording();
result = null;
startedMs = System.currentTimeMillis();
running = true;
thread = new Thread(() -> record(app), "acc-recorder");
thread.start();
}
static synchronized void stop() {
running = false;
if (thread != null) thread.interrupt();
}
private static void record(Context app) {
AccRecording rec = recording;
BroadcastReceiver keys = new BroadcastReceiver() {
@Override public void onReceive(Context c, Intent intent) {
rec.note(System.currentTimeMillis(), "steering-wheel key: " + extras(intent.getExtras()));
}
};
boolean keysRegistered = false;
try {
androidx.core.content.ContextCompat.registerReceiver(app, keys,
new IntentFilter(SteeringKeyListener.ACTION), androidx.core.content.ContextCompat.RECEIVER_EXPORTED);
keysRegistered = true;
} catch (RuntimeException ignored) {
// No key reports: the properties are recorded all the same.
}
try {
long end = startedMs + DURATION_MS;
while (running && System.currentTimeMillis() < end) {
long t0 = System.currentTimeMillis();
Map<String, String> capture = VendorSurfaceProbe.INSTANCE.capture(Collections.emptyList());
rec.add(t0, capture);
long took = System.currentTimeMillis() - t0;
Thread.sleep(Math.max(MIN_INTERVAL_MS - took, took));
}
} catch (InterruptedException ignored) {
// Stopped by the driver.
} catch (Throwable t) {
rec.note(System.currentTimeMillis(), "recorder stopped: " + t.getClass().getSimpleName());
} finally {
if (keysRegistered) {
try {
app.unregisterReceiver(keys);
} catch (RuntimeException ignored) {
// already gone
}
}
String saved = save(app, rec);
synchronized (AccRecorder.class) {
running = false;
result = saved;
}
}
}
private static String extras(Bundle extras) {
if (extras == null) return "(no extras)";
List<String> parts = new ArrayList<>();
for (String k : new java.util.TreeSet<>(extras.keySet())) parts.add(k + "=" + extras.get(k));
return String.join(", ", parts);
}
/** Writes the recording; says where it went. */
private static String save(Context app, AccRecording rec) {
List<String> text = new ArrayList<>();
text.add("ACC recording, " + rec.captures() + " captures from "
+ AccRecording.time(startedMs) + ", app " + BuildConfig.VERSION_NAME);
text.add("");
text.add("Steering-wheel keys and notes:");
text.addAll(rec.notes());
text.add("");
text.add("Changed values, the rarest first:");
text.addAll(rec.summary());
text.add("");
text.add("Everything, in order:");
text.addAll(rec.lines());
List<String> places = new ArrayList<>();
if (write(new File(app.getFilesDir(), FILE), text)) places.add("app");
String name = "acc-recording-" + new SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US)
.format(new Date(startedMs)) + ".txt";
File[] dirs = app.getExternalFilesDirs(null);
for (File dir : dirs == null ? new File[0] : dirs) {
try {
if (dir == null || !Environment.isExternalStorageRemovable(dir)) continue;
File file = new File(dir, name);
if (write(file, text)) places.add(file.getPath());
} catch (RuntimeException ignored) {
// A volume that cannot say what it is gets nothing.
}
}
return places.isEmpty() ? "not saved" : "saved: " + String.join(", ", places);
}
private static boolean write(File file, List<String> text) {
try (Writer out = new OutputStreamWriter(new FileOutputStream(file), StandardCharsets.UTF_8)) {
for (String line : text) out.write(line + "\n");
return true;
} catch (Exception e) {
return false;
}
}
/** What the Logs tab shows: the state, then the changed values, the rarest first. */
static synchronized List<String> status(Context context) {
List<String> out = new ArrayList<>();
if (recording == null) return out;
long elapsed = (running ? System.currentTimeMillis() : startedMs + DURATION_MS) - startedMs;
if (running) {
out.add(context.getString(R.string.acc_recorder_running, elapsed / 60_000, (elapsed / 1000) % 60,
recording.captures()));
} else {
out.add(context.getString(R.string.acc_recorder_done, AccRecording.time(startedMs),
recording.captures(), result == null ? "…" : result));
}
// Steering-wheel keys and the like first: few, and each one a lead.
List<String> notes = recording.notes();
out.addAll(notes.subList(Math.max(0, notes.size() - 10), notes.size()));
out.addAll(recording.summary());
return out;
}
}
@@ -0,0 +1,108 @@
package com.evsuite.abrp;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.TreeSet;
/**
* One recording of the ACC change recorder ({@link AccRecorder}): every value the head unit
* offers, captured again and again while the driver switches the adaptive cruise control on,
* changes the set speed and the distance, and switches it off. It keeps what changed, when,
* and per value how often and through which values, so the value that follows the cruise
* control stands out: it changes a few times, at the moments the driver noted.
* Pure Java, unit-tested.
*/
final class AccRecording {
/** Distinct values kept per key for the summary. */
static final int VALUES_KEPT = 6;
/** Longest value text in the summary. */
static final int VALUE_CHARS = 40;
private Map<String, String> last;
private int captures;
private final List<String> lines = new ArrayList<>();
private final List<String> notes = new ArrayList<>();
private final Map<String, Integer> changes = new HashMap<>();
private final Map<String, LinkedHashSet<String>> seen = new HashMap<>();
static String time(long ms) {
return new SimpleDateFormat("HH:mm:ss", Locale.US).format(new Date(ms));
}
/** A capture: the first is kept whole, each later one as what changed against the one before. */
synchronized void add(long ms, Map<String, String> capture) {
captures++;
if (last == null) {
lines.add(time(ms) + " first capture, " + capture.size() + " values:");
for (Map.Entry<String, String> e : capture.entrySet()) {
lines.add(" " + e.getKey() + " = " + e.getValue());
remember(e.getKey(), e.getValue());
}
} else {
TreeSet<String> keys = new TreeSet<>(last.keySet());
keys.addAll(capture.keySet());
for (String key : keys) {
String before = last.get(key), after = capture.get(key);
if (before == null ? after == null : before.equals(after)) continue;
lines.add(time(ms) + " " + key + ": " + before + " → " + after);
changes.merge(key, 1, Integer::sum);
remember(key, after);
}
}
last = capture;
}
/** Something else that happened, such as a steering-wheel key the head unit reported. */
synchronized void note(long ms, String what) {
lines.add(time(ms) + " " + what);
notes.add(time(ms) + " " + what);
}
/** The notes alone, in order. */
synchronized List<String> notes() {
return new ArrayList<>(notes);
}
private void remember(String key, String value) {
LinkedHashSet<String> values = seen.computeIfAbsent(key, k -> new LinkedHashSet<>());
if (values.size() < VALUES_KEPT) values.add(shorten(String.valueOf(value)));
}
static String shorten(String value) {
return value.length() <= VALUE_CHARS ? value : value.substring(0, VALUE_CHARS - 1) + "…";
}
synchronized int captures() {
return captures;
}
/** Everything, in order: the first capture, then each change and note. */
synchronized List<String> lines() {
return new ArrayList<>(lines);
}
/**
* Every value that changed, the rarest first (the cruise control's switch and set speed
* change a handful of times; speed and power all the time): key, how often, the values.
*/
synchronized List<String> summary() {
List<String> keys = new ArrayList<>(changes.keySet());
keys.sort((a, b) -> {
int c = Integer.compare(changes.get(a), changes.get(b));
return c != 0 ? c : a.compareTo(b);
});
List<String> out = new ArrayList<>();
for (String key : keys) {
out.add(key + " " + changes.get(key) + "× " + String.join(" → ", seen.get(key))
+ (seen.get(key).size() >= VALUES_KEPT ? " …" : ""));
}
return out;
}
}
@@ -375,6 +375,11 @@ public class MainActivity extends AppCompatActivity {
statusText = servicePane.findViewById(R.id.status_text);
callLogText = logPane.findViewById(R.id.call_log_text);
logPane.findViewById(R.id.flush_now_button).setOnClickListener(v -> manualFlush());
logPane.findViewById(R.id.acc_record_button).setOnClickListener(v -> {
if (AccRecorder.running()) AccRecorder.stop();
else AccRecorder.start(this);
refreshAccRecorder();
});
setUpPager();
// Back on the page that was open when the activity was recreated (accent colour change).
@@ -2128,7 +2133,20 @@ public class MainActivity extends AppCompatActivity {
}, "cloud-refresh").start();
}
/** The ACC recorder's button and what it has found so far. */
private void refreshAccRecorder() {
MaterialButton button = logPane.findViewById(R.id.acc_record_button);
button.setText(AccRecorder.running() ? R.string.btn_acc_stop : R.string.btn_acc_record);
TextView text = logPane.findViewById(R.id.acc_record_text);
java.util.List<String> status = AccRecorder.status(this);
text.setVisibility(status.isEmpty() ? View.GONE : View.VISIBLE);
// The rarest changes are the leads; the rest is in the saved recording.
if (status.size() > 81) status = status.subList(0, 81);
if (!status.isEmpty()) text.setText(String.join("\n", status));
}
private void refreshCallLog() {
refreshAccRecorder();
java.util.List<UploadLog.Entry> entries = AbrpUploadService.log().recent();
// Update steps first: after a failed install, they are what the driver came here for.
java.util.List<String> updates = UpdateLog.lines(this);
+7
View File
@@ -1,6 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<com.evsuite.abrp.StableScrollView xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" android:fillViewport="true" android:clipToPadding="false">
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:paddingStart="24dp" android:paddingEnd="24dp" android:paddingTop="12dp" android:paddingBottom="12dp">
<!-- The ACC change recorder (AccRecorder): every value the head unit offers, recorded while driving. -->
<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:layout_marginBottom="16dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/label_acc_recorder" android:textAppearance="@style/Text.EV.Eyebrow" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:text="@string/acc_recorder_hint" android:textAppearance="@style/Text.EV.Secondary" />
<com.google.android.material.button.MaterialButton android:id="@+id/acc_record_button" android:layout_width="match_parent" android:layout_height="72dp" android:layout_marginTop="16dp" android:text="@string/btn_acc_record" style="@style/Widget.EV.Button.Outlined" />
<TextView android:id="@+id/acc_record_text" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:textAppearance="@style/Text.EV.Body" android:fontFamily="monospace" android:textSize="17sp" android:lineSpacingExtra="8dp" android:textIsSelectable="true" android:visibility="gone" />
</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/label_call_log" android:textAppearance="@style/Text.EV.Eyebrow" />
<com.google.android.material.button.MaterialButton android:id="@+id/flush_now_button" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:text="@string/btn_flush_now" app:icon="@drawable/ic_refresh" app:iconGravity="textStart" style="@style/Widget.EV.Button.Outlined" />
+6
View File
@@ -302,6 +302,12 @@
<!-- Call log -->
<string name="label_call_log">Recent uploads</string>
<string name="label_acc_recorder">ACC recorder</string>
<string name="acc_recorder_hint">Records every value the head unit offers for 3 minutes, plus the steering-wheel keys it reports, and lists what changed. Start it while driving, note the times you switch the cruise control on, change the set speed and the distance, and switch it off. With a USB stick plugged in, the whole recording is saved on it too (Android/data/…/files).</string>
<string name="btn_acc_record">Record 3 minutes</string>
<string name="btn_acc_stop">Stop recording</string>
<string name="acc_recorder_running">Recording: %1$d:%2$02d of 3:00, %3$d captures. Keys reported, then values changed so far, the rarest first:</string>
<string name="acc_recorder_done">Recording from %1$s: %2$d captures, %3$s. Keys reported, then values changed, the rarest first:</string>
<string name="call_log_empty">No upload attempted yet.</string>
<string name="update_log_title">Updates, newest first:</string>
<string name="cruise_log_title">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</string>
@@ -0,0 +1,66 @@
package com.evsuite.abrp;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import org.junit.Test;
/** The ACC change recorder's bookkeeping — pure JVM. */
public class AccRecordingTest {
private static Map<String, String> capture(String speed, String acc) {
Map<String, String> m = new TreeMap<>();
m.put("speed", speed);
m.put("acc", acc);
m.put("steady", "1");
return m;
}
@Test
public void rarestChangeFirst() {
AccRecording r = new AccRecording();
r.add(0, capture("10", "0"));
r.add(2000, capture("20", "0"));
r.add(4000, capture("30", "120"));
r.add(6000, capture("40", "120"));
r.add(8000, capture("50", "0"));
List<String> summary = r.summary();
assertEquals(2, summary.size());
assertEquals("acc 2× 0 → 120", summary.get(0));
assertTrue(summary.get(1).startsWith("speed 4× 10 → 20 → 30 → 40 → 50"));
assertEquals(5, r.captures());
}
@Test
public void appearingAndVanishingKeysAreChanges() {
AccRecording r = new AccRecording();
Map<String, String> a = capture("1", "0");
Map<String, String> b = capture("1", "0");
b.put("new", "5");
r.add(0, a);
r.add(1000, b);
r.add(2000, a);
assertEquals("new 2× 5 → null", r.summary().get(0));
}
@Test
public void longValuesAreShortened() {
String v = "[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]";
assertEquals(AccRecording.VALUE_CHARS, AccRecording.shorten(v).length());
assertEquals("short", AccRecording.shorten("short"));
}
@Test
public void firstCaptureIsKeptWhole() {
AccRecording r = new AccRecording();
r.add(0, capture("1", "0"));
r.note(500, "key 287 down");
assertEquals(5, r.lines().size());
assertTrue(r.lines().get(4).endsWith("key 287 down"));
assertEquals(1, r.notes().size());
}
}