App 2.40.3: the signal recorder listens to the assistance properties

The ACC recording of 2.32.0 only polled, and the AAD_* properties read
their defaults. The head unit's own assistance manager never polls them:
it subscribes to each for change events, and a vendor HAL may fill a
value only while someone listens. EVHardware gains a read-only
listenToProperties and AssistBlock (global vendor properties 0x21x0A1xx,
all 108 AAD_* ids); the recorder subscribes to that block for its three
minutes and lists the properties heard, those with changing values first.

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-27 00:55:26 +01:00
co-authored by Claude Opus 5.5
parent f7c489858e
commit e90031d6b0
12 changed files with 282 additions and 14 deletions
+1 -1
View File
@@ -81,7 +81,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 | | 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 | | 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`; "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 (offered only for styles drawn in it, `OverviewStyle.usesAccent`; Retro, Terminal, Grid (and Grid with map, sharing Grid's choice) and Visor 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 (offered only for styles drawn in it, `OverviewStyle.usesAccent`; Retro, Terminal, Grid (and Grid with map, sharing Grid's choice) and Visor 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 | | Logs | signal recorder (`SignalRecorder`/`SignalRecording`: every property and vendor getter via `VendorSurfaceProbe.capture`, again and again for 3 minutes, plus change events of the driver-assistance block it subscribes to, `EVHardware.listenToProperties` with `AssistBlock`, and 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, Key classes (`app/src/main/java/com/evsuite/abrp/`): `AbrpUploadService` (foreground service,
15 s tick), `TelemetryUploader` (+ `TelemetryQueue`, persisted), `TelemetryPayload` (server 15 s tick), `TelemetryUploader` (+ `TelemetryQueue`, persisted), `TelemetryPayload` (server
@@ -0,0 +1,24 @@
package com.evsuite.hardware
/**
* The block of vendor properties the head unit's driver-assistance manager
* (`CarAdvancedAssistedDrivingManager`, its `AAD_*` ids) lives in: global vendor properties
* `0x21x0A1xx`, of any value type — `0x2140A101` (int) to `0x2160A15B` (float, `AAD_EPTCCTRGTSPD`,
* by its name the cruise control's target speed) and the rest of the same block. Used to choose
* what the signal recorder listens to; nothing here is ever written. Pure, unit-tested.
*/
object AssistBlock {
private const val GROUP_MASK = 0xF0000000.toInt()
private const val GROUP_VENDOR = 0x20000000
private const val AREA_MASK = 0x0F000000
private const val AREA_GLOBAL = 0x01000000
private const val BLOCK_MASK = 0x0000FF00
private const val BLOCK = 0x0000A100
/** Whether [propertyId] is a global vendor property in the assistance block. */
fun contains(propertyId: Int): Boolean =
propertyId and GROUP_MASK == GROUP_VENDOR &&
propertyId and AREA_MASK == AREA_GLOBAL &&
propertyId and BLOCK_MASK == BLOCK
}
@@ -320,6 +320,105 @@ object EVHardware {
ignitionCallbacks.remove(callback) ignitionCallbacks.remove(callback)
} }
// -------------------------------------------------------------------------
// Listening to properties (the app's signal recorder) — read only
// -------------------------------------------------------------------------
/** A subscription made by [listenToProperties]; [close] ends it. */
class PropertyListening internal constructor(
/** Properties subscribed to, and those the car service refused. */
val subscribed: Int,
val refused: Int,
private val stop: () -> Unit,
) : AutoCloseable {
override fun close() = stop()
}
/**
* Subscribes to every CarPropertyManager property whose id [admit] accepts, the way the
* head unit's own managers do (`CarAdvancedAssistedDrivingManager` registers each of its
* `AAD_*` ids for change events and never polls them), and hands each change event to
* [onEvent] as (property id, area, value as text) on the car service's thread. A vendor HAL
* may fill a property from the car network only while someone listens, so a poll alone can
* read its default. ON_CHANGE properties are asked for every change, CONTINUOUS ones for
* 1 Hz within their limits. Read only: nothing is set. Null when there is no
* CarPropertyManager or no way to subscribe; [PropertyListening.close] unsubscribes.
*/
fun listenToProperties(admit: (Int) -> Boolean, onEvent: (Int, Int, String) -> Unit): PropertyListening? {
val cpm = sCarPropertyManager ?: return null
return try {
val register = cpm.javaClass.methods.firstOrNull {
it.name == "registerCallback" && it.parameterCount == 3
} ?: return null
val unregister = cpm.javaClass.methods.firstOrNull {
it.name == "unregisterCallback" && it.parameterCount == 1
}
val callbackType = register.parameterTypes[0]
if (!callbackType.isInterface) return null
val proxy = Proxy.newProxyInstance(callbackType.classLoader, arrayOf(callbackType)) { self, method, args ->
when (method.name) {
"onChangeEvent" -> {
val cpv = args?.getOrNull(0)
if (cpv != null) {
try {
val type = cpv.javaClass
val id = type.getMethod("getPropertyId").invoke(cpv) as Int
val area = type.getMethod("getAreaId").invoke(cpv) as Int
onEvent(id, area, com.evsuite.hardware.probe.VendorSurfaceRules.render(
type.getMethod("getValue").invoke(cpv)))
} catch (e: Exception) {
AppLogger.d(TAG, "listen: event unreadable: ${e.readFailureReason()}")
}
}
null
}
"onErrorEvent" -> {
val id = args?.getOrNull(0) as? Int
val area = args?.getOrNull(1) as? Int
if (id != null) onEvent(id, area ?: AREA_GLOBAL, "error event")
null
}
"equals" -> self === args?.getOrNull(0)
"hashCode" -> System.identityHashCode(self)
"toString" -> "PropertyListening"
else -> null
}
}
val configs = cpm.javaClass.getMethod("getPropertyList").invoke(cpm) as? List<*> ?: return null
var subscribed = 0
var refused = 0
for (config in configs) {
if (config == null) continue
val type = config.javaClass
val id = runCatching { type.getMethod("getPropertyId").invoke(config) as Int }.getOrNull() ?: continue
if (!admit(id)) continue
val continuous = runCatching { type.getMethod("getChangeMode").invoke(config) as Int }.getOrNull() == 2
val rate = if (!continuous) 0f else {
val min = runCatching { type.getMethod("getMinSampleRate").invoke(config) as Float }.getOrNull() ?: 0f
val max = runCatching { type.getMethod("getMaxSampleRate").invoke(config) as Float }.getOrNull() ?: 1f
1f.coerceIn(min, maxOf(min, max))
}
try {
if (register.invoke(cpm, proxy, id, rate) == false) refused++ else subscribed++
} catch (e: Exception) {
refused++
AppLogger.d(TAG, "listen: 0x%08X refused: %s".format(id, e.readFailureReason()))
}
}
AppLogger.i(TAG, "listening to $subscribed properties ($refused refused)")
PropertyListening(subscribed, refused) {
try {
unregister?.invoke(cpm, proxy)
} catch (e: Exception) {
AppLogger.d(TAG, "listen: unsubscribe failed: ${e.readFailureReason()}")
}
}
} catch (e: Exception) {
AppLogger.w(TAG, "listenToProperties: ${e.readFailureReason()}")
null
}
}
/** Unregisters the CarPropertyManager proxy (call from Service.onDestroy). */ /** Unregisters the CarPropertyManager proxy (call from Service.onDestroy). */
fun unregisterIgnitionPropertyCallback() { fun unregisterIgnitionPropertyCallback() {
val cpm = sCarPropertyManager ?: return val cpm = sCarPropertyManager ?: return
@@ -0,0 +1,29 @@
package com.evsuite.hardware
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class AssistBlockTest {
@Test
fun `the assistance manager's properties are in the block`() {
// First and last int, the target speed and the object distance (floats).
for (id in intArrayOf(0x2140A101, 0x2140A171, 0x2160A15B, 0x2160A12C, 0x2160A114)) {
assertTrue("0x%08X".format(id), AssistBlock.contains(id))
}
}
@Test
fun `other properties are not`() {
for (id in intArrayOf(
0x2140A0FF, // the block before
0x2140A201, // the block after
0x11400400, // a standard (system) property
0x2340A101, // not global
0x0140A101, // no group
)) {
assertFalse("0x%08X".format(id), AssistBlock.contains(id))
}
}
}
+3 -2
View File
@@ -39,8 +39,9 @@ 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 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 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 speed already in km/h — plus translated comments and, from this fork, the gear's raw code
(`EVHardware.getGearRaw`, `EnergySnapshot.gearRaw`) and a drive mode write without the binder (`EVHardware.getGearRaw`, `EnergySnapshot.gearRaw`), a drive mode write without the binder
fallback (`writeDriveModeProperty`). To take upstream again, merge three ways with fallback (`writeDriveModeProperty`), and read-only listening to properties for the signal
recorder (`listenToProperties`, `AssistBlock`). To take upstream again, merge three ways with
`e4e1158` as the base. `e4e1158` as the base.
The car app links EVHardware into its APK, so the **built app** may only be used and passed on The car app links EVHardware into its APK, so the **built app** may only be used and passed on
+1 -1
View File
@@ -95,7 +95,7 @@ with the server using a token of its own, obtained by pairing with a phone (belo
| **Live data** | Live data on/off, ABRP credentials for sending directly from the car, upload frequency | | **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 | | **Server** | Server URL, pairing by QR code, ingest token, installation ID, config import |
| **Settings** | Full screen, voice key, Overview style and accent colour, tyre pressure unit; 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); battery (manual SoH, usable capacity) and charging price; entry on the phone; version, update check, install from USB stick, About | | **Settings** | Full screen, voice key, Overview style and accent colour, tyre pressure unit; 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); battery (manual SoH, usable capacity) and charging price; entry on the phone; version, update check, install from USB stick, About |
| **Logs** | Signal recorder (records every value the head unit offers for 3 minutes and lists what changed, to find where the car publishes something; saved to a USB stick too), update log (every install step), upload log | | **Logs** | Signal recorder (records every value the head unit offers for 3 minutes, listens to the driver-assistance values for every change they report, and lists what changed, to find where the car publishes something; saved to a USB stick too), update log (every install step), upload log |
- **Opening the app:** the MG4 launcher does not list third-party apps. **Double-press the - **Opening the app:** the MG4 launcher does not list third-party apps. **Double-press the
voice key** on the steering wheel and the app comes to the front (switch on the Settings voice key** on the steering wheel and the app comes to the front (switch on the Settings
+17 -1
View File
@@ -78,7 +78,9 @@ tyre pressures, GPS (lat, lon, elevation, heading) and the is_* flags. It does *
(`AAD_EPTCCTRGTSPD` `0x2160A15B` 0.0, `AAD_ACC_OBJ_*` 0.0 / -128.0, modes 0). A signal recording (`AAD_EPTCCTRGTSPD` `0x2160A15B` 0.0, `AAD_ACC_OBJ_*` 0.0 / -128.0, modes 0). A signal recording
of all 1,194 values while the set speed was 101, 90 and 80 km/h shows none of those numbers of all 1,194 values while the set speed was 101, 90 and 80 km/h shows none of those numbers
(also tried as m/s, mph and ×2/×10/×16/×64) outside the actual speed, and the ACC buttons send (also tried as m/s, mph and ×2/×10/×16/×64) outside the actual speed, and the ACC buttons send
no key broadcast. Most likely only the instrument cluster has it. no key broadcast. Most likely only the instrument cluster has it. Not yet tried: listening
(subscribing) instead of polling, as the head unit's own manager does — the recorder does it
since 2.40.3; a drive with ACC will tell.
## To do ## To do
@@ -138,6 +140,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 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` 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). (Android 9 AOSP at 1920×720, 160 dpi; the car build installs as the system user — checked).
- 2026-10-27 — App 2.40.3: **the signal recorder listens** (owner: "is there really nothing we
can do about the readout of the cruise control set point"). The 2.32.0 recording only polled.
The head unit's `CarAdvancedAssistedDrivingManager` never polls its 108 `AAD_*` properties: it
registers a listener for each (`registerListener(…, 0.0f)`, on change), and a vendor HAL may
fill a value from the car network only while someone listens — which would read as the
defaults we saw. EVHardware gains `listenToProperties` (read only: the CarPropertyManager's
`registerCallback` per admitted property, rate 0 for ON_CHANGE, 1 Hz within limits for
CONTINUOUS, one proxy, `unregisterCallback` on close) and `AssistBlock` (global vendor
properties `0x21x0A1xx`, which holds all 108 `AAD_*` ids; tested). The recorder subscribes to
that block for its 3 minutes; `SignalRecording.event` counts every event and puts value changes
on the timeline; the file and the Logs tab list the properties heard, those with changing
values first ("heard …"). Emulator (no car service): the recorder notes that listening is not
possible, records, saves the new section; no crash. The subscription itself is the same
reflection as the ignition listener that works on the car, but untried on the car.
- 2026-10-27 — App 2.40.2: **the map moves on the GPU** (owner: "is the map screen gpu - 2026-10-27 — App 2.40.2: **the map moves on the GPU** (owner: "is the map screen gpu
accelerated? it looks a bit slow on update"). It was hardware-rendered, but every frame (20 a accelerated? it looks a bit slow on update"). It was hardware-rendered, but every frame (20 a
second) recomputed every road point on the main thread, rebuilt all paths, and drew the big second) recomputed every road point on the main thread, rebuilt all paths, and drew the big
+2 -2
View File
@@ -34,8 +34,8 @@ android {
applicationId "com.stinnesbeck.nilsmg4" applicationId "com.stinnesbeck.nilsmg4"
minSdk 27 minSdk 27
targetSdk 36 targetSdk 36
versionCode 147 versionCode 148
versionName "2.40.2" versionName "2.40.3"
// Nothing about the server ships in the APK: its URL and ingest token are entered in // 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. // the app's Server tab (ServerConfig), and the database is only reachable by the server.
@@ -7,6 +7,8 @@ import android.content.IntentFilter;
import android.os.Bundle; import android.os.Bundle;
import android.os.Environment; import android.os.Environment;
import com.evsuite.hardware.AssistBlock;
import com.evsuite.hardware.EVHardware;
import com.evsuite.hardware.probe.VendorSurfaceProbe; import com.evsuite.hardware.probe.VendorSurfaceProbe;
import java.io.File; import java.io.File;
@@ -26,7 +28,11 @@ import java.util.Map;
* The signal recorder on the Logs tab: for {@link #DURATION_MS} it reads every value the * The signal recorder on the Logs tab: for {@link #DURATION_MS} it reads every value the
* head unit offers (`VendorSurfaceProbe.capture`: every CarPropertyManager property and every * 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 * value getter of the vendor objects, read only) again and again, and every steering-wheel key
* the head unit reports, into a {@link SignalRecording}. The driver notes the times they do * the head unit reports, into a {@link SignalRecording}. It also listens, as the head unit's own
* driver-assistance manager does, to the properties of the assistance block
* ({@link AssistBlock}): subscribed for change events the whole time, because a value that is
* only filled while someone listens reads as its default when polled (2.40.3, still the
* cruise control). The driver notes the times they do
* what is to be found; the values that change then are the leads. (Built to find the adaptive * what is to be found; the values that change then are the leads. (Built to find the adaptive
* cruise control, 2.32.0: on SWI68 the head unit gets none of it, see SPECS.md.) * cruise control, 2.32.0: on SWI68 the head unit gets none of it, see SPECS.md.)
* *
@@ -85,6 +91,21 @@ final class SignalRecorder {
} catch (RuntimeException ignored) { } catch (RuntimeException ignored) {
// No key reports: the properties are recorded all the same. // No key reports: the properties are recorded all the same.
} }
EVHardware.PropertyListening listening = null;
try {
listening = EVHardware.INSTANCE.listenToProperties(AssistBlock.INSTANCE::contains,
(id, area, value) -> {
rec.event(System.currentTimeMillis(),
String.format(Locale.US, "CPM 0x%08X/%d", id, area), value);
return kotlin.Unit.INSTANCE;
});
} catch (Throwable t) {
rec.note(System.currentTimeMillis(), "listening failed: " + t.getClass().getSimpleName());
}
rec.note(System.currentTimeMillis(), listening == null
? "listening to the assistance properties: not possible (no car service)"
: "listening to " + listening.getSubscribed() + " assistance properties ("
+ listening.getRefused() + " refused)");
try { try {
long end = startedMs + DURATION_MS; long end = startedMs + DURATION_MS;
while (running && System.currentTimeMillis() < end) { while (running && System.currentTimeMillis() < end) {
@@ -99,6 +120,13 @@ final class SignalRecorder {
} catch (Throwable t) { } catch (Throwable t) {
rec.note(System.currentTimeMillis(), "recorder stopped: " + t.getClass().getSimpleName()); rec.note(System.currentTimeMillis(), "recorder stopped: " + t.getClass().getSimpleName());
} finally { } finally {
if (listening != null) {
try {
listening.close();
} catch (Throwable ignored) {
// the car service is gone
}
}
if (keysRegistered) { if (keysRegistered) {
try { try {
app.unregisterReceiver(keys); app.unregisterReceiver(keys);
@@ -130,7 +158,10 @@ final class SignalRecorder {
text.add("Steering-wheel keys and notes:"); text.add("Steering-wheel keys and notes:");
text.addAll(rec.notes()); text.addAll(rec.notes());
text.add(""); text.add("");
text.add("Changed values, the rarest first:"); text.add("Assistance properties heard (" + rec.events() + " change events), changing ones first:");
text.addAll(rec.heard());
text.add("");
text.add("Changed values (polled), the rarest first:");
text.addAll(rec.summary()); text.addAll(rec.summary());
text.add(""); text.add("");
text.add("Everything, in order:"); text.add("Everything, in order:");
@@ -168,14 +199,17 @@ final class SignalRecorder {
long elapsed = (running ? System.currentTimeMillis() : startedMs + DURATION_MS) - startedMs; long elapsed = (running ? System.currentTimeMillis() : startedMs + DURATION_MS) - startedMs;
if (running) { if (running) {
out.add(context.getString(R.string.signal_recorder_running, elapsed / 60_000, (elapsed / 1000) % 60, out.add(context.getString(R.string.signal_recorder_running, elapsed / 60_000, (elapsed / 1000) % 60,
recording.captures())); recording.captures(), recording.events()));
} else { } else {
out.add(context.getString(R.string.signal_recorder_done, SignalRecording.time(startedMs), out.add(context.getString(R.string.signal_recorder_done, SignalRecording.time(startedMs),
recording.captures(), result == null ? "…" : result)); recording.captures(), recording.events(), result == null ? "…" : result));
} }
// Steering-wheel keys and the like first: few, and each one a lead. // Steering-wheel keys and the like first: few, and each one a lead.
List<String> notes = recording.notes(); List<String> notes = recording.notes();
out.addAll(notes.subList(Math.max(0, notes.size() - 10), notes.size())); out.addAll(notes.subList(Math.max(0, notes.size() - 10), notes.size()));
// Then what the listening heard, changing properties first.
List<String> heard = recording.heard();
for (String line : heard.subList(0, Math.min(12, heard.size()))) out.add("heard " + line);
out.addAll(recording.summary()); out.addAll(recording.summary());
return out; return out;
} }
@@ -16,6 +16,10 @@ import java.util.TreeSet;
* feature on and off, changes a setting). It keeps what changed, when, and per value how * feature on and off, changes a setting). It keeps what changed, when, and per value how
* often and through which values, so the value that follows the action stands out: it * often and through which values, so the value that follows the action stands out: it
* changes a few times, at the moments the driver noted. * changes a few times, at the moments the driver noted.
*
* Besides the captures (polled), it keeps the change events of the properties the recorder
* listens to ({@link #event}): how many per property and through which values. A value the
* head unit only fills while someone listens shows up there and nowhere else.
* Pure Java, unit-tested. * Pure Java, unit-tested.
*/ */
final class SignalRecording { final class SignalRecording {
@@ -31,6 +35,10 @@ final class SignalRecording {
private final List<String> notes = new ArrayList<>(); private final List<String> notes = new ArrayList<>();
private final Map<String, Integer> changes = new HashMap<>(); private final Map<String, Integer> changes = new HashMap<>();
private final Map<String, LinkedHashSet<String>> seen = new HashMap<>(); private final Map<String, LinkedHashSet<String>> seen = new HashMap<>();
private int events;
private final Map<String, String> lastEvent = new HashMap<>();
private final Map<String, Integer> eventCounts = new HashMap<>();
private final Map<String, LinkedHashSet<String>> eventValues = new HashMap<>();
static String time(long ms) { static String time(long ms) {
return new SimpleDateFormat("HH:mm:ss", Locale.US).format(new Date(ms)); return new SimpleDateFormat("HH:mm:ss", Locale.US).format(new Date(ms));
@@ -65,6 +73,45 @@ final class SignalRecording {
notes.add(time(ms) + " " + what); notes.add(time(ms) + " " + what);
} }
/**
* A change event from a property listened to. Every one is counted; the timeline gets a
* line only when the value differs from that property's last event.
*/
synchronized void event(long ms, String key, String value) {
events++;
eventCounts.merge(key, 1, Integer::sum);
String before = lastEvent.put(key, value);
if (value.equals(before)) return;
lines.add(time(ms) + " heard " + key + " = " + value);
LinkedHashSet<String> values = eventValues.computeIfAbsent(key, k -> new LinkedHashSet<>());
if (values.size() < VALUES_KEPT) values.add(shorten(value));
}
synchronized int events() {
return events;
}
/**
* Every property that sent events: first those that sent different values, the fewest
* events first (the leads), then those that only ever repeated one value.
*/
synchronized List<String> heard() {
List<String> keys = new ArrayList<>(eventCounts.keySet());
keys.sort((a, b) -> {
boolean va = eventValues.get(a).size() > 1, vb = eventValues.get(b).size() > 1;
if (va != vb) return va ? -1 : 1;
int c = Integer.compare(eventCounts.get(a), eventCounts.get(b));
return c != 0 ? c : a.compareTo(b);
});
List<String> out = new ArrayList<>();
for (String key : keys) {
LinkedHashSet<String> values = eventValues.get(key);
out.add(key + " " + eventCounts.get(key) + " events " + String.join(" → ", values)
+ (values.size() >= VALUES_KEPT ? " …" : ""));
}
return out;
}
/** The notes alone, in order. */ /** The notes alone, in order. */
synchronized List<String> notes() { synchronized List<String> notes() {
return new ArrayList<>(notes); return new ArrayList<>(notes);
+3 -3
View File
@@ -223,11 +223,11 @@
<string name="drive_mode_unreadable">Not changed: speed or gear unreadable.</string> <string name="drive_mode_unreadable">Not changed: speed or gear unreadable.</string>
<string name="drive_mode_unknown">an unknown mode (%1$s)</string> <string name="drive_mode_unknown">an unknown mode (%1$s)</string>
<string name="label_signal_recorder">Signal recorder</string> <string name="label_signal_recorder">Signal recorder</string>
<string name="signal_recorder_hint">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).</string> <string name="signal_recorder_hint">Records every value the head unit offers for 3 minutes, listens to the driver-assistance properties for every change they report, and notes the steering-wheel keys; then 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).</string>
<string name="btn_signal_record">Record 3 minutes</string> <string name="btn_signal_record">Record 3 minutes</string>
<string name="btn_signal_stop">Stop recording</string> <string name="btn_signal_stop">Stop recording</string>
<string name="signal_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="signal_recorder_running">Recording: %1$d:%2$02d of 3:00, %3$d captures, %4$d change events heard. Keys reported, then assistance properties heard, then values changed so far, the rarest first:</string>
<string name="signal_recorder_done">Recording from %1$s: %2$d captures, %3$s. Keys reported, then values changed, the rarest first:</string> <string name="signal_recorder_done">Recording from %1$s: %2$d captures, %3$d change events heard, %4$s. Keys reported, then assistance properties heard, then values changed, the rarest first:</string>
<string name="call_log_empty">No upload attempted yet.</string> <string name="call_log_empty">No upload attempted yet.</string>
<string name="update_log_title">Updates, newest first:</string> <string name="update_log_title">Updates, newest first:</string>
<string name="btn_flush_now">Upload now</string> <string name="btn_flush_now">Upload now</string>
@@ -63,4 +63,22 @@ public class SignalRecordingTest {
assertTrue(r.lines().get(4).endsWith("key 287 down")); assertTrue(r.lines().get(4).endsWith("key 287 down"));
assertEquals(1, r.notes().size()); assertEquals(1, r.notes().size());
} }
@Test
public void heardPropertiesWithChangingValuesComeFirst() {
SignalRecording r = new SignalRecording();
for (int i = 0; i < 5; i++) r.event(i * 1000, "CPM 0x2140A10A/0", "1"); // repeats one value
r.event(1000, "CPM 0x2160A15B/0", "0.0");
r.event(2000, "CPM 0x2160A15B/0", "101.0");
r.event(3000, "CPM 0x2160A15B/0", "101.0");
r.event(4000, "CPM 0x2160A15B/0", "90.0");
List<String> heard = r.heard();
assertEquals(2, heard.size());
assertEquals("CPM 0x2160A15B/0 4 events 0.0 → 101.0 → 90.0", heard.get(0));
assertEquals("CPM 0x2140A10A/0 5 events 1", heard.get(1));
assertEquals(9, r.events());
// The timeline holds only the changes: 0.0, 101.0, 90.0 and the first 1.
long lines = r.lines().stream().filter(l -> l.contains("heard ")).count();
assertEquals(4, lines);
}
} }