Skip to content

Commit 3e971ba

Browse files
malysclaude
andcommitted
feat: a reading says whether the car measured it or a model guessed it
The dashboard formatted nullable floats straight out of the snapshot, which worked only because every figure on it happened to be a vehicle reading. That stops being true with the next feature: adaptive range, the climate share and the speed comparison are models, and they have to arrive on screen visibly different from the state of charge. DashboardReadings maps the snapshot into EVHardware's Provenanced carrier — vehicle signals measured, trip totals derived from them, nothing zero when it is missing — and ProvenanceText draws the difference in text rather than colour: a band for an estimate, an em dash for a gap, and the reason for that gap in the reading's content description and in the diagnostics report, where there is room for a sentence. An unrecognised firmware now says it was never taught to read the signal instead of implying the car went quiet. Verified on the API 28 1920x720 emulator, which exposes no car service: every field renders its em dash with its unit intact and the trip controls stay disabled, with no crash in logcat. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent e90f129 commit 3e971ba

7 files changed

Lines changed: 264 additions & 33 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,17 @@ All notable changes follow [Keep a Changelog](https://keepachangelog.com/en/1.1.
66

77
### Added
88

9+
- **Every figure on the dashboard now says what kind of claim it is.** The energy work ahead is
10+
mostly inference — the MG4 publishes no per-consumer counters, so the climate share, the speed
11+
comparison and the arrival state of charge will all be models — and a model drawn in the same
12+
typeface as a vehicle reading is a vehicle reading as far as the driver is concerned. Readings
13+
are mapped into EVHardware's `Provenanced` carrier, which cannot hold an estimate without its
14+
uncertainty band and cannot hold a number for a value it calls unavailable. An estimate wears a
15+
`≈` and its band; a missing figure stays an em dash whose reason — unsupported generation,
16+
signal not published, not enough samples — is spoken by the reading's description and spelled
17+
out in the diagnostics report. The distinction is carried by text, not colour, so it survives
18+
daylight and reaches a screen reader.
19+
920
- **The diagnostics dialog lists what each energy property actually answers.** A field showing
1021
an em dash says a signal is unusable but not why, and unsupported, declared-but-never-published
1122
and unreachable all look identical from the driver's seat. The report names the status, the raw
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package com.evsuite.chargepilot
2+
3+
import com.evsuite.hardware.FirmwareInfo
4+
import com.evsuite.hardware.telemetry.EnergySnapshot
5+
import com.evsuite.hardware.telemetry.EnergyTripSummary
6+
import com.evsuite.hardware.telemetry.Provenanced
7+
import com.evsuite.hardware.telemetry.UnavailableReason
8+
9+
/**
10+
* One frame of the dashboard, with every figure carrying what kind of claim it is.
11+
*
12+
* The activity used to format nullable floats straight out of the snapshot, which worked
13+
* only because everything on screen happened to be a vehicle reading. It will not stay that
14+
* way: adaptive range, the climate share and the speed comparison are models, and they have
15+
* to arrive on screen visibly different from the state of charge. Mapping the snapshot into
16+
* [Provenanced] values here is what makes that difference impossible to forget later.
17+
*/
18+
data class DashboardReadings(
19+
val soc: Provenanced<Float>,
20+
val range: Provenanced<Float>,
21+
val speed: Provenanced<Float>,
22+
val power: Provenanced<Float>,
23+
val outsideTemp: Provenanced<Float>,
24+
val batteryTemp: Provenanced<Float>,
25+
val tripDuration: Provenanced<Long>,
26+
val tripDistance: Provenanced<Double>,
27+
val tripEnergy: Provenanced<Double>,
28+
val tripRegen: Provenanced<Double>,
29+
val tripConsumption: Provenanced<Double>,
30+
) {
31+
companion object {
32+
/** Nothing read yet: every field unavailable, none of them zero. */
33+
fun empty(): DashboardReadings {
34+
fun <T : Any> gap() = Provenanced.unavailable<T>(UnavailableReason.SIGNAL_ABSENT)
35+
return DashboardReadings(
36+
soc = gap(), range = gap(), speed = gap(), power = gap(),
37+
outsideTemp = gap(), batteryTemp = gap(),
38+
tripDuration = gap(), tripDistance = gap(), tripEnergy = gap(),
39+
tripRegen = gap(), tripConsumption = gap(),
40+
)
41+
}
42+
43+
fun of(snapshot: EnergySnapshot, trip: EnergyTripSummary?): DashboardReadings {
44+
// An unrecognised generation is not a car that stopped publishing: it is a car
45+
// this build was never taught to read. The screen says which.
46+
val absent = if (snapshot.firmware == FirmwareInfo.Gen.UNKNOWN) {
47+
UnavailableReason.UNSUPPORTED_FIRMWARE
48+
} else {
49+
UnavailableReason.SIGNAL_ABSENT
50+
}
51+
return DashboardReadings(
52+
soc = Provenanced.measured(snapshot.socPercent, absent),
53+
range = Provenanced.measured(snapshot.rangeKm, absent),
54+
speed = Provenanced.measured(snapshot.speedKmh, absent),
55+
power = Provenanced.measured(snapshot.batteryPowerKw, absent),
56+
outsideTemp = Provenanced.measured(snapshot.outsideTempCelsius, absent),
57+
batteryTemp = Provenanced.measured(snapshot.batteryTempCelsius, absent),
58+
// The trip figures are arithmetic over those readings, so they are derived,
59+
// and they are missing for a different reason: no trip is being recorded, or
60+
// no usable interval has accumulated yet.
61+
tripDuration = Provenanced.derived(trip?.durationMs),
62+
tripDistance = Provenanced.derived(trip?.distanceKm),
63+
tripEnergy = Provenanced.derived(trip?.consumedKwh),
64+
tripRegen = Provenanced.derived(trip?.regeneratedKwh),
65+
tripConsumption = Provenanced.derived(trip?.averageConsumptionKwhPer100Km),
66+
)
67+
}
68+
}
69+
}

‎app/src/main/java/com/evsuite/chargepilot/MainActivity.kt‎

Lines changed: 78 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.evsuite.chargepilot
22

33
import android.os.Bundle
4+
import android.widget.TextView
45
import androidx.appcompat.app.AlertDialog
56
import androidx.appcompat.app.AppCompatActivity
67
import com.evsuite.chargepilot.databinding.ActivityMainBinding
@@ -12,7 +13,7 @@ import com.evsuite.hardware.telemetry.EnergySnapshot
1213
import com.evsuite.hardware.telemetry.EnergyTelemetryReader
1314
import com.evsuite.hardware.telemetry.EnergyTripHistoryStore
1415
import com.evsuite.hardware.telemetry.EnergyTripSession
15-
import com.evsuite.hardware.telemetry.EnergyTripSummary
16+
import com.evsuite.hardware.telemetry.Provenanced
1617
import java.io.File
1718
import java.util.Locale
1819
import java.util.concurrent.Executors
@@ -24,18 +25,22 @@ class MainActivity : AppCompatActivity() {
2425
private lateinit var binding: ActivityMainBinding
2526
private lateinit var reader: EnergyTelemetryReader
2627
private lateinit var tripStore: EnergyTripHistoryStore
28+
private lateinit var provenance: ProvenanceText
2729
private val sampler = Executors.newSingleThreadScheduledExecutor { runnable ->
2830
Thread(runnable, "chargepilot-sampler")
2931
}
3032
private var samplingTask: ScheduledFuture<*>? = null
3133
/** Written by the sampler thread, read by the main thread on a control tap. */
3234
@Volatile private var latest: EnergySnapshot? = null
35+
/** The last frame drawn, so the diagnostics report can explain what the screen shows. */
36+
@Volatile private var latestReadings: DashboardReadings = DashboardReadings.empty()
3337

3438
override fun onCreate(savedInstanceState: Bundle?) {
3539
super.onCreate(savedInstanceState)
3640
binding = ActivityMainBinding.inflate(layoutInflater)
3741
setContentView(binding.root)
3842

43+
provenance = ProvenanceText(this)
3944
reader = EnergyTelemetryReader(applicationContext)
4045
tripStore = EnergyTripHistoryStore(File(filesDir, "trips.json"))
4146
binding.tripAction.setOnClickListener { toggleTrip() }
@@ -82,12 +87,9 @@ class MainActivity : AppCompatActivity() {
8287
}
8388

8489
private fun render(value: EnergySnapshot) {
85-
binding.socValue.text = percent(value.socPercent)
86-
binding.rangeValue.text = distance(value.rangeKm?.toDouble())
87-
binding.speedValue.text = speed(value.speedKmh)
88-
binding.powerValue.text = power(value.batteryPowerKw)
89-
binding.outsideTempValue.text = temperature(value.outsideTempCelsius)
90-
binding.batteryTempValue.text = temperature(value.batteryTempCelsius)
90+
val readings = DashboardReadings.of(value, EnergyTripSession.current(value.timestampMs))
91+
latestReadings = readings
92+
renderReadings(readings)
9193
binding.climateValue.text = climate(value)
9294

9395
val hasVehicleData = value.hasVehicleData
@@ -96,9 +98,6 @@ class MainActivity : AppCompatActivity() {
9698
)
9799
binding.dataStatus.setTextColor(getColor(if (hasVehicleData) R.color.ev_ok else R.color.ev_warn))
98100

99-
val trip = EnergyTripSession.current(value.timestampMs)
100-
renderTrip(trip)
101-
102101
// Recording controls are for a parked driver. Live values remain passive and readable
103102
// while moving; the app never presents an overlay or asks for attention.
104103
val parked = value.speedKmh?.let { it <= 0.1f } == true
@@ -114,24 +113,49 @@ class MainActivity : AppCompatActivity() {
114113
}
115114
}
116115

117-
private fun renderTrip(summary: EnergyTripSummary?) {
118-
binding.tripDurationValue.text = summary?.durationMs?.let(::duration) ?: DASH
119-
binding.tripDistanceValue.text = summary?.distanceKm?.let(::distance) ?: DASH
120-
binding.tripEnergyValue.text = energy(summary?.consumedKwh)
121-
binding.tripRegenValue.text = energy(summary?.regeneratedKwh)
122-
binding.tripConsumptionValue.text = consumption(summary?.averageConsumptionKwhPer100Km)
116+
private fun renderReadings(readings: DashboardReadings) {
117+
bind(binding.socValue, R.string.label_soc, readings.soc, PATTERN_SOC, SOC_UNAVAILABLE)
118+
bind(binding.rangeValue, R.string.label_range, readings.range, PATTERN_DISTANCE)
119+
bind(binding.speedValue, R.string.label_speed, readings.speed, PATTERN_SPEED)
120+
bind(binding.powerValue, R.string.label_power, readings.power, PATTERN_POWER, POWER_UNAVAILABLE)
121+
bind(binding.outsideTempValue, R.string.label_outside_temp, readings.outsideTemp, PATTERN_TEMP)
122+
bind(binding.batteryTempValue, R.string.label_battery_temp, readings.batteryTemp, PATTERN_TEMP)
123+
bind(binding.tripDistanceValue, R.string.label_distance, readings.tripDistance, PATTERN_DISTANCE)
124+
bind(binding.tripEnergyValue, R.string.label_energy_used, readings.tripEnergy, PATTERN_ENERGY)
125+
bind(binding.tripRegenValue, R.string.label_regenerated, readings.tripRegen, PATTERN_ENERGY)
126+
bind(
127+
binding.tripConsumptionValue, R.string.label_consumption,
128+
readings.tripConsumption, PATTERN_CONSUMPTION,
129+
)
130+
val recorded = provenance.renderWith(readings.tripDuration, transform = ::duration)
131+
binding.tripDurationValue.text = recorded
132+
binding.tripDurationValue.contentDescription =
133+
provenance.describe(getString(R.string.label_duration), readings.tripDuration, recorded)
134+
}
135+
136+
/**
137+
* A reading and its description move together: the visible text says what the value is,
138+
* the description says what kind of claim it is and, when it is missing, why. Colour is
139+
* deliberately not carrying either — it would say nothing to a screen reader and nothing
140+
* in daylight.
141+
*/
142+
private fun bind(
143+
view: TextView,
144+
label: Int,
145+
value: Provenanced<out Any>,
146+
pattern: String,
147+
unavailable: String = DASH,
148+
) {
149+
val rendered = provenance.render(value, pattern, unavailable)
150+
view.text = rendered
151+
view.contentDescription = provenance.describe(getString(label), value, rendered)
123152
}
124153

125154
private fun renderUnavailable() {
126-
binding.socValue.text = getString(R.string.value_soc_unavailable)
127-
binding.rangeValue.text = DASH
128-
binding.speedValue.text = DASH
129-
binding.powerValue.text = getString(R.string.value_power_unavailable)
130-
binding.outsideTempValue.text = DASH
131-
binding.batteryTempValue.text = DASH
155+
latestReadings = DashboardReadings.empty()
156+
renderReadings(latestReadings)
132157
binding.climateValue.text = DASH
133158
binding.dataStatus.text = getString(R.string.status_waiting_for_vehicle)
134-
renderTrip(null)
135159
binding.tripAction.isEnabled = false
136160
binding.tripHint.text = getString(R.string.trip_control_speed_unavailable)
137161
}
@@ -174,20 +198,31 @@ class MainActivity : AppCompatActivity() {
174198
appendLine(getString(R.string.diagnostics_properties_hint))
175199
EVHardware.probeTelemetryProperties().forEach { appendLine(it.toString()) }
176200
appendLine()
201+
// The dashboard has room for an em dash and not for a sentence. The report is
202+
// where the sentence goes: per field, what kind of value it is and why it is
203+
// missing when it is.
204+
appendLine(getString(R.string.diagnostics_provenance))
205+
describeReadings(latestReadings).forEach { appendLine(it) }
206+
appendLine()
177207
appendLine(getString(R.string.diagnostics_recent_log))
178208
append(recentLog.ifBlank { getString(R.string.diagnostics_no_log) })
179209
}
180210
}
181211

182-
private fun percent(value: Float?) = value?.let { format("%.1f %%", it) }
183-
?: getString(R.string.value_soc_unavailable)
184-
private fun speed(value: Float?) = value?.let { format("%.0f km/h", it) } ?: DASH
185-
private fun power(value: Float?) = value?.let { format("%+.1f kW", it) }
186-
?: getString(R.string.value_power_unavailable)
187-
private fun temperature(value: Float?) = value?.let { format("%.0f °C", it) } ?: DASH
188-
private fun distance(value: Double?) = value?.let { format("%.1f km", it) } ?: DASH
189-
private fun energy(value: Double?) = value?.let { format("%.2f kWh", it) } ?: DASH
190-
private fun consumption(value: Double?) = value?.let { format("%.1f kWh/100 km", it) } ?: DASH
212+
private fun describeReadings(readings: DashboardReadings): List<String> = listOf(
213+
Triple(R.string.label_soc, readings.soc, PATTERN_SOC),
214+
Triple(R.string.label_range, readings.range, PATTERN_DISTANCE),
215+
Triple(R.string.label_speed, readings.speed, PATTERN_SPEED),
216+
Triple(R.string.label_power, readings.power, PATTERN_POWER),
217+
Triple(R.string.label_outside_temp, readings.outsideTemp, PATTERN_TEMP),
218+
Triple(R.string.label_battery_temp, readings.batteryTemp, PATTERN_TEMP),
219+
Triple(R.string.label_distance, readings.tripDistance, PATTERN_DISTANCE),
220+
Triple(R.string.label_energy_used, readings.tripEnergy, PATTERN_ENERGY),
221+
Triple(R.string.label_regenerated, readings.tripRegen, PATTERN_ENERGY),
222+
Triple(R.string.label_consumption, readings.tripConsumption, PATTERN_CONSUMPTION),
223+
).map { (label, value, pattern) ->
224+
provenance.describe(getString(label), value, provenance.render(value, pattern))
225+
}
191226

192227
private fun climate(value: EnergySnapshot): String = when (value.climate.powerOn) {
193228
true -> buildString {
@@ -209,5 +244,16 @@ class MainActivity : AppCompatActivity() {
209244
private companion object {
210245
const val TAG = "EVChargePilot"
211246
const val DASH = "—"
247+
// The unit-bearing fields keep their unit while unavailable so the layout does not
248+
// shift the moment the vehicle starts answering.
249+
const val SOC_UNAVAILABLE = "— %"
250+
const val POWER_UNAVAILABLE = "— kW"
251+
const val PATTERN_SOC = "%.1f %%"
252+
const val PATTERN_SPEED = "%.0f km/h"
253+
const val PATTERN_POWER = "%+.1f kW"
254+
const val PATTERN_TEMP = "%.0f °C"
255+
const val PATTERN_DISTANCE = "%.1f km"
256+
const val PATTERN_ENERGY = "%.2f kWh"
257+
const val PATTERN_CONSUMPTION = "%.1f kWh/100 km"
212258
}
213259
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
package com.evsuite.chargepilot
2+
3+
import android.content.Context
4+
import com.evsuite.hardware.telemetry.Provenance
5+
import com.evsuite.hardware.telemetry.Provenanced
6+
import com.evsuite.hardware.telemetry.UnavailableReason
7+
import java.util.Locale
8+
9+
/**
10+
* Draws a [Provenanced] value so its kind is visible without a legend.
11+
*
12+
* The distinction is carried by the text itself, not by colour: an estimate wears a `≈` and
13+
* its band, an unavailable figure is an em dash whose reason is spelled out in the reading's
14+
* description and in the diagnostics report. That keeps the daylight contrast floor intact
15+
* and keeps the meaning available to a screen reader, which a coloured dot would not.
16+
*/
17+
class ProvenanceText(private val context: Context) {
18+
19+
/**
20+
* @param pattern a `String.format` pattern carrying the unit, e.g. `"%.1f %%"`.
21+
* @param unavailable what stands in for a missing value; the unit-bearing fields keep
22+
* their unit so the layout does not shift when the vehicle starts answering.
23+
*/
24+
fun render(
25+
value: Provenanced<out Any>,
26+
pattern: String,
27+
unavailable: String = DASH,
28+
): String = when (value.provenance) {
29+
Provenance.UNAVAILABLE -> unavailable
30+
Provenance.MEASURED, Provenance.DERIVED -> format(pattern, value.value!!)
31+
Provenance.ESTIMATED -> context.getString(
32+
R.string.value_estimated,
33+
format(pattern, value.value!!),
34+
format(BAND_PATTERN, value.uncertainty!!),
35+
)
36+
}
37+
38+
/** Same rules, for a value whose text is not a `String.format` pattern (a duration). */
39+
fun <T : Any> renderWith(
40+
value: Provenanced<T>,
41+
unavailable: String = DASH,
42+
transform: (T) -> String,
43+
): String = value.value?.let(transform) ?: unavailable
44+
45+
/** What the reading is, and — when it is missing — why. Used for accessibility and diagnostics. */
46+
fun describe(label: String, value: Provenanced<out Any>, rendered: String): String {
47+
val kind = context.getString(
48+
when (value.provenance) {
49+
Provenance.MEASURED -> R.string.provenance_measured
50+
Provenance.DERIVED -> R.string.provenance_derived
51+
Provenance.ESTIMATED -> R.string.provenance_estimated
52+
Provenance.UNAVAILABLE -> R.string.provenance_unavailable
53+
}
54+
)
55+
val reason = value.reason?.let { context.getString(reasonRes(it)) }
56+
return if (reason == null) {
57+
context.getString(R.string.provenance_description, label, rendered, kind)
58+
} else {
59+
context.getString(R.string.provenance_description_missing, label, kind, reason)
60+
}
61+
}
62+
63+
private fun reasonRes(reason: UnavailableReason): Int = when (reason) {
64+
UnavailableReason.UNSUPPORTED_FIRMWARE -> R.string.reason_unsupported_firmware
65+
UnavailableReason.UNVALIDATED_FIRMWARE -> R.string.reason_unvalidated_firmware
66+
UnavailableReason.SIGNAL_ABSENT -> R.string.reason_signal_absent
67+
UnavailableReason.INSUFFICIENT_SAMPLES -> R.string.reason_insufficient_samples
68+
UnavailableReason.MODEL_NOT_TRAINED -> R.string.reason_model_not_trained
69+
}
70+
71+
private fun format(pattern: String, value: Any): String =
72+
String.format(Locale.getDefault(), pattern, value)
73+
74+
private companion object {
75+
const val DASH = "—"
76+
const val BAND_PATTERN = "%.1f"
77+
}
78+
}

‎app/src/main/res/values-fr/strings.xml‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,5 +38,18 @@
3838
<string name="diagnostics_no_log">Aucune entrée pour le moment.</string>
3939
<string name="diagnostics_properties">Propriétés énergie du véhicule</string>
4040
<string name="diagnostics_properties_hint">Ce que le véhicule répond pour chaque propriété en lecture seule. Une propriété déclarée par le firmware mais jamais publiée est signalée indisponible et affichée par un tiret cadratin sur le tableau de bord, jamais par zéro.</string>
41+
<string name="provenance_measured">mesuré par le véhicule</string>
42+
<string name="provenance_derived">calculé à partir des mesures véhicule</string>
43+
<string name="provenance_estimated">estimé par un modèle</string>
44+
<string name="provenance_unavailable">indisponible</string>
45+
<string name="provenance_description">%1$s : %2$s, %3$s</string>
46+
<string name="provenance_description_missing">%1$s : %2$s, %3$s</string>
47+
<string name="reason_unsupported_firmware">cette génération de firmware n\'expose pas le signal</string>
48+
<string name="reason_unvalidated_firmware">le signal n\'est pas encore validé sur ce firmware</string>
49+
<string name="reason_signal_absent">le véhicule ne publie pas ce signal actuellement</string>
50+
<string name="reason_insufficient_samples">pas encore assez d\'échantillons</string>
51+
<string name="reason_model_not_trained">le modèle n\'a aucun ajustement pour prédire</string>
52+
<string name="diagnostics_provenance">Ce que vaut chaque valeur, et pourquoi elle manque</string>
53+
<string name="label_soc">État de charge</string>
4154
</resources>
4255

0 commit comments

Comments
 (0)