2 Commits
Author SHA1 Message Date
nilsandClaude Opus 5.5 60ab3118a1 App 2.40.4: the recorder's listening works on Android 9
listenToProperties looked only for registerCallback (Android 10); the
car's car library has registerListener/unregisterListener. Take either,
unsubscribe property by property, and say why when it fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-10-27 09:19:33 +01:00
nilsandClaude Opus 5.5 097467f4e0 SPECS: app 2.40.3 published
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-10-27 00:56:39 +01:00
5 changed files with 179 additions and 19 deletions
@@ -324,6 +324,10 @@ object EVHardware {
// Listening to properties (the app's signal recorder) — read only // Listening to properties (the app's signal recorder) — read only
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
/** Why the last [listenToProperties] returned null, in a few words; null after a success. */
@Volatile var listenProblem: String? = null
private set
/** A subscription made by [listenToProperties]; [close] ends it. */ /** A subscription made by [listenToProperties]; [close] ends it. */
class PropertyListening internal constructor( class PropertyListening internal constructor(
/** Properties subscribed to, and those the car service refused. */ /** Properties subscribed to, and those the car service refused. */
@@ -342,19 +346,44 @@ object EVHardware {
* may fill a property from the car network only while someone listens, so a poll alone can * 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 * 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 * 1 Hz within their limits. Read only: nothing is set. Null when there is no
* CarPropertyManager or no way to subscribe; [PropertyListening.close] unsubscribes. * CarPropertyManager or no way to subscribe, with the reason in [listenProblem];
* [PropertyListening.close] unsubscribes.
*
* Android 9's car library (the MG4's) names the calls `registerListener(CarPropertyEventListener,
* int, float)` and `unregisterListener`; Android 10 renamed them `registerCallback` and
* `unregisterCallback`. Both are looked for. Unsubscribing goes property by property: the
* one-argument `unregisterListener` of Android 9 removes entries from the list it walks and so
* leaves every other property subscribed.
*/ */
fun listenToProperties(admit: (Int) -> Boolean, onEvent: (Int, Int, String) -> Unit): PropertyListening? { fun listenToProperties(admit: (Int) -> Boolean, onEvent: (Int, Int, String) -> Unit): PropertyListening? {
val cpm = sCarPropertyManager ?: return null listenProblem = null
val cpm = sCarPropertyManager ?: run {
listenProblem = "no car service"
return null
}
return try { return try {
val register = cpm.javaClass.methods.firstOrNull { val methods = cpm.javaClass.methods
it.name == "registerCallback" && it.parameterCount == 3 val register = methods.firstOrNull {
} ?: return null (it.name == "registerListener" || it.name == "registerCallback") && it.parameterCount == 3 &&
val unregister = cpm.javaClass.methods.firstOrNull { it.parameterTypes[1] == Int::class.javaPrimitiveType &&
it.name == "unregisterCallback" && it.parameterCount == 1 it.parameterTypes[2] == Float::class.javaPrimitiveType
} ?: run {
listenProblem = "no way to subscribe"
return null
} }
val callbackType = register.parameterTypes[0] val callbackType = register.parameterTypes[0]
if (!callbackType.isInterface) return null val unregister = methods.firstOrNull {
it.name == register.name.replaceFirst("register", "unregister") && it.parameterCount == 2 &&
it.parameterTypes[0] == callbackType && it.parameterTypes[1] == Int::class.javaPrimitiveType
}
val unregisterAll = methods.firstOrNull {
it.name == register.name.replaceFirst("register", "unregister") && it.parameterCount == 1 &&
it.parameterTypes[0] == callbackType
}
if (!callbackType.isInterface) {
listenProblem = "unexpected listener type ${callbackType.simpleName}"
return null
}
val proxy = Proxy.newProxyInstance(callbackType.classLoader, arrayOf(callbackType)) { self, method, args -> val proxy = Proxy.newProxyInstance(callbackType.classLoader, arrayOf(callbackType)) { self, method, args ->
when (method.name) { when (method.name) {
"onChangeEvent" -> { "onChangeEvent" -> {
@@ -385,7 +414,7 @@ object EVHardware {
} }
} }
val configs = cpm.javaClass.getMethod("getPropertyList").invoke(cpm) as? List<*> ?: return null val configs = cpm.javaClass.getMethod("getPropertyList").invoke(cpm) as? List<*> ?: return null
var subscribed = 0 val subscribedIds = ArrayList<Int>()
var refused = 0 var refused = 0
for (config in configs) { for (config in configs) {
if (config == null) continue if (config == null) continue
@@ -399,21 +428,31 @@ object EVHardware {
1f.coerceIn(min, maxOf(min, max)) 1f.coerceIn(min, maxOf(min, max))
} }
try { try {
if (register.invoke(cpm, proxy, id, rate) == false) refused++ else subscribed++ if (register.invoke(cpm, proxy, id, rate) == false) refused++ else subscribedIds.add(id)
} catch (e: Exception) { } catch (e: Exception) {
refused++ refused++
AppLogger.d(TAG, "listen: 0x%08X refused: %s".format(id, e.readFailureReason())) AppLogger.d(TAG, "listen: 0x%08X refused: %s".format(id, e.readFailureReason()))
} }
} }
AppLogger.i(TAG, "listening to $subscribed properties ($refused refused)") AppLogger.i(TAG, "listening to ${subscribedIds.size} properties ($refused refused) via ${register.name}")
PropertyListening(subscribed, refused) { PropertyListening(subscribedIds.size, refused) {
for (id in subscribedIds) {
try { try {
unregister?.invoke(cpm, proxy) if (unregister != null) unregister.invoke(cpm, proxy, id)
} catch (e: Exception) {
AppLogger.d(TAG, "listen: unsubscribe 0x%08X failed: %s".format(id, e.readFailureReason()))
}
}
if (unregister == null) {
try {
unregisterAll?.invoke(cpm, proxy)
} catch (e: Exception) { } catch (e: Exception) {
AppLogger.d(TAG, "listen: unsubscribe failed: ${e.readFailureReason()}") AppLogger.d(TAG, "listen: unsubscribe failed: ${e.readFailureReason()}")
} }
} }
}
} catch (e: Exception) { } catch (e: Exception) {
listenProblem = e.readFailureReason()
AppLogger.w(TAG, "listenToProperties: ${e.readFailureReason()}") AppLogger.w(TAG, "listenToProperties: ${e.readFailureReason()}")
null null
} }
@@ -0,0 +1,105 @@
package com.evsuite.hardware
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class PropertyListeningTest {
class Config(val propertyId: Int, val changeMode: Int, val minSampleRate: Float, val maxSampleRate: Float)
class Value(val propertyId: Int, val areaId: Int, val value: Any)
/** Shaped like Android 9's CarPropertyManager, the car's: registerListener, and an
* unregisterListener(listener) that skips every other property as the car's does. */
class Android9Manager(private val configs: List<Config>) {
interface CarPropertyEventListener {
fun onChangeEvent(value: Any?)
fun onErrorEvent(propertyId: Int, areaId: Int)
}
val active = LinkedHashMap<Int, MutableList<CarPropertyEventListener>>()
val rates = HashMap<Int, Float>()
fun getPropertyList(): List<Config> = configs
fun registerListener(listener: CarPropertyEventListener, propertyId: Int, rate: Float): Boolean {
active.getOrPut(propertyId) { ArrayList() }.add(listener)
rates[propertyId] = rate
return true
}
fun unregisterListener(listener: CarPropertyEventListener) {
val ids = active.keys.toMutableList()
var i = 0
while (i < ids.size) {
unregisterListener(listener, ids[i])
if (active[ids[i]] == null) ids.removeAt(i) // as SparseArray.remove under keyAt(i)
i++
}
}
fun unregisterListener(listener: CarPropertyEventListener, propertyId: Int) {
val list = active[propertyId] ?: return
list.remove(listener)
if (list.isEmpty()) active.remove(propertyId)
}
fun fire(propertyId: Int, value: Any) {
active[propertyId]?.toList()?.forEach { it.onChangeEvent(Value(propertyId, 0x01000000, value)) }
}
}
class NoListeners {
fun getPropertyList(): List<Config> = emptyList()
}
private val field = EVHardware::class.java.getDeclaredField("sCarPropertyManager").apply { isAccessible = true }
@After
fun reset() = field.set(null, null)
@Test
fun `subscribes through Android 9's registerListener and unsubscribes every property`() {
val manager = Android9Manager(listOf(
Config(0x2160A15B, 1, 0f, 0f), // in the block, on change
Config(0x2140A15A, 1, 0f, 0f),
Config(0x2160A1A3, 2, 0f, 10f), // continuous
Config(0x2140A101, 1, 0f, 0f),
Config(0x11400400, 1, 0f, 0f), // outside the block
))
field.set(null, manager)
val heard = ArrayList<String>()
val listening = EVHardware.listenToProperties(AssistBlock::contains) { id, area, value ->
heard += "%08X/%d=%s".format(id, area, value)
}
assertNotNull(EVHardware.listenProblem, listening)
assertEquals(4, listening!!.subscribed)
assertEquals(0, listening.refused)
assertEquals(setOf(0x2160A15B, 0x2140A15A, 0x2160A1A3, 0x2140A101), manager.active.keys)
assertEquals(0f, manager.rates[0x2160A15B])
assertEquals(1f, manager.rates[0x2160A1A3])
manager.fire(0x2160A15B, 90f)
assertEquals(1, heard.size)
assertTrue(heard[0], heard[0].startsWith("2160A15B/16777216="))
listening.close()
assertTrue(manager.active.toString(), manager.active.isEmpty())
}
@Test
fun `says why when there is no way to subscribe`() {
field.set(null, NoListeners())
assertNull(EVHardware.listenToProperties(AssistBlock::contains) { _, _, _ -> })
assertEquals("no way to subscribe", EVHardware.listenProblem)
field.set(null, null)
assertNull(EVHardware.listenToProperties(AssistBlock::contains) { _, _, _ -> })
assertEquals("no car service", EVHardware.listenProblem)
}
}
+15
View File
@@ -140,6 +140,21 @@ 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.4: **the recorder's listening works on Android 9**. Both recordings
from the car (08:49 and 08:52, 2.40.3) said "listening to the assistance properties: not
possible": `listenToProperties` looked only for `registerCallback`, Android 10's name; the car's
car library (Android 9) has `registerListener(CarPropertyEventListener, int, float)` and
`unregisterListener`, as the app's `CarPropertyAdapter` already knew. It now takes either, and
unsubscribes property by property (Android 9's one-argument `unregisterListener` removes
entries from the list it walks and would leave every other property subscribed). A failure now
says why (`EVHardware.listenProblem`: no car service, no way to subscribe, or the exception).
Tested with a stand-in manager of Android 9's shape (`PropertyListeningTest`). Correction to
2.40.3: the ignition listener it was compared with also looks only for `registerCallback`, so
it most likely never subscribes on the car either (it reads the state once at start); left as
it is. What the polled part of the two recordings shows of the block: `0x2160A15B` (by name
the target speed) 0.0 throughout; `0x2140A15A` between 3 and 7 every few captures;
`0x2160A1A3`/`0x2160A1A4` counting up by 0.1; speeds 8–33 km/h. Not tried on the car.
- 2026-10-27 — App 2.40.3 published (`car-2.40.3`; also carries 2.40.1 and 2.40.2).
- 2026-10-27 — App 2.40.3: **the signal recorder listens** (owner: "is there really nothing we - 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. 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 The head unit's `CarAdvancedAssistedDrivingManager` never polls its 108 `AAD_*` properties: it
+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 148 versionCode 149
versionName "2.40.3" versionName "2.40.4"
// 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.
@@ -103,7 +103,8 @@ final class SignalRecorder {
rec.note(System.currentTimeMillis(), "listening failed: " + t.getClass().getSimpleName()); rec.note(System.currentTimeMillis(), "listening failed: " + t.getClass().getSimpleName());
} }
rec.note(System.currentTimeMillis(), listening == null rec.note(System.currentTimeMillis(), listening == null
? "listening to the assistance properties: not possible (no car service)" ? "listening to the assistance properties: not possible ("
+ EVHardware.INSTANCE.getListenProblem() + ")"
: "listening to " + listening.getSubscribed() + " assistance properties (" : "listening to " + listening.getSubscribed() + " assistance properties ("
+ listening.getRefused() + " refused)"); + listening.getRefused() + " refused)");
try { try {