From e90031d6b059028d2defbed8c57fcdd0103bb1fc Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Tue, 27 Oct 2026 00:55:26 +0100 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6 --- AGENTS.md | 2 +- .../java/com/evsuite/hardware/AssistBlock.kt | 24 +++++ .../java/com/evsuite/hardware/EVHardware.kt | 99 +++++++++++++++++++ .../com/evsuite/hardware/AssistBlockTest.kt | 29 ++++++ LICENSE.md | 5 +- README.md | 2 +- SPECS.md | 18 +++- app/build.gradle | 4 +- .../java/com/evsuite/abrp/SignalRecorder.java | 42 +++++++- .../com/evsuite/abrp/SignalRecording.java | 47 +++++++++ app/src/main/res/values/strings.xml | 6 +- .../com/evsuite/abrp/SignalRecordingTest.java | 18 ++++ 12 files changed, 282 insertions(+), 14 deletions(-) create mode 100644 EVHardware/lib/src/main/java/com/evsuite/hardware/AssistBlock.kt create mode 100644 EVHardware/lib/src/test/java/com/evsuite/hardware/AssistBlockTest.kt diff --git a/AGENTS.md b/AGENTS.md index 84295cc..f6f9df6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 | | 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 | -| 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, 15 s tick), `TelemetryUploader` (+ `TelemetryQueue`, persisted), `TelemetryPayload` (server diff --git a/EVHardware/lib/src/main/java/com/evsuite/hardware/AssistBlock.kt b/EVHardware/lib/src/main/java/com/evsuite/hardware/AssistBlock.kt new file mode 100644 index 0000000..90e4a6a --- /dev/null +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/AssistBlock.kt @@ -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 +} 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 2a19657..5306fda 100644 --- a/EVHardware/lib/src/main/java/com/evsuite/hardware/EVHardware.kt +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/EVHardware.kt @@ -320,6 +320,105 @@ object EVHardware { 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). */ fun unregisterIgnitionPropertyCallback() { val cpm = sCarPropertyManager ?: return diff --git a/EVHardware/lib/src/test/java/com/evsuite/hardware/AssistBlockTest.kt b/EVHardware/lib/src/test/java/com/evsuite/hardware/AssistBlockTest.kt new file mode 100644 index 0000000..587e86f --- /dev/null +++ b/EVHardware/lib/src/test/java/com/evsuite/hardware/AssistBlockTest.kt @@ -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)) + } + } +} diff --git a/LICENSE.md b/LICENSE.md index 6db5db7..45c4251 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -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 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 a drive mode write without the binder -fallback (`writeDriveModeProperty`). To take upstream again, merge three ways with +(`EVHardware.getGearRaw`, `EnergySnapshot.gearRaw`), a drive mode write without the binder +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. The car app links EVHardware into its APK, so the **built app** may only be used and passed on diff --git a/README.md b/README.md index 982ba69..5c8ec1b 100644 --- a/README.md +++ b/README.md @@ -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 | | **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 | -| **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 voice key** on the steering wheel and the app comes to the front (switch on the Settings diff --git a/SPECS.md b/SPECS.md index f8207af..cc25ad3 100644 --- a/SPECS.md +++ b/SPECS.md @@ -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 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 - 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 @@ -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 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-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 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 diff --git a/app/build.gradle b/app/build.gradle index d6282ff..98ff111 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -34,8 +34,8 @@ android { applicationId "com.stinnesbeck.nilsmg4" minSdk 27 targetSdk 36 - versionCode 147 - versionName "2.40.2" + versionCode 148 + versionName "2.40.3" // 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/SignalRecorder.java b/app/src/main/java/com/evsuite/abrp/SignalRecorder.java index 4688791..4b15276 100644 --- a/app/src/main/java/com/evsuite/abrp/SignalRecorder.java +++ b/app/src/main/java/com/evsuite/abrp/SignalRecorder.java @@ -7,6 +7,8 @@ import android.content.IntentFilter; import android.os.Bundle; import android.os.Environment; +import com.evsuite.hardware.AssistBlock; +import com.evsuite.hardware.EVHardware; import com.evsuite.hardware.probe.VendorSurfaceProbe; 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 * 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 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 * 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) { // 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 { long end = startedMs + DURATION_MS; while (running && System.currentTimeMillis() < end) { @@ -99,6 +120,13 @@ final class SignalRecorder { } catch (Throwable t) { rec.note(System.currentTimeMillis(), "recorder stopped: " + t.getClass().getSimpleName()); } finally { + if (listening != null) { + try { + listening.close(); + } catch (Throwable ignored) { + // the car service is gone + } + } if (keysRegistered) { try { app.unregisterReceiver(keys); @@ -130,7 +158,10 @@ final class SignalRecorder { text.add("Steering-wheel keys and notes:"); text.addAll(rec.notes()); 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.add(""); text.add("Everything, in order:"); @@ -168,14 +199,17 @@ final class SignalRecorder { long elapsed = (running ? System.currentTimeMillis() : startedMs + DURATION_MS) - startedMs; if (running) { out.add(context.getString(R.string.signal_recorder_running, elapsed / 60_000, (elapsed / 1000) % 60, - recording.captures())); + recording.captures(), recording.events())); } else { 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. List notes = recording.notes(); out.addAll(notes.subList(Math.max(0, notes.size() - 10), notes.size())); + // Then what the listening heard, changing properties first. + List heard = recording.heard(); + for (String line : heard.subList(0, Math.min(12, heard.size()))) out.add("heard " + line); out.addAll(recording.summary()); return out; } diff --git a/app/src/main/java/com/evsuite/abrp/SignalRecording.java b/app/src/main/java/com/evsuite/abrp/SignalRecording.java index d6efb2d..4dd833d 100644 --- a/app/src/main/java/com/evsuite/abrp/SignalRecording.java +++ b/app/src/main/java/com/evsuite/abrp/SignalRecording.java @@ -16,6 +16,10 @@ import java.util.TreeSet; * 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 * 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. */ final class SignalRecording { @@ -31,6 +35,10 @@ final class SignalRecording { private final List notes = new ArrayList<>(); private final Map changes = new HashMap<>(); private final Map> seen = new HashMap<>(); + private int events; + private final Map lastEvent = new HashMap<>(); + private final Map eventCounts = new HashMap<>(); + private final Map> eventValues = new HashMap<>(); static String time(long 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); } + /** + * 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 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 heard() { + List 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 out = new ArrayList<>(); + for (String key : keys) { + LinkedHashSet 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. */ synchronized List notes() { return new ArrayList<>(notes); diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 32d9b8c..eea36ef 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -223,11 +223,11 @@ Not changed: speed or gear unreadable. an unknown mode (%1$s) Signal recorder - 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). + 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). Record 3 minutes Stop recording - Recording: %1$d:%2$02d of 3:00, %3$d captures. Keys reported, then values changed so far, the rarest first: - Recording from %1$s: %2$d captures, %3$s. Keys reported, then values changed, the rarest first: + 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: + 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: No upload attempted yet. Updates, newest first: Upload now diff --git a/app/src/test/java/com/evsuite/abrp/SignalRecordingTest.java b/app/src/test/java/com/evsuite/abrp/SignalRecordingTest.java index a7bf95d..3d8291f 100644 --- a/app/src/test/java/com/evsuite/abrp/SignalRecordingTest.java +++ b/app/src/test/java/com/evsuite/abrp/SignalRecordingTest.java @@ -63,4 +63,22 @@ public class SignalRecordingTest { assertTrue(r.lines().get(4).endsWith("key 287 down")); 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 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); + } }