Skip to content

Commit 8560884

Browse files
malysclaude
andcommitted
feat(diagnostics): carry every probe out on the USB bundle
The guidance probe kept its trace in memory and offered a copy button, so the only way off the head unit was a paste into some other app. On a read-only, offline unit that is not a retrieval path: nothing on the car consumes a clipboard, and a paste drops the census and the caveats the readings have to be judged against. Both probes now write bounded JSON into the evidence folder the diagnostic export already bundles, so one "Export to USB" carries the signal captures and the guidance trace out together, each hashed in the bundle manifest. The trace file states what the copied Markdown stated: the distance unit is unproven, and traffic on undecoded transaction codes means the R69 map is shifted and every reading with it. The road name is bounded to 40 characters when the line is recorded. It is head-unit text of unknown length, and a full ring of unbounded names would pass the export's 64 KiB per-file ceiling and be dropped from the bundle silently — the one failure that would leave the driver believing the trace had been exported. The copy actions are gone from both screens, the capture screen no longer points at adb, and the file store's retention is one shared pool of eight, which is the bundle's own evidence limit: what is newest across the probes is what leaves the car. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 7be953b commit 8560884

11 files changed

Lines changed: 241 additions & 103 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,21 @@ All notable changes follow [Keep a Changelog](https://keepachangelog.com/en/1.1.
2020
- **A route source exists after all, and the unstable channel can prove it.** The head unit's
2121
adapter service fans remaining distance, remaining time, guidance status and road name out to
2222
any registered listener, so a route no longer requires a network, a permission or an account.
23-
A parked-armed probe in the unstable channel records that fan-out over a drive and reports it
24-
as a copyable trace. Nothing is displayed as a value: the distance callbacks carry no proven
25-
unit and the status codes no documented meaning, so every reading stays raw pending the
26-
on-vehicle run. The stable channel is unchanged and gains no capability.
23+
A parked-armed probe in the unstable channel records that fan-out over a drive and saves it
24+
as a trace file the diagnostic export carries to USB. Nothing is displayed as a value: the
25+
distance callbacks carry no proven unit and the status codes no documented meaning, so every
26+
reading stays raw pending the on-vehicle run. The stable channel is unchanged and gains no
27+
capability.
28+
29+
### Changed
30+
31+
- **Every probe now leaves the car on a USB stick, not through the clipboard.** The guidance
32+
probe kept its trace in memory and offered a copy button, so the only way off the head unit
33+
was a paste into some other app — which the read-only, offline boundary makes pointless, and
34+
which loses the census and the caveats a reading has to be judged against. Both probes now
35+
write bounded JSON into the folder the diagnostic export bundles, so one **Export to USB**
36+
carries the signal captures and the guidance trace out together, each hashed in the bundle
37+
manifest. The copy actions are gone, and the capture screen no longer points at adb.
2738

2839
### Security
2940

‎analysis/CP-040_route_source.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -151,5 +151,6 @@ on a head-unit service for the life of the process.
151151
## Next step
152152

153153
Run the probe on the vehicle: park, arm, start a guidance in the car's own navigation, drive a
154-
leg whose distance is known independently, then park and copy the trace. The trace answers the
155-
unit question and the idle question together.
154+
leg whose distance is known independently, then park, save the trace, and take it out with
155+
**Diagnostic → Export to USB** — the trace lands in the bundle as `evidence/navguidance-*.json`
156+
beside the signal captures. It answers the unit question and the idle question together.
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package com.evsuite.chargepilot
2+
3+
import com.google.gson.JsonParser
4+
import java.nio.file.Files
5+
import java.util.zip.ZipFile
6+
import org.junit.Assert.assertEquals
7+
import org.junit.Assert.assertNotNull
8+
import org.junit.Assert.assertTrue
9+
import org.junit.Test
10+
11+
class NavGuidanceProbeArtifactTest {
12+
13+
@Test
14+
fun `a saved trace leaves the car inside the diagnostic USB bundle`() {
15+
val filesDir = Files.createTempDirectory("nav-probe-files").toFile()
16+
val usb = Files.createTempDirectory("nav-probe-usb").toFile()
17+
try {
18+
val evidence = filesDir.resolve("evidence")
19+
val saved = EvidenceCaptureFileStore(evidence) { 1_700_000_000_000L }
20+
.write(artifact().toJson(), NavGuidanceProbeArtifact.KIND, "SWI69")
21+
22+
val bundle = DiagnosticExporter.writeBundle("report", evidence, usb, 0L)
23+
24+
assertNotNull(saved)
25+
assertNotNull(bundle)
26+
ZipFile(bundle).use { zip ->
27+
val entry = zip.entries().toList().single { it.name.startsWith("evidence/") }
28+
assertEquals("evidence/${saved!!.name}", entry.name)
29+
val json = JsonParser.parseString(
30+
zip.getInputStream(entry).reader().use { it.readText() }
31+
).asJsonObject
32+
assertEquals(NavGuidanceProbeArtifact.PROBE, json["probe"].asString)
33+
assertEquals(2, json["trace"].asJsonArray.size())
34+
assertEquals(1, json["undecodedCodes"].asJsonArray.size())
35+
assertEquals(999, json["undecodedCodes"].asJsonArray[0].asInt)
36+
}
37+
} finally {
38+
filesDir.deleteRecursively()
39+
usb.deleteRecursively()
40+
}
41+
}
42+
43+
/** A full ring of the widest lines the probe can produce must still fit the bundle. */
44+
@Test
45+
fun `a full trace stays under the per-file evidence ceiling`() {
46+
val line = "%5ds n=%-4d status=%-4s dist=%-8s min=%-6s turn=%-8s road=%s".format(
47+
10_800, 999_999, "9999", "99999999", "999999", "99999999", "é".repeat(40),
48+
)
49+
val json = artifact(trace = List(300) { line }, traceComplete = false).toJson()
50+
51+
assertTrue(
52+
json.toByteArray(Charsets.UTF_8).size < DiagnosticExporter.MAX_EVIDENCE_BYTES
53+
)
54+
}
55+
56+
private fun artifact(
57+
trace: List<String> = listOf(" 1s n=1 road=A9", " 2s n=2 road=A9"),
58+
traceComplete: Boolean = true,
59+
) = NavGuidanceProbeArtifact.of(
60+
savedAtMs = 1_700_000_000_000L,
61+
firmware = "SWI69",
62+
adapterBound = true,
63+
listenerRegistered = true,
64+
callbacks = trace.size,
65+
census = mapOf(1 to 4, 999 to 2),
66+
censusBeyondCeiling = 0,
67+
trace = trace,
68+
traceComplete = traceComplete,
69+
)
70+
}

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

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

3-
import android.content.ClipData
4-
import android.content.ClipboardManager
53
import android.content.Intent
64
import android.os.Bundle
75
import android.os.SystemClock
@@ -12,9 +10,7 @@ import com.evsuite.hardware.telemetry.EnergySnapshot
1210
import com.evsuite.hardware.telemetry.EnergyTelemetryReader
1311
import com.evsuite.hardware.telemetry.EvidenceCapture
1412
import com.evsuite.hardware.telemetry.SignalKind
15-
import com.evsuite.hardware.telemetry.TelemetryEvidenceFormat
1613
import com.evsuite.hardware.telemetry.TelemetryEvidenceRecorder
17-
import com.google.android.material.snackbar.Snackbar
1814
import java.io.File
1915
import java.util.Locale
2016
import java.util.concurrent.Executors
@@ -49,17 +45,13 @@ class EvidenceCaptureActivity : AppCompatActivity() {
4945
private var lastWrittenFile: File? = null
5046
private var writeFailed = false
5147

52-
// Main-thread state used only by the clipboard action.
53-
private var displayedCapture: EvidenceCapture? = null
54-
5548
override fun onCreate(savedInstanceState: Bundle?) {
5649
super.onCreate(savedInstanceState)
5750
binding = ActivityEvidenceCaptureBinding.inflate(layoutInflater)
5851
setContentView(binding.root)
5952
reader = EnergyTelemetryReader(applicationContext)
6053
fileStore = EvidenceCaptureFileStore(File(filesDir, "evidence"))
6154
binding.captureAction.setOnClickListener { requestToggle() }
62-
binding.copyAction.setOnClickListener { copyMarkdown() }
6355
binding.navProbeAction.setOnClickListener {
6456
startActivity(Intent(this, NavGuidanceProbeActivity::class.java))
6557
}
@@ -154,21 +146,7 @@ class EvidenceCaptureActivity : AppCompatActivity() {
154146
}
155147
}
156148

157-
/**
158-
* The Markdown goes to the clipboard rather than through a share sheet: this app holds no
159-
* network or storage permission, and the JSON remains app-private for adb retrieval.
160-
*/
161-
private fun copyMarkdown() {
162-
val capture = displayedCapture ?: return
163-
val clipboard = getSystemService(ClipboardManager::class.java) ?: return
164-
clipboard.setPrimaryClip(
165-
ClipData.newPlainText("evidence", TelemetryEvidenceFormat.toMarkdown(capture))
166-
)
167-
Snackbar.make(binding.root, R.string.evidence_copied, Snackbar.LENGTH_SHORT).show()
168-
}
169-
170149
private fun render(state: CaptureViewState) {
171-
displayedCapture = state.capture
172150
val gate = EvidenceCapturePolicy.gate(state.speedKmh)
173151

174152
binding.captureAction.isEnabled = gate == EvidenceCaptureGate.PARKED
@@ -204,7 +182,6 @@ class EvidenceCaptureActivity : AppCompatActivity() {
204182
}
205183
binding.evidenceTable.text = state.capture?.let(::table)
206184
?: getString(R.string.evidence_no_data)
207-
binding.copyAction.isEnabled = state.capture != null
208185
}
209186

210187
private fun table(capture: EvidenceCapture): String = buildString {

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

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,20 +8,30 @@ import java.text.SimpleDateFormat
88
import java.util.Date
99
import java.util.Locale
1010

11-
/** Writes a completed capture atomically and retains only a small fixed set. */
11+
/** Writes a completed probe artifact atomically and retains only a small fixed set. */
1212
internal class EvidenceCaptureFileStore(
1313
private val directory: File,
1414
private val maxFiles: Int = MAX_CAPTURE_FILES,
1515
private val nowMs: () -> Long = System::currentTimeMillis,
1616
) {
17-
fun write(capture: EvidenceCapture): File? {
17+
fun write(capture: EvidenceCapture): File? =
18+
write(TelemetryEvidenceFormat.toJson(capture), CAPTURE_KIND, capture.firmware)
19+
20+
/**
21+
* Any probe artifact, not only a signal capture.
22+
*
23+
* Every probe writes its JSON here because this folder is what the diagnostic export
24+
* bundles onto the USB stick; the retention below is one shared pool, so the newest
25+
* artifacts of every probe are the ones that leave the car.
26+
*/
27+
fun write(json: String, kind: String, firmware: String): File? {
1828
if (!directory.isDirectory && !directory.mkdirs()) return null
1929
if (maxFiles <= 0 || !prepareDirectory()) return null
20-
val target = uniqueTarget(capture)
30+
val target = uniqueTarget(kind, firmware)
2131
val temp = File(directory, ".${target.name}.${System.nanoTime()}.tmp")
2232
return try {
2333
FileOutputStream(temp).use { output ->
24-
output.write(TelemetryEvidenceFormat.toJson(capture).toByteArray(Charsets.UTF_8))
34+
output.write(json.toByteArray(Charsets.UTF_8))
2535
output.fd.sync()
2636
}
2737
if (temp.renameTo(target)) target else null
@@ -32,16 +42,16 @@ internal class EvidenceCaptureFileStore(
3242
}
3343
}
3444

35-
private fun uniqueTarget(capture: EvidenceCapture): File {
45+
private fun uniqueTarget(kind: String, firmware: String): File {
3646
val stamp = SimpleDateFormat("yyyyMMdd-HHmmss-SSS", Locale.ROOT).format(Date(nowMs()))
37-
val firmware = capture.firmware.replace(Regex("[^A-Za-z0-9._-]"), "_")
47+
val safeFirmware = firmware.replace(Regex("[^A-Za-z0-9._-]"), "_")
3848
val scenario = directory.parentFile
3949
?.let(::VehicleTestContextStore)
4050
?.read()
4151
?.scenario
4252
?.id
4353
?: "unclassified"
44-
val base = "evidence-$firmware-$scenario-$stamp"
54+
val base = "$kind-$safeFirmware-$scenario-$stamp"
4555
var candidate = File(directory, "$base.json")
4656
var suffix = 1
4757
while (candidate.exists()) candidate = File(directory, "$base-${suffix++}.json")
@@ -69,6 +79,7 @@ internal class EvidenceCaptureFileStore(
6979

7080
companion object {
7181
internal const val MAX_CAPTURE_FILES = 8
82+
internal const val CAPTURE_KIND = "evidence"
7283
private val CAPTURE_NAME = Regex("^(.*-\\d{8}-\\d{6}-\\d{3})(?:-(\\d+))?$")
7384
}
7485
}

0 commit comments

Comments
 (0)