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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user