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
@@ -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)
}
// -------------------------------------------------------------------------
// 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
@@ -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))
}
}
}