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
@@ -28,6 +28,9 @@ import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
import com.hawhamburg.micr0bu.data.transport.UsbSerialState
import com.hawhamburg.micr0bu.data.transport.ObuHardware
import com.hawhamburg.micr0bu.ui.components.StatusTopBar
import com.hawhamburg.micr0bu.ui.navigation.BottomNavBar
import com.hawhamburg.micr0bu.ui.navigation.Screen
@@ -122,7 +125,14 @@ class MainActivity : AppCompatActivity() {
}
Scaffold(
topBar = { StatusTopBar(state, mqttConnectionState) },
topBar = {
StatusTopBar(
state = state,
mqttConnectionState = mqttConnectionState,
isEsp32 = obuHardware == ObuHardware.ESP32_C5,
usbSerialState = usbSerialState,
)
},
bottomBar = { BottomNavBar(navController) },
) { innerPadding ->
NavHost(
@@ -165,12 +175,19 @@ class MainActivity : AppCompatActivity() {
RecordingScreen(
state = state,
mqttConnectionState = mqttConnectionState,
obuConnected = if (obuHardware == ObuHardware.ESP32_C5)
usbSerialState == UsbSerialState.CONNECTED
else
mqttConnectionState == MqttConnectionState.CONNECTED,
tripServiceState = tripServiceState,
showBatteryOptPrompt = showBatteryOptPrompt,
onToggleRecording = {
// Start / stop both CSV recording and the event detection service
// Start / stop both CSV recording and the event detection
// service. Order matters on start: the CSV session must exist
// before the trip row is written, so the trip can store its
// id and later delete the CSV along with itself.
viewModel.toggleRecording()
tripViewModel.toggleRecording()
tripViewModel.toggleRecording(viewModel.activeSessionId)
},
onOpenSessionLog = {
navController.navigate(Screen.Log.route)
@@ -294,6 +311,7 @@ class MainActivity : AppCompatActivity() {
DeveloperSettingsScreen(
state = state,
onDeveloperMode = viewModel::setDeveloperMode,
onOpenSensorMonitor = { navController.navigate(Screen.Sensors.route) },
onBack = { navController.popBackStack() },
)
}
@@ -0,0 +1,150 @@
package com.hawhamburg.micr0bu.data
import android.content.Context
import android.content.Intent
import androidx.core.content.FileProvider
import com.hawhamburg.micr0bu.data.db.DetectedEventEntity
import com.hawhamburg.micr0bu.data.db.RecordedTripEntity
import com.hawhamburg.micr0bu.data.db.V2xMessageEntity
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONArray
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
private val isoUtc = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US)
private val humanLocal = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
fun tripFileName(trip: RecordedTripEntity): String =
"micr0bu_trip_${humanLocal.format(Date(trip.startTime))
.replace(" ", "_").replace(":", "-")}.csv"
/**
* Builds a single combined CSV for one trip: the raw sensor samples recorded alongside it, the
* events the detector fired, the GPS track, and every V2X message seen during the ride — all in
* one file, ordered by time.
*
* **Why one file rather than a zip of tables.** The point of the export is correlation: what was
* the bike doing when that CAM arrived, what did the detector make of it. Splitting those into
* separate files pushes the join onto whoever opens it. A leading `type` column keeps the rows
* distinguishable, which is the same shape the existing session CSV already uses, so the two
* remain readable by the same tooling.
*
* Columns are the union of what the row types need; a row leaves the fields that don't apply to it
* empty rather than inventing values. That is deliberately wide and sparse — spreadsheets and
* pandas both handle it fine, and it keeps every value under a self-describing header instead of
* a positional one that means different things per row.
*
* Sensor rows are copied through verbatim from the session CSV where one exists. Trips recorded
* before the trip↔session link existed (schema v3 and earlier) have no `sessionId`, so their
* export contains everything except the raw sensor stream.
*/
suspend fun buildTripCsv(
context: Context,
trip: RecordedTripEntity,
events: List<DetectedEventEntity>,
v2xMessages: List<V2xMessageEntity>,
): String = withContext(Dispatchers.IO) {
buildString {
appendLine("# MicrOBU Trip Export")
appendLine("# Trip ID,${trip.id}")
appendLine("# Start,${isoUtc.format(Date(trip.startTime))}")
appendLine("# End,${isoUtc.format(Date(trip.endTime))}")
appendLine("# Distance_m,${trip.distanceMetres}")
appendLine("# Events,${trip.eventCount}")
appendLine("# V2X_messages,${v2xMessages.size}")
appendLine("# Session_ID,${trip.sessionId ?: "(none - recorded before trip/session linking)"}")
appendLine()
appendLine(
"type,timestamp_ms,timestamp_iso,lat,lon,speed_ms,heading_deg," +
"event_type,confidence,peak_accel,peak_gyro,duration_ms," +
"station_id,station_type,is_own,yaw_rate_dps,rssi_dbm"
)
// GPS track: stored on the trip row as a JSON array of points, not in a table.
for (point in trip.gpsTrackJson.toTrackPoints()) {
appendLine(
"gps,${point.timestamp},${isoUtc.format(Date(point.timestamp))}," +
"${point.lat},${point.lon},,," +
",,,,," +
",,,"
)
}
for (e in events) {
appendLine(
"event,${e.timestamp},${isoUtc.format(Date(e.timestamp))}," +
"${e.latitude},${e.longitude},${e.speedMps},," +
"${e.type},${e.confidence},${e.peakAccelMagnitude},${e.peakGyroMagnitude},${e.durationMs}," +
",,,,"
)
}
for (m in v2xMessages) {
appendLine(
"v2x,${m.timestamp},${isoUtc.format(Date(m.timestamp))}," +
"${m.latitude},${m.longitude},${m.speedMps},${m.headingDeg}," +
",,,,," +
"${m.stationId},${m.stationType},${m.isOwn},${m.yawRateDps ?: ""},${m.rssiDbm ?: ""}"
)
}
// Raw sensor samples, copied verbatim from the session CSV. Appended last rather than
// merge-sorted in: a long ride is hundreds of thousands of rows, and sorting them against
// the (comparatively tiny) event/V2X sets in memory would defeat the streaming that
// CsvExporter deliberately does. Each row carries its own timestamp, so sort on load.
val sessionCsv = trip.sessionId?.let { File(File(context.filesDir, "sessions"), "$it.csv") }
if (sessionCsv != null && sessionCsv.exists()) {
appendLine()
appendLine("# --- raw sensor samples (from session ${trip.sessionId}) ---")
sessionCsv.forEachLine { line ->
if (line.isNotBlank() && !line.startsWith("#")) appendLine(line)
}
}
}
}
/** Shares a trip's combined CSV via Android's share sheet. */
suspend fun shareTripCsv(
context: Context,
trip: RecordedTripEntity,
events: List<DetectedEventEntity>,
v2xMessages: List<V2xMessageEntity>,
) {
val fileName = tripFileName(trip)
val cacheFile = File(context.cacheDir, fileName)
val csv = buildTripCsv(context, trip, events, v2xMessages)
withContext(Dispatchers.IO) { cacheFile.writeText(csv) }
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
cacheFile,
)
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/csv"
putExtra(Intent.EXTRA_STREAM, uri)
putExtra(Intent.EXTRA_SUBJECT, "MicrOBU Trip Export — $fileName")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(Intent.createChooser(intent, "Export trip"))
}
private data class TrackPoint(val timestamp: Long, val lat: Double, val lon: Double)
/** Parses [RecordedTripEntity.gpsTrackJson]; returns empty on anything malformed. */
private fun String.toTrackPoints(): List<TrackPoint> = runCatching {
val arr = JSONArray(this)
(0 until arr.length()).mapNotNull { i ->
val o = arr.optJSONObject(i) ?: return@mapNotNull null
// Keys match TripRecordingService.appendGpsPoint: {"lat":..,"lon":..,"ts":..}
TrackPoint(
timestamp = o.optLong("ts", 0L),
lat = o.optDouble("lat", Double.NaN),
lon = o.optDouble("lon", Double.NaN),
).takeIf { !it.lat.isNaN() && !it.lon.isNaN() }
}
}.getOrDefault(emptyList())
@@ -1,5 +1,7 @@
package com.hawhamburg.micr0bu.data
import android.content.Context
import android.util.Log
import com.hawhamburg.micr0bu.data.db.AppDatabase
import com.hawhamburg.micr0bu.data.db.DetectedEventEntity
import com.hawhamburg.micr0bu.data.db.RecordedTripEntity
@@ -7,6 +9,10 @@ import com.hawhamburg.micr0bu.data.db.V2xMessageEntity
import com.hawhamburg.micr0bu.domain.cam.Cam
import com.hawhamburg.micr0bu.domain.detection.DetectedEvent
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import java.io.File
private const val TAG = "TripRepository"
/**
* Repository that abstracts Room access for trips and detected events.
@@ -14,9 +20,10 @@ import kotlinx.coroutines.flow.Flow
* All suspend functions are safe to call from a coroutine running on any
* dispatcher; Room executes the actual SQL on its own I/O thread pool.
*/
class TripRepository(db: AppDatabase) {
class TripRepository(db: AppDatabase, private val context: Context) {
private val dao = db.tripDao()
private val sessionDao = db.sessionDao()
// ── Trips ─────────────────────────────────────────────────────────────────
@@ -30,6 +37,7 @@ class TripRepository(db: AppDatabase) {
distanceMetres: Float = 0f,
eventCount: Int = 0,
gpsTrackJson: String = "[]",
sessionId: String? = null,
): Long = dao.insertTrip(
RecordedTripEntity(
startTime = startTime,
@@ -37,6 +45,7 @@ class TripRepository(db: AppDatabase) {
distanceMetres = distanceMetres,
eventCount = eventCount,
gpsTrackJson = gpsTrackJson,
sessionId = sessionId,
)
)
@@ -65,7 +74,40 @@ class TripRepository(db: AppDatabase) {
/** Emits all trips ordered by startTime DESC, updating whenever the DB changes. */
fun getAllTrips(): Flow<List<RecordedTripEntity>> = dao.getAllTrips()
suspend fun deleteTrip(tripId: Long) = dao.deleteTripById(tripId)
/** One-shot read of a single trip row, or null if it no longer exists. */
suspend fun getTrip(tripId: Long): RecordedTripEntity? = dao.getTripById(tripId)
/**
* One-shot snapshots for export. The Flow-returning variants above stay observable for the UI;
* an export wants a value it can write out, not a stream it has to unsubscribe from.
*/
suspend fun getEventsForTripOnce(tripId: Long): List<DetectedEventEntity> =
dao.getEventsForTrip(tripId).first()
suspend fun getV2xMessagesForTripOnce(tripId: Long): List<V2xMessageEntity> =
dao.getV2xMessagesForTrip(tripId).first()
/**
* Deletes a trip and everything belonging to it: detected events and V2X messages go via the
* schema's CASCADE foreign keys, and the CSV recorded alongside it is removed here.
*
* The CSV is a plain file outside the database, so nothing deletes it implicitly - before
* this, every deleted trip left one behind and the user had to clear them by hand.
*/
suspend fun deleteTrip(tripId: Long) {
val sessionId = dao.getTripById(tripId)?.sessionId
dao.deleteTripById(tripId)
if (sessionId == null) return
// The CSV lives in two places as far as the user is concerned: a file on disk, and a row
// in `sessions` that makes it visible on the Session Log screen. Deleting only the file
// left a phantom entry there pointing at nothing, so both go.
sessionDao.deleteById(sessionId)
val csv = File(File(context.filesDir, "sessions"), "$sessionId.csv")
if (csv.exists() && !csv.delete()) {
Log.w(TAG, "deleteTrip: failed to delete CSV for session $sessionId")
}
}
// ── Events ────────────────────────────────────────────────────────────────
@@ -112,6 +154,7 @@ class TripRepository(db: AppDatabase) {
speedMps = cam.speedMps,
headingDeg = cam.headingDeg,
yawRateDps = cam.yawRateDps,
rssiDbm = cam.rssiDbm,
)
)
@@ -190,8 +190,22 @@ class CamUseCaseRepository @Inject constructor(
}
}
// Our own station ID. On the CiT One path it's learned from v2x/rx/obu_gnss; the ESP32-C5
// path has no such topic, so it comes from the same persisted value CamTransmitLoop puts
// in outgoing CAMs.
//
// Without this the ID stayed null on the ESP32 path and the self-heard-TX filter in
// [handleCamFromSerial] never fired - so the phone's own CAMs, which the ESP32 hears back
// off the air in promiscuous mode, were tracked as a *remote* station: a ghost vehicle
// sitting exactly on top of the ego position, fed into the detection engine as a
// collision partner for itself.
scope.launch {
obuHardwarePrefs.obuHardwareFlow.collect { currentHardware = it }
obuHardwarePrefs.obuHardwareFlow.collect { hardware ->
currentHardware = hardware
if (hardware == ObuHardware.ESP32_C5) {
_ownStationId.value = obuHardwarePrefs.getOrCreateOwnStationId()
}
}
}
}
@@ -269,8 +283,9 @@ class CamUseCaseRepository @Inject constructor(
*/
private fun handleCamFromSerial(payload: ByteArray) {
if (payload.isEmpty()) return
val rssiDbm = payload[0].toInt() // signed dBm from the firmware's promiscuous RX metadata
val camBytes = payload.copyOfRange(1, payload.size) // payload[0] is RSSI, not part of the CAM
val cam = camCodec.decodeCam(camBytes, System.currentTimeMillis())
val cam = camCodec.decodeCam(camBytes, System.currentTimeMillis())?.copy(rssiDbm = rssiDbm)
if (cam == null) {
// Logged, not silently dropped: "the app shows nothing" has two completely different
// causes - frames not arriving at all, versus arriving and failing to decode - and
@@ -278,12 +293,12 @@ class CamUseCaseRepository @Inject constructor(
Log.w(
TAG,
"handleCamFromSerial: decode FAILED for ${camBytes.size}-byte CAM " +
"(rssi=${payload[0].toInt()}) - first bytes: ${camBytes.toHexPreview()}",
"(rssi=$rssiDbm dBm) - first bytes: ${camBytes.toHexPreview()}",
)
return
}
Log.d(TAG, "handleCamFromSerial: decoded station=${cam.stationId} " +
"lat=${cam.latitude} lon=${cam.longitude} speed=${cam.speedMps} rssi=${payload[0].toInt()}")
"lat=${cam.latitude} lon=${cam.longitude} speed=${cam.speedMps} rssi=$rssiDbm dBm")
if (_ownStationId.value != null && cam.stationId == _ownStationId.value) return // self-heard TX
engine.onRemoteCam(cam)
_processedCam.tryEmit(cam)
@@ -14,7 +14,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
DetectedEventEntity::class,
V2xMessageEntity::class,
],
version = 3,
version = 4,
exportSchema = false,
)
abstract class AppDatabase : RoomDatabase() {
@@ -34,13 +34,30 @@ abstract class AppDatabase : RoomDatabase() {
AppDatabase::class.java,
"micr0bu.db",
)
.addMigrations(MIGRATION_1_2, MIGRATION_2_3)
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4)
.build()
.also { INSTANCE = it }
}
// ── Migrations ────────────────────────────────────────────────────────
/**
* Two additions:
* - `trips.sessionId` links a trip to the CSV recording session captured alongside it, so
* deleting a trip can also delete its `filesDir/sessions/<id>.csv`. Nullable, because
* trips recorded before this column existed have no way to identify their CSV - those
* files stay orphaned and have to be cleared by hand once.
* - `v2x_messages.rssiDbm` stores the received signal strength the ESP32-C5 firmware
* already sends in every CAM_RX frame but which the app previously discarded, so
* range-vs-signal can be analysed from a recorded ride.
*/
private val MIGRATION_3_4 = object : Migration(3, 4) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE `trips` ADD COLUMN `sessionId` TEXT")
database.execSQL("ALTER TABLE `v2x_messages` ADD COLUMN `rssiDbm` INTEGER")
}
}
/**
* Adds the `trips` and `detected_events` tables introduced in Phase A.
* The existing `sessions` table is left untouched.
@@ -18,4 +18,16 @@ data class RecordedTripEntity(
val distanceMetres: Float,
val eventCount: Int,
val gpsTrackJson: String,
/**
* ID of the CSV recording session captured alongside this trip, or null for trips recorded
* before this column existed (schema v3 and earlier).
*
* Trips and CSV sessions are written by two independent subsystems - [TripRecordingService]
* and SensorViewModel - that the Recording screen happens to start together. Without this
* link, deleting a trip left its `filesDir/sessions/<id>.csv` behind forever with nothing in
* the UI referencing it. Matching them by timestamp instead was rejected: two recordings
* close together would silently delete the wrong file.
*/
val sessionId: String? = null,
)
@@ -48,4 +48,7 @@ data class V2xMessageEntity(
val speedMps: Double,
val headingDeg: Double,
val yawRateDps: Double?,
/** Received signal strength, dBm, for CAMs heard over the air. Null on the CiT One path. */
val rssiDbm: Int? = null,
)
@@ -56,6 +56,17 @@ data class Cam(
/** Optional longitudinal acceleration control field, m/s², where available. */
val accelerationMps2: Double? = null,
/**
* Received signal strength in dBm for a CAM heard over the air, or null when there is no such
* measurement.
*
* Only the ESP32-C5 path populates this: the firmware reads it from the promiscuous RX
* callback's packet metadata (`wifi_pkt_rx_ctrl_t.rssi`) and puts it in byte 0 of every
* CAM_RX frame. Null for own CAMs and for everything on the CiT One path, whose Use Case API
* JSON carries no equivalent field.
*/
val rssiDbm: Int? = null,
/** Wall-clock ms this CAM was received/processed. */
val timestamp: Long,
@@ -65,6 +65,13 @@ class TripRecordingService : Service() {
companion object {
const val ACTION_START = "com.hawhamburg.micr0bu.TRIP_START"
/**
* Optional string extra on [ACTION_START]: the ID of the CSV recording session started
* alongside this trip, so deleting the trip can also delete its CSV. Absent if the trip
* was started without CSV recording.
*/
const val EXTRA_SESSION_ID = "com.hawhamburg.micr0bu.EXTRA_SESSION_ID"
const val ACTION_STOP = "com.hawhamburg.micr0bu.TRIP_STOP"
private const val NOTIFICATION_ID = 9001
@@ -215,13 +222,13 @@ class TripRecordingService : Service() {
super.onCreate()
sensorManager = getSystemService(SENSOR_SERVICE) as SensorManager
fusedLocation = LocationServices.getFusedLocationProviderClient(this)
repository = TripRepository(AppDatabase.getInstance(applicationContext))
repository = TripRepository(AppDatabase.getInstance(applicationContext), applicationContext)
createNotificationChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_START -> startTrip()
ACTION_START -> startTrip(intent.getStringExtra(EXTRA_SESSION_ID))
ACTION_STOP -> stopTrip()
}
return START_NOT_STICKY
@@ -236,7 +243,7 @@ class TripRecordingService : Service() {
// ── Trip control ─────────────────────────────────────────────────────────
private fun startTrip() {
private fun startTrip(sessionId: String?) {
// Keep the CPU awake for the duration of the trip so sensor and GPS callbacks
// are delivered reliably when the screen is off. Released in stopTrip().
val pm = getSystemService(POWER_SERVICE) as PowerManager
@@ -260,7 +267,7 @@ class TripRecordingService : Service() {
// Insert placeholder trip row and get the auto-generated ID
serviceScope.launch {
currentTripId = repository.insertTrip(startTime = startTime)
currentTripId = repository.insertTrip(startTime = startTime, sessionId = sessionId)
TripServiceBus.update {
copy(
isRecording = true,
@@ -19,6 +19,8 @@ import androidx.compose.material.icons.filled.GpsFixed
import androidx.compose.material.icons.filled.GpsOff
import androidx.compose.material.icons.filled.Sensors
import androidx.compose.material.icons.filled.SensorsOff
import androidx.compose.material.icons.filled.Usb
import androidx.compose.material.icons.filled.UsbOff
import androidx.compose.material.icons.filled.Wifi
import androidx.compose.material.icons.filled.WifiOff
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -37,6 +39,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
import com.hawhamburg.micr0bu.data.transport.UsbSerialState
import com.hawhamburg.micr0bu.viewmodel.SensorUiState
private val GreenActive = Color(0xFF4CAF50)
@@ -46,7 +49,12 @@ private val GrayInactive = Color(0xFF555E6A)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun StatusTopBar(state: SensorUiState, mqttConnectionState: MqttConnectionState) {
fun StatusTopBar(
state: SensorUiState,
mqttConnectionState: MqttConnectionState,
isEsp32: Boolean = false,
usbSerialState: UsbSerialState = UsbSerialState.DISCONNECTED,
) {
TopAppBar(
title = {
Text(
@@ -85,7 +93,7 @@ fun StatusTopBar(state: SensorUiState, mqttConnectionState: MqttConnectionState)
)
Spacer(Modifier.width(8.dp))
ObuStatusIcon(mqttConnectionState)
ObuStatusIcon(mqttConnectionState, isEsp32, usbSerialState)
}
},
colors = TopAppBarDefaults.topAppBarColors(
@@ -94,13 +102,27 @@ fun StatusTopBar(state: SensorUiState, mqttConnectionState: MqttConnectionState)
)
}
/**
* OBU link indicator. Which transport it reflects depends on the selected hardware: the CiT One
* reaches the phone over MQTT (Wi-Fi / USB-C tethering), the ESP32-C5 over a USB-serial link with
* no broker at all - so on that path [mqttConnectionState] is permanently DISCONNECTED and would
* report the OBU as offline while CAMs were streaming in. Uses a USB glyph there rather than the
* Wi-Fi one, since that is literally what the connection is.
*/
@Composable
private fun ObuStatusIcon(state: MqttConnectionState) {
val icon = when (state) {
MqttConnectionState.CONNECTED,
MqttConnectionState.CONNECTING -> Icons.Default.Wifi
MqttConnectionState.ERROR,
MqttConnectionState.DISCONNECTED -> Icons.Default.WifiOff
private fun ObuStatusIcon(
mqttState: MqttConnectionState,
isEsp32: Boolean,
usbSerialState: UsbSerialState,
) {
val state = if (isEsp32) usbSerialState.asConnectionState() else mqttState
val icon = when {
isEsp32 && state == MqttConnectionState.CONNECTED -> Icons.Default.Usb
isEsp32 && state == MqttConnectionState.CONNECTING -> Icons.Default.Usb
isEsp32 -> Icons.Default.UsbOff
state == MqttConnectionState.CONNECTED ||
state == MqttConnectionState.CONNECTING -> Icons.Default.Wifi
else -> Icons.Default.WifiOff
}
val tint = when (state) {
MqttConnectionState.CONNECTED -> GreenActive
@@ -167,3 +189,15 @@ private fun RecordingPulse() {
.background(Color(0xFFFF5252).copy(alpha = alpha)),
)
}
/**
* Maps the ESP32-C5 serial link's lifecycle onto the MQTT connection vocabulary this bar's colour
* and pulse logic already speaks, so one indicator serves both transports.
*/
private fun UsbSerialState.asConnectionState(): MqttConnectionState = when (this) {
UsbSerialState.CONNECTED -> MqttConnectionState.CONNECTED
UsbSerialState.DEVICE_ATTACHED,
UsbSerialState.PERMISSION_REQUESTED -> MqttConnectionState.CONNECTING
UsbSerialState.ERROR -> MqttConnectionState.ERROR
UsbSerialState.DISCONNECTED -> MqttConnectionState.DISCONNECTED
}
@@ -52,9 +52,11 @@ sealed class Screen(val route: String, val labelRes: Int) {
data object SettingsAbout : Screen("settings/about", R.string.settings_about)
}
// Sensors is deliberately absent: a live phone-sensor feed isn't something a rider needs, and the
// Dashboard already reports whether GNSS/IMU are active. The screen and its route still exist and
// are reachable from Settings > Developer for bench diagnosis - see DeveloperSettingsScreen.
private val bottomNavItems = listOf(
Screen.Dashboard,
Screen.Sensors,
Screen.Record,
Screen.TripHistory,
Screen.MqttViewer,
@@ -530,6 +530,14 @@ private fun ReceivedCamRow(
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface,
)
cam.rssiDbm?.let { rssi ->
Text(
text = stringResource(R.string.v2x_cam_rx_rssi, rssi),
style = MaterialTheme.typography.labelSmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
text = timeFormat.format(Date(cam.timestamp)),
style = MaterialTheme.typography.labelSmall,
@@ -45,6 +45,13 @@ import com.hawhamburg.micr0bu.viewmodel.SensorUiState
fun RecordingScreen(
state: SensorUiState,
mqttConnectionState: MqttConnectionState,
/**
* Whether the OBU link is up, whichever transport this hardware uses - MQTT for the CiT One,
* the USB-serial link for the ESP32-C5. Passed in rather than derived from
* [mqttConnectionState] because that is permanently DISCONNECTED on the ESP32 path, which
* showed the OBU stream as offline during a recording that was actively beaconing CAMs.
*/
obuConnected: Boolean = mqttConnectionState == MqttConnectionState.CONNECTED,
tripServiceState: TripServiceBus.State,
showBatteryOptPrompt: Boolean,
onToggleRecording: () -> Unit,
@@ -213,7 +220,7 @@ fun RecordingScreen(
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
StreamRow(stringResource(R.string.stream_baro), state.barometerEnabled && state.pressureHpa != null)
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
StreamRow(stringResource(R.string.stream_obu), mqttConnectionState == MqttConnectionState.CONNECTED)
StreamRow(stringResource(R.string.stream_obu), obuConnected)
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
StreamRow(stringResource(R.string.rec_stream_event_detection), tripServiceState.isRecording)
}
@@ -397,11 +397,16 @@ fun UseCaseAlertsSettingsScreen(
fun DeveloperSettingsScreen(
state: SensorUiState,
onDeveloperMode: (Boolean) -> Unit,
onOpenSensorMonitor: () -> Unit,
onBack: () -> Unit,
) {
SubScreen(stringResource(R.string.settings_developer), onBack) {
SectionCard {
SettingToggleRow(stringResource(R.string.settings_dev_mode), state.developerMode, onDeveloperMode)
Divider()
// Sensor Monitor lives here rather than in the bottom nav: a live phone-sensor feed is
// a bench-diagnosis tool, not something a rider needs mid-ride.
MenuRow(stringResource(R.string.sensor_monitor_title), onOpenSensorMonitor)
if (state.developerMode) {
Divider()
DisabledRow(stringResource(R.string.settings_wifi), stringResource(R.string.settings_wifi_val))
@@ -21,6 +21,7 @@ import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -33,6 +34,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@@ -51,6 +53,7 @@ fun TripHistoryScreen(
onOpenTrip: (Long) -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val trips by viewModel.trips.collectAsState(initial = emptyList())
var pendingDelete by remember { mutableStateOf<RecordedTripEntity?>(null) }
@@ -99,6 +102,7 @@ fun TripHistoryScreen(
index = trips.size - index,
onOpen = { onOpenTrip(trip.id) },
onDelete = { pendingDelete = trip },
onExport = { viewModel.exportTrip(context, trip.id) },
)
}
}
@@ -112,6 +116,7 @@ private fun TripCard(
index: Int,
onOpen: () -> Unit,
onDelete: () -> Unit,
onExport: () -> Unit,
) {
Card(
modifier = Modifier
@@ -146,6 +151,13 @@ private fun TripCard(
IconButton(onClick = onOpen) {
Icon(Icons.Default.Map, contentDescription = stringResource(R.string.trip_review_title), tint = MaterialTheme.colorScheme.primary)
}
IconButton(onClick = onExport) {
Icon(
Icons.Default.Share,
contentDescription = stringResource(R.string.trip_export_cd),
tint = MaterialTheme.colorScheme.primary,
)
}
IconButton(onClick = onDelete) {
Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.log_delete_cd), tint = MaterialTheme.colorScheme.error)
}
@@ -112,13 +112,14 @@ fun V2xLiveMapView(
update = { mv ->
mv.overlays.clear()
// Own marker — distinct from remotes via a dedicated title prefix; osmdroid
// doesn't tint default pins per-instance without a custom drawable, so color
// differentiation for now relies on the title label shown on tap.
// Own position: a centred "you are here" dot, not a pin. Own position is a fact
// about the viewer rather than one of the tracked objects, and when both used
// osmdroid's identical default pin the two were indistinguishable at a glance.
mv.overlays.add(
Marker(mv).apply {
position = ownGeoPoint
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
icon = ContextCompat.getDrawable(context, R.drawable.ic_map_own)
title = context.getString(R.string.v2x_map_own_label)
}
)
@@ -131,10 +132,19 @@ fun V2xLiveMapView(
AlertLevel.INFO -> context.getString(R.string.v2x_map_remote_info, stationId)
null -> context.getString(R.string.v2x_map_remote_plain, stationId)
}
// Teardrop pin anchored at its tip, tinted by severity. Now that these are
// custom drawables, per-instance tinting is possible - severity no longer
// depends on tapping the marker to read its label. mutate() is essential:
// without it every marker shares one ConstantState and the last tint applied
// would recolour all of them.
val pin = ContextCompat.getDrawable(context, R.drawable.ic_map_remote_station)
?.mutate()
?.apply { setTint(level.toMarkerColor()) }
mv.overlays.add(
Marker(mv).apply {
position = GeoPoint(cam.latitude, cam.longitude)
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
icon = pin
title = label
}
)
@@ -168,6 +178,18 @@ fun V2xLiveMapView(
}
}
/**
* Marker tint per severity, matching [UseCaseAlertPanel]'s colours so the same station reads the
* same way in both places. Stations with no active alert stay neutral grey-blue rather than green:
* green would imply "checked and safe", when it only means "nothing detected yet".
*/
private fun AlertLevel?.toMarkerColor(): Int = when (this) {
AlertLevel.WARNING -> 0xFFE53935.toInt()
AlertLevel.AWARENESS -> 0xFFFFC107.toInt()
AlertLevel.INFO -> 0xFF64B5F6.toInt()
null -> 0xFF78909C.toInt()
}
@Composable
private fun NoFixPlaceholder(modifier: Modifier) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
@@ -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),
)
}
}
}