Skip to content

Commit 0c7534a

Browse files
committed
feat(telemetry): probe usable capacity
1 parent c56d404 commit 0c7534a

7 files changed

Lines changed: 138 additions & 5 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,14 @@ All notable changes to this project are documented here. Format follows
2020

2121
### Added
2222

23+
- **A public post-AAOS 9 capacity signal can be tested without becoming product data.**
24+
`EV_CURRENT_BATTERY_CAPACITY` describes real-time usable capacity rather than the pack's nominal
25+
new capacity, but a public id does not prove that an MG4 firmware publishes it. The new
26+
status-aware, firmware-branched reader is therefore internal to explicit evidence capture,
27+
stays null when absent or invalid, and is carried beside — never inside — `EnergySnapshot`.
28+
[The candidate review](docs/energy-signal-candidates.md) records exact AOSP provenance and the
29+
negative search results for HVAC, battery-heater, accessory and cumulative energy counters.
30+
2331
- **Three radio behaviours the library could already perform are now things a rule can ask for.**
2432
`srcPauseRadio`, the tuner's own play state and `startRadioActivity` were implemented and
2533
unreachable: the catalogue offered play, tune and station stepping, so "silence the radio"

‎docs/energy-signal-candidates.md‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# Public energy-signal candidates
2+
3+
Reviewed 2026-08-28 for CP-004. This review uses only public AOSP catalogues and the
4+
project's existing runtime read seam. It does not use proprietary artifacts or non-public
5+
property catalogues.
6+
7+
## Safety and validation boundary
8+
9+
Every entry below is read-only and starts with `validated: no`. A public property number proves
10+
only that AOSP assigned a number and semantics; it does not prove that an MG4 VHAL implements or
11+
publishes it. No candidate may enter `EnergySnapshot`, a production fallback or a stable UI until
12+
CP-003 records availability, unit, scale, sign and update cadence for every supported firmware
13+
generation. An unavailable or non-finite read stays null.
14+
15+
## Public catalogue findings
16+
17+
| Signal | Public declaration | Type / area / unit / mode | AOSP coverage | EVSuite disposition | validated |
18+
|---|---|---|---|---|---|
19+
| `EV_CURRENT_BATTERY_CAPACITY` (`291504909`, `0x1160030D`) | [AOSP snapshot `c80b787`](https://android.googlesource.com/platform/packages/services/Car/+/c80b787d499243d120c2738eb68e7c0520c7238d/car-lib/src/android/car/VehiclePropertyIds.java) | Float / global / Wh / on-change / read-only | Public post-AAOS 9 API; the declaration requires Car UDC with Android 13 or later | Highest-value new candidate: status-aware probe in unstable evidence capture only | no |
20+
| `EV_BATTERY_AVERAGE_TEMPERATURE` (`291504910`, `0x1160030E`) | [AOSP change `5b96f99`](https://android.googlesource.com/platform/packages/services/Car/+/5b96f99271e9209774a312c8f0fc1279dccf0678/car-lib/src/android/car/VehiclePropertyIds.java) | Float / global / °C / continuous / read-only | Added to the public API in December 2023 | Already read by the CP-001 baseline as nullable battery temperature; CP-004 adds no duplicate candidate | no |
21+
| `EV_BATTERY_INSTANTANEOUS_CHARGE_RATE` (`291504908`, `0x1160030C`) | [Android 9 AOSP snapshot `8686c86`](https://android.googlesource.com/platform/packages/services/Car/+/8686c86b38a590712412fea2dd083181aceba4f2/car-lib/src/android/car/VehiclePropertyIds.java) | Float / global / mW / continuous / read-only | Android 9 public catalogue | Existing CP-001 battery-power input; its live scale and sign still require CP-003 evidence | no |
22+
23+
All cited files are Copyright Android Open Source Project and licensed Apache-2.0 at the cited
24+
commit. The review records public declarations, not copied implementation code. Vehicle coverage
25+
remains unknown for SWI68, SWI69, SWI131, SWI132, SWI133 and SWI165 until CP-003 runs on each one.
26+
27+
## Explicit negative results
28+
29+
| Requested quantity | Public catalogue result | Consequence |
30+
|---|---|---|
31+
| HVAC, compressor or PTC electrical power | No Android 9 or reviewed later public vehicle property. `HVAC_POWER_ON` is only a boolean subsystem state. | Climate energy can only be estimated from validated pack power and controlled trials. |
32+
| Battery-heater power | No public power or energy property found. Battery temperature is a temperature, not heater consumption. | No direct measurement candidate. |
33+
| DC-DC or accessory load | No public power or energy property found. | No direct measurement candidate. |
34+
| Cumulative battery discharge | No public cumulative counter found. `EV_BATTERY_LEVEL` is current stored energy. | Integrate validated instantaneous power, retaining uncertainty and gaps. |
35+
| Cumulative regeneration | No public cumulative counter found. Later regenerative-braking properties describe a state, not recovered energy. | Integrate only after CP-003 proves the power sign convention. |
36+
| Per-trip energy | No public per-trip energy property found. | EVSuite trip totals remain derived from validated samples. |
37+
38+
The search deliberately stops here: sweeping arbitrary property ids would produce ambiguous data
39+
and broaden the runtime surface without provenance.
40+
41+
## Probe wiring
42+
43+
`EV_CURRENT_BATTERY_CAPACITY` is read through the same status-aware reflected
44+
`CarPropertyManager.getProperty` path as existing telemetry. The read is explicitly branched over
45+
the supported firmware generations and returns null for unknown firmware, unavailable status,
46+
negative values, exceptions and non-finite values. It is exposed only by
47+
`EnergyTelemetryReader.readEvidence()` and recorded as
48+
`candidate.evCurrentBatteryCapacityWh`; ordinary `read()` and `EnergySnapshot` are unchanged.

‎lib/src/main/java/com/evsuite/hardware/EVHardware.kt‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,7 @@ object EVHardware {
392392
// Standard AAOS read-only energy signals. Every access is explicitly limited to a
393393
// supported firmware generation; UNKNOWN stays null instead of assuming compatibility.
394394
private const val PROP_EV_INSTANTANEOUS_CHARGE_RATE = 0x1160030C // mW, + charge / - discharge
395+
private const val PROP_EV_CURRENT_BATTERY_CAPACITY = 0x1160030D // Wh, public post-AAOS 9 candidate
395396
private const val PROP_EV_CHARGE_PORT_CONNECTED = 0x1120030B
396397
private const val PROP_EV_BATTERY_AVG_TEMP = 0x1160030E // °C
397398
private const val PROP_EV_BATTERY_LEVEL = 0x11600309 // Wh
@@ -428,6 +429,15 @@ object EVHardware {
428429
?.div(1_000f)
429430
}
430431

432+
/**
433+
* Probe-only public AAOS candidate; never use this as a production telemetry fallback until
434+
* CP-003 validates its availability and meaning on every supported firmware generation.
435+
*/
436+
internal fun probeCurrentBatteryCapacityWh(): Float? = supportedTelemetryRead {
437+
getFloatPropertyCPM(PROP_EV_CURRENT_BATTERY_CAPACITY, AREA_GLOBAL)
438+
?.takeIf { it.isFinite() && it >= 0f }
439+
}
440+
431441
fun getStandardRangeKm(): Float? = supportedTelemetryRead {
432442
getFloatPropertyCPM(PROP_RANGE_REMAINING, AREA_GLOBAL)
433443
?.takeIf { it.isFinite() && it >= 0f }

‎lib/src/main/java/com/evsuite/hardware/telemetry/EnergyTelemetryReader.kt‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ interface EnergySignalSource {
2525
fun parked(): Boolean?
2626
fun climate(): ClimateSnapshot
2727
fun tirePressures(): TirePressureSnapshot
28+
/** Probe-only values excluded from [EnergySnapshot] and normal consumers. */
29+
fun evidenceProbes(): List<TelemetryEvidenceProbe> = emptyList()
2830
}
2931

3032
class EvHardwareEnergySignalSource(context: Context) : EnergySignalSource {
@@ -59,6 +61,12 @@ class EvHardwareEnergySignalSource(context: Context) : EnergySignalSource {
5961
rearLeftKpa = EVHardware.getTirePressureKpa(EVHardware.Wheel.REAR_LEFT),
6062
rearRightKpa = EVHardware.getTirePressureKpa(EVHardware.Wheel.REAR_RIGHT),
6163
)
64+
override fun evidenceProbes() = listOf(
65+
TelemetryEvidenceProbe(
66+
TelemetryEvidenceRecorder.CANDIDATE_CURRENT_BATTERY_CAPACITY_WH,
67+
EVHardware.probeCurrentBatteryCapacityWh()?.toDouble(),
68+
)
69+
)
6270
override fun climate() = ClimateSnapshot(
6371
powerOn = SaicClimate.powerOn(),
6472
acOn = SaicClimate.acOn(),
@@ -96,4 +104,12 @@ class EnergyTelemetryReader(private val source: EnergySignalSource) {
96104
climate = source.climate(),
97105
tirePressures = source.tirePressures(),
98106
)
107+
108+
/** Reads CP-004 candidates only when an unstable evidence capture explicitly requests them. */
109+
fun readEvidence(nowMs: Long = System.currentTimeMillis()) = TelemetryEvidenceSample(
110+
snapshot = read(nowMs),
111+
probes = source.evidenceProbes().map { probe ->
112+
probe.copy(value = probe.value?.takeIf { it.isFinite() })
113+
},
114+
)
99115
}

‎lib/src/main/java/com/evsuite/hardware/telemetry/TelemetryEvidence.kt‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@ import com.google.gson.Gson
55
/** Whether a signal is a quantity or a flag; a flag has no meaningful mean. */
66
enum class SignalKind { NUMERIC, BOOLEAN }
77

8+
/** A read-only candidate carried beside, but never inside, the production snapshot. */
9+
data class TelemetryEvidenceProbe(val signal: String, val value: Double?)
10+
11+
data class TelemetryEvidenceSample(
12+
val snapshot: EnergySnapshot,
13+
val probes: List<TelemetryEvidenceProbe>,
14+
)
15+
816
/**
917
* What one signal did during a capture, in the terms a decision needs.
1018
*
@@ -109,6 +117,13 @@ class TelemetryEvidenceRecorder {
109117
record(CLIMATE_PASSENGER_TARGET_C, climate.passengerTargetCelsius, at)
110118
}
111119

120+
fun record(sample: TelemetryEvidenceSample) {
121+
record(sample.snapshot)
122+
sample.probes.forEach { probe ->
123+
record(probe.signal, probe.value, sample.snapshot.timestampMs)
124+
}
125+
}
126+
112127
/** For a candidate property being probed alongside the snapshot (see the CP-004 findings). */
113128
fun record(signal: String, value: Double?, atMs: Long) =
114129
accumulator(signal, SignalKind.NUMERIC).add(value, atMs)
@@ -244,6 +259,8 @@ class TelemetryEvidenceRecorder {
244259
const val CLIMATE_FAN_LEVEL_MAX = "climate.fanLevelMax"
245260
const val CLIMATE_DRIVER_TARGET_C = "climate.driverTargetCelsius"
246261
const val CLIMATE_PASSENGER_TARGET_C = "climate.passengerTargetCelsius"
262+
const val CANDIDATE_CURRENT_BATTERY_CAPACITY_WH =
263+
"candidate.evCurrentBatteryCapacityWh"
247264
}
248265
}
249266

‎lib/src/test/java/com/evsuite/hardware/telemetry/EnergyTelemetryTest.kt‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,31 @@ class EnergyTelemetryTest {
1616
assertEquals(18f, snapshot.batteryPowerKw)
1717
}
1818

19+
@Test fun `candidate probes exist only in the explicit evidence read`() {
20+
val source = FakeSignals(soc = 80f, range = 200f, power = 18f)
21+
source.probes = listOf(
22+
TelemetryEvidenceProbe(
23+
TelemetryEvidenceRecorder.CANDIDATE_CURRENT_BATTERY_CAPACITY_WH,
24+
61_700.0,
25+
)
26+
)
27+
val reader = EnergyTelemetryReader(source)
28+
29+
val normal = reader.read(41L)
30+
val evidence = reader.readEvidence(42L)
31+
32+
assertEquals(41L, normal.timestampMs)
33+
assertEquals(42L, evidence.snapshot.timestampMs)
34+
assertEquals(61_700.0, evidence.probes.single().value!!, 0.0)
35+
}
36+
37+
@Test fun `non finite candidate values fail closed`() {
38+
val source = FakeSignals(soc = null, range = null, power = null)
39+
source.probes = listOf(TelemetryEvidenceProbe("candidate.invalid", Double.NaN))
40+
41+
assertNull(EnergyTelemetryReader(source).readEvidence(42L).probes.single().value)
42+
}
43+
1944
@Test fun `trip integrates distance consumption and regeneration`() {
2045
val trip = EnergyTripAccumulator(0L, 80f)
2146
trip.add(snapshot(0L, 60f, 18f))
@@ -69,6 +94,7 @@ class EnergyTelemetryTest {
6994
private val range: Float?,
7095
private val power: Float?,
7196
) : EnergySignalSource {
97+
var probes: List<TelemetryEvidenceProbe> = emptyList()
7298
override fun firmware() = FirmwareInfo.Gen.SWI68
7399
override fun socPercent() = soc
74100
override fun rangeKm() = range
@@ -86,5 +112,6 @@ class EnergyTelemetryTest {
86112
override fun climate() =
87113
ClimateSnapshot(null, null, null, null, null, null, null, null, null)
88114
override fun tirePressures() = TirePressureSnapshot(null, null, null, null)
115+
override fun evidenceProbes() = probes
89116
}
90117
}

‎lib/src/test/java/com/evsuite/hardware/telemetry/TelemetryEvidenceTest.kt‎

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -150,13 +150,20 @@ class TelemetryEvidenceTest {
150150

151151
@Test fun `a probed candidate property is recorded beside the snapshot signals`() {
152152
val recorder = TelemetryEvidenceRecorder()
153-
recorder.record(snapshot(at = 0L, power = 1f))
154-
recorder.record("candidate.hvacPowerKw", 0.8, 0L)
155-
recorder.record("candidate.hvacPowerKw", null as Double?, 1_000L)
156-
157-
val candidate = recorder.evidence().single { it.signal == "candidate.hvacPowerKw" }
153+
val name = TelemetryEvidenceRecorder.CANDIDATE_CURRENT_BATTERY_CAPACITY_WH
154+
recorder.record(TelemetryEvidenceSample(
155+
snapshot(at = 0L, power = 1f),
156+
listOf(TelemetryEvidenceProbe(name, 61_700.0)),
157+
))
158+
recorder.record(TelemetryEvidenceSample(
159+
snapshot(at = 1_000L, power = 2f),
160+
listOf(TelemetryEvidenceProbe(name, null)),
161+
))
162+
163+
val candidate = recorder.evidence().single { it.signal == name }
158164
assertEquals(1, candidate.samples)
159165
assertEquals(1, candidate.nulls)
166+
assertEquals(2, recorder.capture().snapshots)
160167
}
161168

162169
@Test fun `the markdown rendering names every signal and dashes what is missing`() {

0 commit comments

Comments
 (0)