ESP32 link indicators, trip export with V2X + RSSI, trip/CSV cleanup

UI:
- Top bar shows a USB glyph reflecting the serial link on the ESP32-C5 path,
  instead of a Wi-Fi glyph driven by an MQTT state that is permanently
  disconnected there.
- Recording screen's OBU stream row follows whichever transport the selected
  hardware uses. It previously read as offline throughout a recording that was
  actively beaconing CAMs.
- Live map uses distinct markers: a centred dot for own position (a fact about
  the viewer, not a tracked object) and a teardrop pin for remote stations,
  tinted by alert severity. Both were osmdroid's identical default pin before,
  and severity required tapping a marker to read its label.
- Sensor Monitor moves out of the bottom nav to Settings > Developer. A live
  phone-sensor feed is a bench tool; the Dashboard already reports GNSS/IMU
  health. Screen and route are unchanged, just not in the rider's way.

Detection engine on the ESP32 path:
- Seed our own StationID from the persisted value CamTransmitLoop transmits.
  It was null here (the CiT One learns it from v2x/rx/obu_gnss, which doesn't
  exist on this path), so the self-heard-TX filter never fired: the ESP32 runs
  promiscuous for raw TX to work at all, hears our own CAMs back off the air,
  and they were tracked as a remote station - a ghost vehicle on top of the ego
  position, fed to the engine as a collision partner for itself.

RSSI:
- The firmware has always sent per-frame RSSI in byte 0 of every CAM_RX frame;
  the app discarded it. Now carried on Cam, persisted per V2X message, shown per
  station in the received-CAM list, and exported. Null for own CAMs and the
  whole CiT One path, neither of which has a measurement.

Trips, CSV and export (schema v3 -> v4):
- trips.sessionId links a trip to the CSV session recorded alongside it. The two
  are written by independent subsystems that the Recording button happens to
  start together; without the link, deleting a trip orphaned its CSV forever.
  Timestamp matching was rejected - close recordings would delete the wrong file.
- Deleting a trip now removes the CSV file AND its sessions row, so it stops
  appearing in the Session Log pointing at nothing.
- Per-trip combined CSV export: GPS track, detected events, V2X messages (with
  RSSI) and the raw sensor samples in one file, keyed by a leading type column.
  One file rather than a zip of tables because the point of the export is
  correlating those streams, and splitting them pushes the join downstream.
- Export takes the Activity context. Sharing from the ViewModel's Application
  context threw AndroidRuntimeException on startActivity - this crashed on the
  first tap of the share button.

Trips recorded before v4 have a null sessionId, so their exports omit raw sensor
rows and their CSVs still need clearing by hand once.
This commit is contained in:
Ashin Walpola
2026-08-11 15:47:54 +02:00
parent f507a8a9fd
commit f1770e11dd
22 changed files with 496 additions and 34 deletions
@@ -93,6 +93,13 @@ class SensorViewModel(application: Application) : AndroidViewModel(application)
private var recordingTimerJob: Job? = null
private var recordingSessionId: String = ""
/**
* ID of the CSV session currently being written, or null when not recording. Read by
* MainActivity so the trip started at the same moment can store it - see
* [com.hawhamburg.micr0bu.data.db.RecordedTripEntity.sessionId] for why that link matters.
*/
val activeSessionId: String? get() = recordingSessionId.takeIf { _state.value.isRecording }
private var csvWriter: BufferedWriter? = null
private val isoFmt = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US)
@@ -7,6 +7,7 @@ import android.os.PowerManager
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.hawhamburg.micr0bu.data.TripRepository
import com.hawhamburg.micr0bu.data.shareTripCsv
import com.hawhamburg.micr0bu.data.db.AppDatabase
import com.hawhamburg.micr0bu.data.db.DetectedEventEntity
import com.hawhamburg.micr0bu.data.db.RecordedTripEntity
@@ -27,7 +28,7 @@ import kotlinx.coroutines.launch
*/
class TripRecordingViewModel(application: Application) : AndroidViewModel(application) {
private val repository = TripRepository(AppDatabase.getInstance(application))
private val repository = TripRepository(AppDatabase.getInstance(application), application)
// ── Battery optimisation prompt ───────────────────────────────────────────
// Shown once on the Recording screen to ask the user to exempt the app so
@@ -85,13 +86,21 @@ class TripRecordingViewModel(application: Application) : AndroidViewModel(applic
* Starts the foreground recording service if not already running,
* or stops it if a trip is already active.
*/
fun toggleRecording() {
if (serviceState.value.isRecording) stopRecording() else startRecording()
fun toggleRecording(sessionId: String? = null) {
if (serviceState.value.isRecording) stopRecording() else startRecording(sessionId)
}
fun startRecording() {
/**
* @param sessionId ID of the CSV session recorded alongside this trip, so the trip row can
* reference it and deleting the trip can delete the CSV too. Null if CSV recording isn't
* running - the trip is still recorded, it just has no CSV to clean up later.
*/
fun startRecording(sessionId: String? = null) {
val intent = Intent(getApplication(), TripRecordingService::class.java)
.apply { action = TripRecordingService.ACTION_START }
.apply {
action = TripRecordingService.ACTION_START
putExtra(TripRecordingService.EXTRA_SESSION_ID, sessionId)
}
getApplication<Application>().startForegroundService(intent)
}
@@ -106,4 +115,26 @@ class TripRecordingViewModel(application: Application) : AndroidViewModel(applic
fun deleteTrip(tripId: Long) {
viewModelScope.launch { repository.deleteTrip(tripId) }
}
/**
* Builds and shares the combined CSV for [tripId] - GPS track, detected events, V2X messages
* and the raw sensor samples recorded alongside it. See [buildTripCsv] for the format and why
* it's one file rather than several.
*
* [context] must be the Activity context (`LocalContext.current` from the composable), not
* this ViewModel's Application context: the share sheet is launched with `startActivity`, and
* doing that from a non-Activity context throws unless FLAG_ACTIVITY_NEW_TASK is set. Same
* pattern as SessionLogScreen's share.
*/
fun exportTrip(context: Context, tripId: Long) {
viewModelScope.launch {
val trip = repository.getTrip(tripId) ?: return@launch
shareTripCsv(
context = context,
trip = trip,
events = repository.getEventsForTripOnce(tripId),
v2xMessages = repository.getV2xMessagesForTripOnce(tripId),
)
}
}
}