Skip to content

Commit 3d7f849

Browse files
malysclaude
andcommitted
feat(diagnostics): read-only evidence probes for the open on-car tickets
The 2026-10-13 triage of CP-055, CP-078-081, CP-083, CP-086, CP-087 and CP-090 found the same gap each time: the bundle cannot say what the app did between two exports, or what became of a voice line or a window. These probes record it without reading anything new from the car. - Monitor journal (unstable, CP-078/079/080): a bounded, atomically written monitor-journal.json survives process deaths, which the in-memory validation rings cannot. Process start, boot, monitor start/suspend/stop/destroy, trip end, ledger entries and page reloads, each with wall clock, uptime, odometer and SOC. Exported as Q11 inside the existing validation file so the 8-file bundle gains nothing. Stable gets a no-op, as for every other probe. - CP-055 Q1 now fires on isMapNavigating, the guidance signal proven on the car, instead of the recorder's flag that never turned true. - EcoVoice keeps its last 20 outcomes (origin, path, bound, wait, process importance) for [eco_driving]: the vendor prompt has no completion callback, so this is the only record a line was dispatched. - [eco_trip_review] always prints the windows inputs, so "never open" and "open but not distinguishable" read apart (CP-086). - AdviceEntry.notifiedAtMs (nullable, no schema bump: older files read unchanged) lets a bundle prove notifications stayed out of trips. - Trips artifact schema 3 carries DrivingStress per tracked trip, to show the measurement returns figures on real tracks (CP-090). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent e00ce16 commit 3d7f849

25 files changed

Lines changed: 469 additions & 26 deletions

‎CHANGELOG.md‎

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

77
### Added
88

9+
- **Read-only evidence probes for the open on-car tickets (CP-055, CP-078–CP-081, CP-083,
10+
CP-086, CP-087, CP-090).** Nothing new is read from or written to the car.
11+
- *Monitor journal (unstable, CP-078/079/080).* A persistent, bounded (200 lines), atomically
12+
written `monitor-journal.json` records process start, boot received (with detection on/off),
13+
monitor start/suspension/self-stop/destroy, every trip end (automatic, km, stored, history
14+
revision), every battery ledger entry, and dashboard / battery page reloads, each with
15+
wall clock, uptime, odometer and charge. It reaches the existing validation file as block
16+
Q11; the bundle gains no file.
17+
- *Q1 on the proven guidance signal (CP-055).* When `isMapNavigating` turns true the
18+
destination probe fires, instead of waiting for a guidance flag never seen on the car.
19+
- *Voice outcomes (CP-081/083).* The last 20 voice dispatches (origin, voice path, bind state,
20+
wait, process importance) are listed as `voice_event=` in the diagnostic `[eco_driving]`.
21+
- *Windows factor (CP-086).* `[eco_trip_review]` always prints the newest trip's open-window
22+
samples, open distance above 60 km/h, window residual and whether it was distinguishable.
23+
- *Notification time (CP-087).* Advice history entries record when their notification was
24+
posted (`notified_at_ms=` in `[battery_advice]`); older files read unchanged.
25+
- *Driving stress per trip (unstable, CP-090).* The trips artifact (schema 3) carries
26+
`drivingStress` for each exported trip with a track.
927
- **Energy page warms when driving is not smooth.** With eco advice on, the page background
1028
between the cards blends gradually towards the warning colour as the harsh-acceleration share
1129
rises: none up to 6 %, full at 24 % (the advice mark is 12 %). Eased over a few seconds,

‎app/proguard-rules.pro‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,9 @@
5555
-keep class com.evsuite.hardware.telemetry.BatteryLedgerEntry { *; }
5656
-keep class com.evsuite.hardware.telemetry.BatteryLedgerFile { *; }
5757
-keep class com.evsuite.hardware.telemetry.BatteryLedgerStore$LedgerEnvelope { *; }
58+
59+
# The monitor journal (CP-080) and the trip artifact's stress rows (CP-090), kept for the
60+
# same reasons as the stores and artifacts above: persisted by Gson, read by a person.
61+
-keep class com.evsuite.chargepilot.MonitorJournal$Entry { *; }
62+
-keep class com.evsuite.chargepilot.MonitorJournal$Envelope { *; }
63+
-keep class com.evsuite.chargepilot.TripHistoryArtifact$TripStress { *; }

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

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ data class AdviceEntry(
1818
val kind: AdviceKind,
1919
val text: String,
2020
val notified: Boolean = false,
21+
/**
22+
* When the notification for this entry was posted (CP-087). Null for an entry that was not
23+
* posted — held back by the daily cap, or not yet flushed — and for every entry written
24+
* before this field existed, which is why it needs no schema bump.
25+
*/
26+
val notifiedAtMs: Long? = null,
2127
)
2228

2329
/**
@@ -49,11 +55,27 @@ class AdviceJournal(
4955
@Synchronized
5056
fun append(entry: AdviceEntry): Boolean = write((read() + entry).takeLast(maxEntries))
5157

52-
/** Marks the given entries as notified; the rest stay as they are. */
58+
/**
59+
* Marks the given entries as notified, and stamps [nowMs] on those that were [posted]; the
60+
* rest stay as they are.
61+
*/
5362
@Synchronized
54-
fun markNotified(entries: Collection<AdviceEntry>): Boolean {
63+
fun markNotified(
64+
entries: Collection<AdviceEntry>,
65+
posted: Collection<AdviceEntry> = emptyList(),
66+
nowMs: Long? = null,
67+
): Boolean {
5568
val marked = entries.toSet()
56-
return write(read().map { if (it in marked) it.copy(notified = true) else it })
69+
val stamped = posted.toSet()
70+
return write(
71+
read().map {
72+
when (it) {
73+
in stamped -> it.copy(notified = true, notifiedAtMs = nowMs)
74+
in marked -> it.copy(notified = true)
75+
else -> it
76+
}
77+
}
78+
)
5779
}
5880

5981
private fun write(entries: List<AdviceEntry>): Boolean {

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,11 @@ internal class BatteryAdvisor(
220220
.filterKeys { it !in spent }
221221
.map { (kind, list) -> post(manager, kind, list.last()); list.last() }
222222
// What the cap held back is still in the history; it is not queued for tomorrow.
223-
journal.markNotified(waiting.filter { entry -> posted.any { it.kind == entry.kind } } + posted)
223+
journal.markNotified(
224+
waiting.filter { entry -> posted.any { it.kind == entry.kind } } + posted,
225+
posted,
226+
nowMs,
227+
)
224228
}
225229

226230
// The head unit runs API 28, where no permission gates a notification. POST_NOTIFICATIONS is

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,9 @@ class BatteryHealthActivity : PrimaryNavigationActivity() {
8484
speedObservedAtMs = snapshot.timestampMs
8585
if (value.batteryRevision != loadedBatteryRevision) {
8686
loadedBatteryRevision = value.batteryRevision
87+
ValidationProbe.monitor(snapshot) {
88+
"battery page reloaded revision ${value.batteryRevision}"
89+
}
8790
load()
8891
}
8992
renderApply()

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ class ChargePilotApp : Application() {
1212
// Unstable arms the validation probes here so a drive is recorded without anyone
1313
// opening a screen first; stable does nothing, and contains none of this.
1414
EvidenceCaptureHook.startProbes(this)
15+
// CP-080: a small elapsedRealtime here is a fresh boot; a large one, a restarted process.
16+
ValidationProbe.monitor(null) { "process start" }
1517
// The vehicle voice binds asynchronously; asking now means the first coach line, or the
1618
// settings test, finds it bound. Idempotent, and a car without it just falls back.
1719
if (EcoCoach.voiceEnabled(this)) EcoVoice.connect(this)

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

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,9 @@ class DiagnosticsActivity : PrimaryNavigationActivity() {
298298
// CP-081: whether the SAIC voice service is bound. False with the voice on means
299299
// every line fell back to an Android engine the car does not have.
300300
appendLine("vehicle_voice_bound=${SaicTts.isAvailable}")
301+
// CP-081/083: the last outcomes, in memory since this process started. path=vehicle is
302+
// the car's voice service accepting the line, not proof it was heard.
303+
EcoVoice.recentEvents().forEach { appendLine(it.line()) }
301304
DashboardFrame.eco.describe().forEach(::appendLine)
302305
appendLine()
303306
// CP-075. The same verdict the trip screen shows for the newest drive, so a bundle
@@ -313,7 +316,12 @@ class DiagnosticsActivity : PrimaryNavigationActivity() {
313316
BatteryDigestStore(File(filesDir, BatteryDigestStore.FILE_NAME)).read().takeLast(7)
314317
.forEach { appendLine("digest=$it") }
315318
AdviceJournal.of(filesDir).read().takeLast(20)
316-
.forEach { appendLine("advice=${it.atMs} ${it.kind} notified=${it.notified} ${it.text}") }
319+
.forEach {
320+
appendLine(
321+
"advice=${it.atMs} ${it.kind} notified=${it.notified} " +
322+
"notified_at_ms=${it.notifiedAtMs} ${it.text}"
323+
)
324+
}
317325
appendLine()
318326
appendLine("[property_probe]")
319327
appendLine(getString(R.string.diagnostics_properties))
@@ -348,10 +356,17 @@ class DiagnosticsActivity : PrimaryNavigationActivity() {
348356
val trips = EnergyTripHistoryStore(File(filesDir, HISTORY_FILE)).read()
349357
val latest = trips.maxByOrNull { it.summary.startedAtMs }
350358
?: return listOf("no_trip_recorded")
351-
val review = reviewHistory(filesDir, trips).reviews[latest.summary.startedAtMs]
359+
val history = reviewHistory(filesDir, trips)
360+
val review = history.reviews[latest.summary.startedAtMs]
352361
?: return listOf("no_trip_recorded")
353362
listOf("started_at_epoch_ms=${latest.summary.startedAtMs}") +
354-
EcoTripReviewer.describe(review)
363+
EcoTripReviewer.describe(review) +
364+
// CP-086: whether or not a finding was made, so "never open" and "not
365+
// distinguishable" read apart.
366+
EcoTripReviewer.describeWindows(
367+
latest,
368+
history.attributions[latest.summary.startedAtMs],
369+
)
355370
}.getOrElse { listOf("unavailable: ${it.javaClass.simpleName}") }
356371

357372
/**

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ class EcoCoach(context: Context) {
133133
if (line == spokenLine || nowMs - lastSpokeAtMs < SPEAK_COOLDOWN_MS) return
134134
spokenLine = line
135135
lastSpokeAtMs = nowMs
136-
EcoVoice.say(app, line)
136+
EcoVoice.say(app, line, EcoVoice.Origin.COACH)
137137
}
138138

139139
/**

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,23 @@ object EcoTripReviewer {
239239
)
240240
}
241241

242+
/**
243+
* CP-086. The window-open inputs and residual as computed, finding or not. Null where the
244+
* attribution produced no window-open residual.
245+
*/
246+
fun describeWindows(trip: StoredTrip, attribution: EnergyAttributionResult?): List<String> {
247+
val residual = (attribution as? EnergyAttributionResult.Ready)?.attribution?.residuals
248+
?.firstOrNull { it.context == ResidualContext.WINDOWS_OPEN }
249+
val openSamples = trip.samples.orEmpty().count { (it.widestWindowPercent ?: 0) > 0 }
250+
val distinguishable = residual?.let { it.finding == ResidualFinding.DISTINGUISHABLE }
251+
return listOf(
252+
"windows_open_samples=$openSamples",
253+
"windows_open_fast_km=${format(openFastDistanceKm(trip))}",
254+
"windows_residual_kwh=${residual?.let { format(it.estimate.valueKwh) }}",
255+
"windows_distinguishable=$distinguishable",
256+
)
257+
}
258+
242259
/** Trapezoid distance over the intervals open and above the speed at both ends. */
243260
private fun openFastDistanceKm(trip: StoredTrip): Double =
244261
trip.samples.orEmpty().zipWithNext().sumOf { (previous, current) ->

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

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
package com.evsuite.chargepilot
22

3+
import android.app.ActivityManager
34
import android.content.Context
45
import android.media.AudioAttributes
56
import android.speech.tts.TextToSpeech
67
import android.speech.tts.UtteranceProgressListener
78
import com.evsuite.hardware.AppLogger
89
import com.evsuite.hardware.saic.SaicTts
10+
import java.util.ArrayDeque
911
import java.util.concurrent.CountDownLatch
1012
import java.util.concurrent.Executors
1113
import java.util.concurrent.TimeUnit
@@ -33,6 +35,45 @@ object EcoVoice {
3335
private const val INIT_TIMEOUT_SECONDS = 5L
3436
private const val SPEAK_TIMEOUT_SECONDS = 30L
3537

38+
/** CP-081/083: enough to cover a drive's coach lines and the test, bounded against a loop. */
39+
private const val MAX_EVENTS = 20
40+
41+
/** Who asked for a line. */
42+
enum class Origin { TEST, COACH, TRIP_END }
43+
44+
/** Which voice took it; [NONE] when neither did. */
45+
enum class Path { VEHICLE, PLATFORM, NONE }
46+
47+
/**
48+
* One dispatch and what became of it, for the diagnostic report.
49+
*
50+
* The vendor prompt has no completion callback, so [Path.VEHICLE] means the car's voice
51+
* service accepted the line, never that it was heard. [importance] is this process's own at
52+
* dispatch: above `IMPORTANCE_FOREGROUND` (100) the app was not in front.
53+
*/
54+
data class Event(
55+
val atMs: Long,
56+
val origin: Origin,
57+
val path: Path,
58+
val bound: Boolean,
59+
val waitedMs: Long,
60+
val importance: Int?,
61+
) {
62+
fun line(): String = "voice_event=$atMs origin=${origin.name.lowercase()} " +
63+
"path=${path.name.lowercase()} bound=$bound waited_ms=$waitedMs importance=$importance"
64+
}
65+
66+
/** In memory only, guarded by itself: the report reads it, the worker writes it. */
67+
private val events = ArrayDeque<Event>()
68+
69+
/** The newest [MAX_EVENTS] outcomes, oldest first. */
70+
fun recentEvents(): List<Event> = synchronized(events) { events.toList() }
71+
72+
private fun remember(event: Event) = synchronized(events) {
73+
if (events.size >= MAX_EVENTS) events.removeFirst()
74+
events.addLast(event)
75+
}
76+
3677
private val worker = Executors.newSingleThreadExecutor { runnable ->
3778
Thread(runnable, "chargepilot-voice").apply { isDaemon = true }
3879
}
@@ -46,12 +87,14 @@ object EcoVoice {
4687
/**
4788
* Speaks [text] and reports, on the worker thread, whether anything took it.
4889
*
90+
* @param origin who asked, recorded with the outcome.
4991
* @param interrupt true only for the driver's own test from the settings screen; a coach line
5092
* queues behind whatever the car is already announcing.
5193
*/
5294
fun say(
5395
context: Context,
5496
text: String,
97+
origin: Origin,
5598
interrupt: Boolean = false,
5699
onResult: ((Boolean) -> Unit)? = null,
57100
) {
@@ -60,17 +103,30 @@ object EcoVoice {
60103
return
61104
}
62105
val app = context.applicationContext
106+
val atMs = System.currentTimeMillis()
107+
val importance = runCatching {
108+
ActivityManager.RunningAppProcessInfo()
109+
.also { ActivityManager.getMyMemoryState(it) }.importance
110+
}.getOrNull()
63111
runCatching {
64112
worker.execute {
65-
val spoken = runCatching { speakBlocking(app, text, interrupt) }
113+
val outcome = runCatching { speakBlocking(app, text, interrupt) }
66114
.onFailure { AppLogger.w(TAG, "speech failed: ${it.message}") }
67-
.getOrDefault(false)
115+
.getOrNull()
116+
remember(
117+
Event(
118+
atMs, origin, outcome?.first ?: Path.NONE,
119+
SaicTts.isAvailable, outcome?.second ?: 0L, importance,
120+
)
121+
)
122+
val spoken = outcome != null && outcome.first != Path.NONE
68123
runCatching { onResult?.invoke(spoken) }
69124
}
70125
}.onFailure { onResult?.invoke(false) }
71126
}
72127

73-
private fun speakBlocking(app: Context, text: String, interrupt: Boolean): Boolean {
128+
/** The path that took the line, and how long the vendor bind was waited for. */
129+
private fun speakBlocking(app: Context, text: String, interrupt: Boolean): Pair<Path, Long> {
74130
connect(app)
75131
var waited = 0L
76132
while (!SaicTts.isAvailable && waited < BIND_WAIT_MS) {
@@ -79,9 +135,9 @@ object EcoVoice {
79135
}
80136
if (SaicTts.isAvailable && SaicTts.speak(text, interrupt, app.packageName)) {
81137
AppLogger.d(TAG, "spoken by the vehicle voice service")
82-
return true
138+
return Path.VEHICLE to waited
83139
}
84-
return speakWithPlatformEngine(app, text)
140+
return (if (speakWithPlatformEngine(app, text)) Path.PLATFORM else Path.NONE) to waited
85141
}
86142

87143
/**

0 commit comments

Comments
 (0)