Skip to content

Commit ea2e855

Browse files
malysclaude
andcommitted
feat(diagnostics): record probes for the whole session, export them together
Validation had to be driven by hand: open a screen, arm a probe, remember a save button, then export. That is the wrong default while the car is the instrument. Evidence is collected on a drive, when nobody should be operating a screen, and the most decision-relevant result — a probe that heard nothing — is the one a driver is least likely to think worth saving. The guidance listener and a new session-long signal recorder now arm when the application starts, and the export writes every probe artifact itself. One "Export to USB" carries the guidance trace with its transaction census, the signal statistics for the whole session, and the recorded trip summaries. NavGuidanceRecorder owns the trace at process scope, so the probe screen is a window onto it rather than its owner and closing it no longer ends a capture. Arming is deliberately not parked-gated: it registers a read-only listener with no UI, and refusing because the car was moving when the app started would lose the drive the evidence is being collected on. Trip summaries travel because trips.json never left the car, so a wrong distance could only be reported second-hand. Distance over duration gives the average speed the integration believed in, which is what the speed-unit question turns on. Cost: one binder registration, and two 1 Hz ticks reading volatiles and vehicle properties — the cadence the recording service already uses. Both recorders keep constant memory; the trace ring is bounded and nothing is written to disk until an export asks. The artifact pool grew from eight files to twelve because an export now writes three of them. Stable arms nothing, writes nothing, and contains none of these classes. Refs: CP-040 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 7ff705a commit ea2e855

11 files changed

Lines changed: 393 additions & 89 deletions

File tree

‎CHANGELOG.md‎

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

55
## [Unreleased]
66

7+
### Added
8+
9+
- **The unstable channel records its own evidence and the export carries all of it.** Validation
10+
had to be driven by hand: open a screen, arm a probe, remember a save button, then export —
11+
and the run that mattered most, the one where a probe heard nothing, was the one nobody
12+
thought worth saving. The guidance listener and a session-long signal recorder now arm when
13+
the application starts, and one "Export to USB" writes the guidance trace, the signal
14+
statistics and the recorded trip summaries into the bundle by itself. The probe screen became
15+
a window onto the recorder rather than its owner, so closing it no longer ends a capture.
16+
Trip summaries travel because a distance integrated from a wrong speed scale shows up there
17+
first. Stable arms nothing and contains none of it.
18+
719
### Fixed
820

921
- **The dashboard now asks for the vehicle signals it already declared.** AAOS classifies

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ class ChargePilotApp : Application() {
77
override fun onCreate() {
88
super.onCreate()
99
CrashLogger.install(this, "EVChargePilot")
10+
// Unstable arms the validation probes here so a drive is recorded without anyone
11+
// opening a screen first; stable does nothing, and contains none of this.
12+
EvidenceCaptureHook.startProbes(this)
1013
}
1114
}
1215

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -513,6 +513,10 @@ class MainActivity : AppCompatActivity() {
513513
// volatile latest sample at the last responsible moment so parking cannot go stale.
514514
val decision = diagnosticExportDecision(service?.latest)
515515
val file = if (decision == DiagnosticExportPolicy.Decision.ALLOWED) {
516+
// Every always-on probe writes its current artifact first, so one export
517+
// carries the guidance trace, the signal statistics and the trip record
518+
// without anyone having remembered to save them during the drive.
519+
EvidenceCaptureHook.saveProbeArtifacts(appContext)
516520
// Rebuild after USB selection so timestamps, logs and last sample match export.
517521
DiagnosticExporter.export(appContext, diagnosticsReport(), directory)
518522
} else null
Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,25 @@
11
package com.evsuite.chargepilot
22

33
import android.app.Activity
4+
import android.content.Context
45

56
/**
67
* Stable channel: no evidence capture, by construction.
78
*
89
* The capture screen is a development instrument — it samples faster than the dashboard, writes
910
* files a driver has no use for, and exists to answer questions about firmware behaviour. It is
1011
* not disabled here; it is not in the APK. Nothing in a stable build can be persuaded to start
11-
* recording property statistics.
12+
* recording property statistics, or to register a listener on a head-unit service.
1213
*/
1314
object EvidenceCaptureHook {
1415
const val IS_SUPPORTED = false
1516

1617
/** Does nothing. */
1718
fun open(activity: Activity) = Unit
19+
20+
/** Does nothing. Stable arms no probe. */
21+
fun startProbes(context: Context) = Unit
22+
23+
/** Does nothing. Stable has no probe artifact to write. */
24+
fun saveProbeArtifacts(context: Context) = Unit
1825
}

‎app/src/testUnstable/java/com/evsuite/chargepilot/EvidenceCaptureFileStoreTest.kt‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,10 @@ class EvidenceCaptureFileStoreTest {
6161
.mapNotNull { TelemetryEvidenceFormat.fromJson(it.readText())?.snapshots }
6262
.toSet()
6363
assertEquals(EvidenceCaptureFileStore.MAX_CAPTURE_FILES, retainedSnapshots.size)
64-
assertEquals((3..10).toSet(), retainedSnapshots)
64+
// The two oldest of the writes above are evicted; the rest survive, whatever the
65+
// pool size happens to be.
66+
val newest = EvidenceCaptureFileStore.MAX_CAPTURE_FILES + 2
67+
assertEquals((3..newest).toSet(), retainedSnapshots)
6568
} finally {
6669
directory.deleteRecursively()
6770
}

‎app/src/unstable/java/com/evsuite/chargepilot/EvidenceCaptureFileStore.kt‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,11 @@ internal class EvidenceCaptureFileStore(
7878
}
7979

8080
companion object {
81-
internal const val MAX_CAPTURE_FILES = 8
81+
/**
82+
* One export now writes three artifacts — guidance, signal statistics and trips — so the
83+
* pool holds four full exports rather than eight of a single kind.
84+
*/
85+
internal const val MAX_CAPTURE_FILES = 12
8286
internal const val CAPTURE_KIND = "evidence"
8387
private val CAPTURE_NAME = Regex("^(.*-\\d{8}-\\d{6}-\\d{3})(?:-(\\d+))?$")
8488
}
Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,58 @@
11
package com.evsuite.chargepilot
22

33
import android.app.Activity
4-
import android.content.Intent
4+
import android.content.Context
5+
import com.evsuite.hardware.FirmwareInfo
6+
import java.io.File
57

68
/** Unstable channel: the capture screen exists and is reachable from the dashboard. */
79
object EvidenceCaptureHook {
810
const val IS_SUPPORTED = true
911

1012
fun open(activity: Activity) {
11-
activity.startActivity(Intent(activity, EvidenceCaptureActivity::class.java))
13+
activity.startActivity(
14+
android.content.Intent(activity, EvidenceCaptureActivity::class.java)
15+
)
16+
}
17+
18+
/**
19+
* Arms the always-on probes when the application starts.
20+
*
21+
* During validation the guidance listener has to be running before the drive begins, not
22+
* after somebody remembers to open a screen. It costs one binder registration and a 1 Hz
23+
* tick, and it is read-only.
24+
*/
25+
fun startProbes(context: Context) {
26+
NavGuidanceRecorder.start(context)
27+
SignalEvidenceRecorder.start(context)
28+
}
29+
30+
/**
31+
* Writes every always-on probe artifact into the folder the diagnostic export bundles.
32+
*
33+
* Called on the export path so one "Export to USB" always carries the current state of
34+
* every probe, including the states nobody would think to save by hand: an adapter that
35+
* never bound, or a listener that was registered all drive and heard nothing.
36+
*/
37+
fun saveProbeArtifacts(context: Context) {
38+
// A late arm is better than none: the app may have started before the vehicle
39+
// services were up, and an export is a good moment to find out.
40+
NavGuidanceRecorder.start(context)
41+
SignalEvidenceRecorder.start(context)
42+
val firmware = FirmwareInfo.getGeneration().name
43+
val store = EvidenceCaptureFileStore(
44+
File(context.filesDir, NavGuidanceRecorder.EVIDENCE_DIRECTORY)
45+
)
46+
store.write(
47+
NavGuidanceRecorder.artifact().toJson(),
48+
NavGuidanceProbeArtifact.KIND,
49+
firmware,
50+
)
51+
store.write(SignalEvidenceRecorder.capture())
52+
store.write(
53+
TripHistoryArtifact.of(context).toJson(),
54+
TripHistoryArtifact.KIND,
55+
firmware,
56+
)
1257
}
1358
}

‎app/src/unstable/java/com/evsuite/chargepilot/NavGuidanceProbeActivity.kt‎

Lines changed: 16 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,11 @@ package com.evsuite.chargepilot
33
import android.os.Bundle
44
import android.os.Handler
55
import android.os.Looper
6-
import android.os.SystemClock
76
import androidx.appcompat.app.AppCompatActivity
87
import com.evsuite.chargepilot.databinding.ActivityNavGuidanceProbeBinding
9-
import com.evsuite.hardware.FirmwareInfo
10-
import com.evsuite.hardware.saic.NavGuidance
118
import com.evsuite.hardware.saic.SaicNavGuidance
129
import com.evsuite.hardware.telemetry.EnergyTelemetryReader
1310
import com.google.android.material.snackbar.Snackbar
14-
import java.io.File
15-
import java.util.ArrayDeque
16-
import java.util.Locale
1711

1812
/**
1913
* Records what the head unit's navigation actually reports during a drive.
@@ -36,22 +30,16 @@ class NavGuidanceProbeActivity : AppCompatActivity() {
3630

3731
private lateinit var binding: ActivityNavGuidanceProbeBinding
3832
private lateinit var reader: EnergyTelemetryReader
39-
private lateinit var fileStore: EvidenceCaptureFileStore
4033

4134
private val ticker = Handler(Looper.getMainLooper())
42-
private val trace = ArrayDeque<String>()
43-
private var lastEvents = -1
4435
private var visible = false
45-
private var startedAtElapsedMs: Long? = null
46-
private var dropped = false
4736

4837
override fun onCreate(savedInstanceState: Bundle?) {
4938
super.onCreate(savedInstanceState)
5039
binding = ActivityNavGuidanceProbeBinding.inflate(layoutInflater)
5140
setContentView(binding.root)
5241
reader = EnergyTelemetryReader(applicationContext)
53-
fileStore = EvidenceCaptureFileStore(File(filesDir, EVIDENCE_DIRECTORY))
54-
SaicNavGuidance.connect(applicationContext)
42+
NavGuidanceRecorder.start(applicationContext)
5543
binding.probeAction.setOnClickListener { toggle() }
5644
binding.saveAction.setOnClickListener { saveTrace() }
5745
ticker.post(tick)
@@ -69,57 +57,27 @@ class NavGuidanceProbeActivity : AppCompatActivity() {
6957
super.onStop()
7058
}
7159

60+
/**
61+
* Leaves the recorder running. It belongs to the process now, so closing this screen
62+
* neither unregisters the listener nor discards the trace — which is the whole reason a
63+
* drive no longer has to be spent with a diagnostic screen in front of the driver.
64+
*/
7265
override fun onDestroy() {
7366
ticker.removeCallbacks(tick)
74-
SaicNavGuidance.stop()
7567
super.onDestroy()
7668
}
7769

7870
private val tick = object : Runnable {
7971
override fun run() {
80-
record(SaicNavGuidance.latest())
8172
if (visible) render()
8273
ticker.postDelayed(this, TICK_MS)
8374
}
8475
}
8576

86-
/**
87-
* Appends a line only when the listener actually saw something new.
88-
*
89-
* A drive that produces no line is the finding, not a failure of this screen — which is
90-
* why the trace is never padded with repeats of an unchanged state.
91-
*/
92-
private fun record(guidance: NavGuidance) {
93-
if (guidance.events == lastEvents) return
94-
lastEvents = guidance.events
95-
if (guidance.events == 0) return
96-
val since = startedAtElapsedMs?.let { (SystemClock.elapsedRealtime() - it) / 1_000L } ?: 0L
97-
if (trace.size >= TRACE_LINES) {
98-
trace.removeFirst()
99-
dropped = true
100-
}
101-
trace.addLast(
102-
String.format(
103-
Locale.ROOT,
104-
"%5ds n=%-4d status=%-4s dist=%-8s min=%-6s turn=%-8s road=%s",
105-
since, guidance.events,
106-
guidance.guideStatus.show(),
107-
guidance.remainingDistanceRaw.show(),
108-
guidance.remainingMinutes.show(),
109-
guidance.nextTurnDistanceRaw.show(),
110-
// A road name is head-unit text of unknown length; the saved artifact has to
111-
// stay under the export's per-file ceiling whatever it contains.
112-
(guidance.road ?: guidance.direction ?: DASH).take(ROAD_CHARS),
113-
)
114-
)
115-
}
116-
11777
/** Rechecks speed at the moment of the press; a disabled button is not a safety boundary. */
11878
private fun toggle() {
119-
if (SaicNavGuidance.isListening) {
120-
SaicNavGuidance.stop()
121-
startedAtElapsedMs = null
122-
lastEvents = -1
79+
if (NavGuidanceRecorder.isRunning) {
80+
NavGuidanceRecorder.stop()
12381
render()
12482
return
12583
}
@@ -128,38 +86,19 @@ class NavGuidanceProbeActivity : AppCompatActivity() {
12886
Snackbar.make(binding.root, R.string.nav_probe_refused, Snackbar.LENGTH_LONG).show()
12987
return
13088
}
131-
if (SaicNavGuidance.start()) {
132-
startedAtElapsedMs = SystemClock.elapsedRealtime()
133-
trace.clear()
134-
dropped = false
135-
lastEvents = -1
136-
}
89+
NavGuidanceRecorder.start(applicationContext)
90+
NavGuidanceRecorder.clear()
13791
render()
13892
}
13993

14094
/**
141-
* Writes the trace where the diagnostic export finds it.
95+
* Saves now, rather than waiting for the next export to do it.
14296
*
143-
* The clipboard was never a way off this head unit: the file goes into the evidence
144-
* folder, and the dashboard's "Export to USB" carries it out with the rest of the bundle.
145-
* The write is a bounded JSON file on internal storage, so it stays on the click.
97+
* The export writes this artifact on its own, so this button is a convenience for a
98+
* driver who wants the current state pinned before carrying on.
14699
*/
147100
private fun saveTrace() {
148-
val saved = fileStore.write(
149-
NavGuidanceProbeArtifact.of(
150-
savedAtMs = System.currentTimeMillis(),
151-
firmware = FirmwareInfo.getGeneration().name,
152-
adapterBound = SaicNavGuidance.isAvailable,
153-
listenerRegistered = SaicNavGuidance.isListening,
154-
callbacks = SaicNavGuidance.latest().events,
155-
census = SaicNavGuidance.census(),
156-
censusBeyondCeiling = SaicNavGuidance.censusBeyondCeiling(),
157-
trace = trace.toList(),
158-
traceComplete = !dropped,
159-
).toJson(),
160-
NavGuidanceProbeArtifact.KIND,
161-
FirmwareInfo.getGeneration().name,
162-
)
101+
val saved = NavGuidanceRecorder.save(applicationContext)
163102
Snackbar.make(
164103
binding.root,
165104
if (saved == null) R.string.nav_probe_save_failed else R.string.nav_probe_saved,
@@ -169,7 +108,7 @@ class NavGuidanceProbeActivity : AppCompatActivity() {
169108

170109
private fun render() {
171110
val guidance = SaicNavGuidance.latest()
172-
val listening = SaicNavGuidance.isListening
111+
val listening = NavGuidanceRecorder.isRunning
173112
binding.probeAction.setText(
174113
if (listening) R.string.nav_probe_stop else R.string.nav_probe_start
175114
)
@@ -188,6 +127,7 @@ class NavGuidanceProbeActivity : AppCompatActivity() {
188127
else -> R.string.nav_probe_hint_ready
189128
}
190129
)
130+
val trace = NavGuidanceRecorder.trace()
191131
binding.probeTrace.text = if (trace.isEmpty()) {
192132
getString(R.string.nav_probe_no_data)
193133
} else {
@@ -200,15 +140,7 @@ class NavGuidanceProbeActivity : AppCompatActivity() {
200140
binding.saveAction.isEnabled = true
201141
}
202142

203-
private fun Int?.show(): String = this?.toString() ?: DASH
204-
205143
private companion object {
206144
const val TICK_MS = 1_000L
207-
/** Three hours of change at one line per second would not fit; the newest lines win. */
208-
const val TRACE_LINES = 300
209-
/** A road name past this adds nothing a decision uses, and the file has a ceiling. */
210-
const val ROAD_CHARS = 40
211-
const val DASH = "—"
212-
const val EVIDENCE_DIRECTORY = "evidence"
213145
}
214146
}

0 commit comments

Comments
 (0)