Skip to content

Commit 46ae7ca

Browse files
committed
feat(diagnostics): export vehicle evidence bundle
Correlate each CP-003 capture with its real-world scenario and export bounded runtime, log, crash, and evidence artifacts with integrity hashes. Assisted-by: Codex
1 parent a4a2e14 commit 46ae7ca

15 files changed

Lines changed: 685 additions & 64 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,12 +43,13 @@ All notable changes follow [Keep a Changelog](https://keepachangelog.com/en/1.1.
4343
unvalidated until CP-003 proves it. The block states that it shows climate state only and that
4444
no HVAC power is measured; it adds no control path and retains no additional history.
4545

46-
- **Diagnostics can be exported explicitly to a removable USB volume.** The action is
47-
parked-only and fail-closed when speed is unreadable, offers no internal-storage fallback,
48-
adds no storage permission, and writes a report capped at 128 KiB through a unique temporary
49-
file plus same-directory rename. A pathological oversized report keeps its head and an explicit
50-
truncation marker. Firmware, property/provenance status, the recent bounded log and the previous
51-
bounded crash report are included; probing and I/O stay off the main thread.
46+
- **Diagnostics and vehicle-test evidence export as one bounded USB bundle.** The parked-only ZIP
47+
carries runtime/APK/signer identity, exact firmware, service and latest-sample state, property
48+
probes, provenance, bounded logs, previous crash, and up to eight scenario-named CP-003 JSON
49+
captures. Its manifest provides byte counts and SHA-256 per artifact. Report, capture and bundle
50+
limits are 128/64/768 KiB; the atomic temporary file lives on USB, no archive accumulates on
51+
AAOS, and no network or storage permission is added. Unstable diagnostics now require choosing
52+
the physical test scenario before capture so evidence cannot be mistaken for another segment.
5253

5354
- **Battery power flow is directional, static and evidence-gated.** The signed kW value is paired
5455
with a centred, non-animated scale and explicit output/regeneration text, so colour never

‎README.md‎

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ It does not write to the vehicle and does not contain network or update code.
4848
- Evidence-gated signed battery power with a static traction/regeneration scale.
4949
- Pack temperature and charging state when exposed by the current firmware.
5050
- Energy and regeneration integration from the shared EVHardware power convention.
51-
- Parked-only, bounded diagnostic export to an explicitly chosen removable USB volume.
51+
- Parked-only, bounded diagnostic/evidence ZIP to an explicitly chosen removable USB volume.
5252
- Atomic, app-private trip history and in-app crash diagnostics.
5353

5454
Every signal remains best-effort: unsupported or unreadable properties are displayed as `—`,
@@ -93,13 +93,15 @@ reported duration is the time actually covered by usable samples, not wall clock
9393
sampler adds nothing to duration, distance or energy, so consumption averages compare values
9494
measured over the same interval.
9595

96-
The diagnostics dialog can export the same firmware, property/provenance, recent-log and crash
97-
context to a removable USB volume. The app offers only removable roots already visible at
98-
runtime and can fall back only to its own folder on that same USB volume; it never falls back to
99-
internal AAOS storage and declares no storage permission. Export requires a fresh readable
100-
speed at or below 0.1 km/h, runs off the main thread, caps the report at 128 KiB with an explicit
101-
truncation marker, and renames a unique temporary file only after its contents have been flushed
102-
and synced.
96+
The diagnostics dialog exports one self-contained ZIP to a removable USB volume: runtime/APK and
97+
signer identity, exact firmware, service and last-sample state, property/provenance probes,
98+
bounded recent log, previous crash, and up to eight newest unstable CP-003 evidence JSON files.
99+
Its manifest records byte counts and SHA-256 per artifact. The app offers only removable roots
100+
already visible at runtime and can fall back only to its own folder on that same USB volume; it
101+
never falls back to internal AAOS storage and declares no storage permission. Export requires a
102+
fresh readable speed at or below 0.1 km/h, runs off the main thread, caps the text report at
103+
128 KiB, each evidence file at 64 KiB and the ZIP at 768 KiB, then atomically renames a synced
104+
temporary file on USB. No archive copy accumulates on AAOS.
103105

104106
## Trip export formats
105107

‎analysis/CP-003_energy_signal_validation.md‎

Lines changed: 38 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# CP-003 energy signal validation
22

3-
Status: protocol prepared by Codex on 2026-08-31; no vehicle evidence collected yet.
3+
Status: protocol and bounded USB evidence bundle prepared by Codex on 2026-09-01; no vehicle
4+
evidence collected yet.
45

56
This protocol validates observed runtime signals. It does not claim support from an APK,
67
firmware file, emulator, or another vehicle generation. Phase 3 remains blocked until the
@@ -19,10 +20,15 @@ required vehicle evidence is attached and interpreted.
1920
use a proprietary firmware artifact for this run.
2021
- Captures store aggregate statistics, not a raw sample stream: 5 Hz for the first minute, then
2122
1 Hz, with at most 1,024 change intervals per signal in memory. Files are app-private atomic
22-
JSON summaries; only the eight newest are retained. One full six-capture run fits below that
23-
ceiling. Pull and hash all files before another vehicle/generation run.
24-
- Optional diagnostic context may be exported separately through the parked-only USB action.
25-
It never substitutes for the evidence JSON required below.
23+
JSON summaries; only the eight newest are retained. The eight named road/charge segments below
24+
exactly fit that ceiling. Export before any extra exploratory capture or another generation.
25+
- Parked-only USB export writes one atomic ZIP, capped at 768 KiB. It contains a 128 KiB-bounded
26+
diagnostic, all eight newest evidence JSON files (64 KiB each maximum), and a manifest with byte
27+
counts and SHA-256 hashes. It creates no additional persistent file on AAOS.
28+
- Diagnostic includes exact detected firmware, app/APK/signature identity, Android/head-unit build,
29+
permissions, heap/storage state, recorder state, latest normalized snapshot, property probes,
30+
value provenance, bounded recent logs and previous crash. VIN, serial, location and accounts are
31+
deliberately excluded.
2632

2733
## Preconditions
2834

@@ -34,9 +40,9 @@ Record before installation or driving:
3440
| Vehicle model/year/trim | pending |
3541
| Firmware string shown by runtime | pending |
3642
| EVHardware generation classification | pending |
37-
| EVChargePilot APK filename/version | pending |
38-
| APK SHA-256 | pending |
39-
| APK signing certificate SHA-256 | pending |
43+
| EVChargePilot APK filename/version | pending; version is also in `diagnostic.txt` |
44+
| APK SHA-256 | emitted in `diagnostic.txt` |
45+
| APK signing certificate SHA-256 | emitted in `diagnostic.txt` |
4046
| Operator and passenger present | pending |
4147
| Charging equipment/rated power, if used | pending |
4248

@@ -49,8 +55,9 @@ state, and any interruption. Never transcribe an unavailable value as zero.
4955

5056
## Capture sequence
5157

52-
Run each scenario as a separate capture. Start parked. Return to a safe parked state before
53-
stopping and saving. A skipped scenario is explicitly `not tested`.
58+
Run each named segment as a separate capture. From **Diagnostic**, choose **Open evidence capture**
59+
and select the exact scenario first; that ID is embedded in the JSON filename. Start parked. Return
60+
to a safe parked state before stopping and saving. A skipped scenario is explicitly `not tested`.
5461

5562
1. **Stationary, ignition on, HVAC off — 3 minutes.** Doors closed; climate power, AC, seat and
5663
steering heat off. Establish idle availability, cadence and battery-power baseline.
@@ -60,11 +67,11 @@ stopping and saving. A skipped scenario is explicitly `not tested`.
6067
3. **Urban accelerate/regen — 10 minutes.** Repeat normal, legal acceleration and lift-off/regen
6168
cycles. No hard manoeuvre is required. Record selected regen/drive mode as operator context,
6269
not as EVSuite compatibility evidence.
63-
4. **Motorway steady speed — 10 minutes when legal and safe.** Hold approximately 110 km/h for
64-
5 minutes, then approximately 130 km/h for 5 minutes. If either speed is illegal or unsafe,
65-
omit it. One combined capture is sufficient for availability/cadence; it is not model-fitting
66-
data because this recorder intentionally retains no raw track.
67-
5. **Grade — one downhill and one uphill segment.** Use an existing safe route; do not seek a
70+
4. **Motorway steady speed — two captures when legal and safe.** Capture approximately 110 km/h
71+
for 5 minutes, then approximately 130 km/h for 5 minutes. If either speed is illegal or unsafe,
72+
omit it. These prove availability/cadence; they are not model-fitting data because this recorder
73+
intentionally retains no raw track.
74+
5. **Grade — separate downhill and uphill captures.** Use an existing safe route; do not seek a
6875
steep road. Record approximate segment times and whether each segment completed. This checks
6976
that both battery-power polarities remain observable in ordinary driving.
7077
6. **Charging — at least 3 minutes, if reachable.** Start after the charge session is stable and
@@ -74,8 +81,18 @@ stopping and saving. A skipped scenario is explicitly `not tested`.
7481

7582
## Evidence retrieval
7683

77-
The saved absolute path is shown in-app. From an already-authorized ADB workstation, pull that
78-
exact path without changing app or filesystem permissions:
84+
Primary path, with vehicle parked and a removable USB key mounted:
85+
86+
1. Open **Diagnostic**. Confirm displayed firmware, current scenario and latest sample age.
87+
2. Select **Export to USB**, choose the removable volume, and wait for the exported `.zip` path.
88+
3. On a workstation, keep the ZIP unchanged, verify `manifest.txt`, then extract
89+
`evidence/*.json` into `analysis/evidence/`. The manifest already carries SHA-256 per artifact;
90+
hash the ZIP itself too for transport integrity.
91+
4. Keep `diagnostic.txt` with the intake. It explains the exact build/runtime and failure context
92+
but never substitutes for the evidence JSON.
93+
94+
ADB is fallback only. From an already-authorized workstation, pull the exact path shown in-app
95+
without changing app or filesystem permissions:
7996

8097
```bash
8198
mkdir -p analysis/evidence
@@ -101,8 +118,10 @@ After every pull:
101118
| pending | stationary-hvac-off | pending | pending | not tested |
102119
| pending | stationary-hvac-max | pending | pending | not tested |
103120
| pending | urban | pending | pending | not tested |
104-
| pending | motorway | pending | pending | not tested |
105-
| pending | grade | pending | pending | not tested |
121+
| pending | motorway-110 | pending | pending | not tested |
122+
| pending | motorway-130 | pending | pending | not tested |
123+
| pending | grade-uphill | pending | pending | not tested |
124+
| pending | grade-downhill | pending | pending | not tested |
106125
| pending | charging | pending | pending | not tested |
107126

108127
## Generation coverage

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

Lines changed: 131 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,24 +4,69 @@ import android.content.Context
44
import java.io.File
55
import java.io.FileOutputStream
66
import java.io.IOException
7+
import java.io.RandomAccessFile
78
import java.text.SimpleDateFormat
89
import java.util.Date
910
import java.util.Locale
11+
import java.security.MessageDigest
12+
import java.util.zip.ZipEntry
13+
import java.util.zip.ZipOutputStream
1014

1115
/** Writes one bounded diagnostic report to an explicitly selected removable volume. */
1216
object DiagnosticExporter {
1317

1418
internal const val MAX_REPORT_BYTES = 128 * 1024
19+
internal const val MAX_EVIDENCE_FILES = 8
20+
internal const val MAX_EVIDENCE_BYTES = 64 * 1024
21+
internal const val MAX_BUNDLE_BYTES = 768 * 1024
1522
internal const val TRUNCATION_MARKER = "\n[report truncated at 128 KiB]\n"
1623

1724
fun export(context: Context, report: String, chosenDirectory: File): File? {
1825
val target = DiagnosticUsbStorage.writableTarget(context, chosenDirectory) ?: return null
19-
return write(report, target)
26+
return writeBundle(report, File(context.filesDir, "evidence"), target)
27+
}
28+
29+
/**
30+
* One self-contained USB artifact: readable report plus newest bounded CP-003 captures.
31+
* Nothing is copied back to internal storage, and malformed/oversized capture files are
32+
* listed as skipped rather than allowed to grow the bundle without limit.
33+
*/
34+
internal fun writeBundle(
35+
report: String,
36+
evidenceDirectory: File,
37+
directory: File,
38+
nowMs: Long = System.currentTimeMillis(),
39+
): File? {
40+
if (!directory.isDirectory) return null
41+
val target = uniqueTarget(directory, fileStem(nowMs), "zip") ?: return null
42+
val temp = File(directory, ".${target.name}.${System.nanoTime()}.tmp")
43+
return try {
44+
val reportBytes = bounded(report).toByteArray(Charsets.UTF_8)
45+
val evidence = evidenceFiles(evidenceDirectory)
46+
val manifest = manifest(nowMs, reportBytes, evidence)
47+
FileOutputStream(temp).use { output ->
48+
ZipOutputStream(output.buffered()).use { zip ->
49+
zip.writeEntry("manifest.txt", manifest.toByteArray(Charsets.UTF_8), nowMs)
50+
zip.writeEntry("diagnostic.txt", reportBytes, nowMs)
51+
evidence.included.forEach { artifact ->
52+
zip.writeEntry("evidence/${artifact.file.name}", artifact.bytes, artifact.modifiedMs)
53+
}
54+
}
55+
}
56+
RandomAccessFile(temp, "rw").use { it.fd.sync() }
57+
if (temp.length() > MAX_BUNDLE_BYTES || !temp.renameTo(target)) null else target
58+
} catch (_: IOException) {
59+
null
60+
} catch (_: SecurityException) {
61+
null
62+
} finally {
63+
if (temp.exists()) temp.delete()
64+
}
2065
}
2166

2267
internal fun write(report: String, directory: File, nowMs: Long = System.currentTimeMillis()): File? {
2368
if (!directory.isDirectory) return null
24-
val target = uniqueTarget(directory, fileStem(nowMs)) ?: return null
69+
val target = uniqueTarget(directory, fileStem(nowMs), "txt") ?: return null
2570
val temp = File(directory, ".${target.name}.${System.nanoTime()}.tmp")
2671
return try {
2772
val bytes = bounded(report).toByteArray(Charsets.UTF_8)
@@ -43,15 +88,86 @@ object DiagnosticExporter {
4388
private fun fileStem(nowMs: Long): String = "evchargepilot-diagnostic-" +
4489
SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date(nowMs))
4590

46-
private fun uniqueTarget(directory: File, stem: String): File? {
91+
private fun uniqueTarget(directory: File, stem: String, extension: String): File? {
4792
repeat(MAX_SAME_SECOND_EXPORTS) { suffix ->
48-
val name = if (suffix == 0) "$stem.txt" else "$stem-$suffix.txt"
93+
val name = if (suffix == 0) "$stem.$extension" else "$stem-$suffix.$extension"
4994
File(directory, name).takeUnless(File::exists)?.let { return it }
5095
}
5196
return null
5297
}
5398

99+
private fun evidenceFiles(directory: File): EvidenceSelection {
100+
val candidates = runCatching { directory.listFiles() }
101+
.getOrNull()
102+
?.filter { file ->
103+
file.isFile && file.extension == "json" && SAFE_FILE_NAME.matches(file.name)
104+
}
105+
?.sortedWith(compareByDescending<File> { it.lastModified() }.thenByDescending(File::getName))
106+
.orEmpty()
107+
.take(MAX_EVIDENCE_FILES)
108+
val included = mutableListOf<EvidenceArtifact>()
109+
val skipped = mutableListOf<String>()
110+
candidates.forEach { file ->
111+
if (file.length() !in 1..MAX_EVIDENCE_BYTES.toLong()) {
112+
skipped += "${file.name}: size=${file.length()}"
113+
return@forEach
114+
}
115+
val bytes = runCatching { file.readBytes() }.getOrNull()
116+
if (bytes == null || bytes.size !in 1..MAX_EVIDENCE_BYTES) {
117+
skipped += "${file.name}: unreadable-or-size-changed"
118+
} else {
119+
included += EvidenceArtifact(file, bytes, file.lastModified())
120+
}
121+
}
122+
return EvidenceSelection(included, skipped)
123+
}
124+
125+
private fun manifest(
126+
nowMs: Long,
127+
report: ByteArray,
128+
evidence: EvidenceSelection,
129+
): String = buildString {
130+
appendLine("schema=1")
131+
appendLine("created_epoch_ms=$nowMs")
132+
appendLine("bundle_limit_bytes=$MAX_BUNDLE_BYTES")
133+
appendLine("diagnostic_bytes=${report.size}")
134+
appendLine("diagnostic_sha256=${sha256(report)}")
135+
appendLine("evidence_included=${evidence.included.size}")
136+
appendLine("evidence_skipped=${evidence.skipped.size}")
137+
evidence.included.forEachIndexed { index, artifact ->
138+
appendLine("evidence.$index.name=${artifact.file.name}")
139+
appendLine("evidence.$index.bytes=${artifact.bytes.size}")
140+
appendLine("evidence.$index.modified_epoch_ms=${artifact.modifiedMs}")
141+
appendLine("evidence.$index.sha256=${sha256(artifact.bytes)}")
142+
}
143+
evidence.skipped.forEachIndexed { index, reason ->
144+
appendLine("skipped.$index=$reason")
145+
}
146+
}
147+
148+
private fun ZipOutputStream.writeEntry(name: String, bytes: ByteArray, modifiedMs: Long) {
149+
putNextEntry(ZipEntry(name).apply { time = modifiedMs.coerceAtLeast(0L) })
150+
write(bytes)
151+
closeEntry()
152+
}
153+
154+
internal fun sha256(bytes: ByteArray): String = MessageDigest.getInstance("SHA-256")
155+
.digest(bytes)
156+
.joinToString("") { "%02x".format(Locale.ROOT, it) }
157+
54158
private const val MAX_SAME_SECOND_EXPORTS = 100
159+
private val SAFE_FILE_NAME = Regex("^[A-Za-z0-9._-]+\\.json$")
160+
161+
private data class EvidenceArtifact(
162+
val file: File,
163+
val bytes: ByteArray,
164+
val modifiedMs: Long,
165+
)
166+
167+
private data class EvidenceSelection(
168+
val included: List<EvidenceArtifact>,
169+
val skipped: List<String>,
170+
)
55171

56172
/** Keeps the report head and a visible marker without splitting a UTF-8 sequence. */
57173
internal fun bounded(report: String): String {
@@ -62,6 +178,17 @@ object DiagnosticExporter {
62178
while (end > 0 && bytes[end].toInt() and 0xC0 == 0x80) end--
63179
return String(bytes, 0, end, Charsets.UTF_8) + TRUNCATION_MARKER
64180
}
181+
182+
/** Keeps newest events for a bounded log section without cutting a UTF-8 sequence. */
183+
internal fun boundedTail(content: String, maxBytes: Int, marker: String): String {
184+
val bytes = content.toByteArray(Charsets.UTF_8)
185+
if (bytes.size <= maxBytes) return content
186+
val markerBytes = marker.toByteArray(Charsets.UTF_8)
187+
if (markerBytes.size >= maxBytes) return String(markerBytes, 0, maxBytes, Charsets.UTF_8)
188+
var start = bytes.size - (maxBytes - markerBytes.size)
189+
while (start < bytes.size && bytes[start].toInt() and 0xC0 == 0x80) start++
190+
return marker + String(bytes, start, bytes.size - start, Charsets.UTF_8)
191+
}
65192
}
66193

67194
/** Pure fail-closed parked gate, kept separate so stale-sample behavior is JVM-tested. */

0 commit comments

Comments
 (0)