diff --git a/app/src/main/java/com/hawhamburg/micr0bu/MainActivity.kt b/app/src/main/java/com/hawhamburg/micr0bu/MainActivity.kt index cc083b1..77d8ce9 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/MainActivity.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/MainActivity.kt @@ -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() }, ) } diff --git a/app/src/main/java/com/hawhamburg/micr0bu/data/TripExporter.kt b/app/src/main/java/com/hawhamburg/micr0bu/data/TripExporter.kt new file mode 100644 index 0000000..a59d1b7 --- /dev/null +++ b/app/src/main/java/com/hawhamburg/micr0bu/data/TripExporter.kt @@ -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, + v2xMessages: List, +): 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, + v2xMessages: List, +) { + 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 = 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()) diff --git a/app/src/main/java/com/hawhamburg/micr0bu/data/TripRepository.kt b/app/src/main/java/com/hawhamburg/micr0bu/data/TripRepository.kt index 31db081..05fcf25 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/data/TripRepository.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/data/TripRepository.kt @@ -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> = 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 = + dao.getEventsForTrip(tripId).first() + + suspend fun getV2xMessagesForTripOnce(tripId: Long): List = + 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, ) ) diff --git a/app/src/main/java/com/hawhamburg/micr0bu/data/cam/CamUseCaseRepository.kt b/app/src/main/java/com/hawhamburg/micr0bu/data/cam/CamUseCaseRepository.kt index 3cc0374..9357186 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/data/cam/CamUseCaseRepository.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/data/cam/CamUseCaseRepository.kt @@ -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) diff --git a/app/src/main/java/com/hawhamburg/micr0bu/data/db/AppDatabase.kt b/app/src/main/java/com/hawhamburg/micr0bu/data/db/AppDatabase.kt index 0c5c850..af5f86c 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/data/db/AppDatabase.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/data/db/AppDatabase.kt @@ -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/.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. diff --git a/app/src/main/java/com/hawhamburg/micr0bu/data/db/RecordedTripEntity.kt b/app/src/main/java/com/hawhamburg/micr0bu/data/db/RecordedTripEntity.kt index 125cc57..c112169 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/data/db/RecordedTripEntity.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/data/db/RecordedTripEntity.kt @@ -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/.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, ) diff --git a/app/src/main/java/com/hawhamburg/micr0bu/data/db/V2xMessageEntity.kt b/app/src/main/java/com/hawhamburg/micr0bu/data/db/V2xMessageEntity.kt index 76b3094..edc1009 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/data/db/V2xMessageEntity.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/data/db/V2xMessageEntity.kt @@ -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, ) diff --git a/app/src/main/java/com/hawhamburg/micr0bu/domain/cam/Cam.kt b/app/src/main/java/com/hawhamburg/micr0bu/domain/cam/Cam.kt index 404fd62..36f13c0 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/domain/cam/Cam.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/domain/cam/Cam.kt @@ -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, diff --git a/app/src/main/java/com/hawhamburg/micr0bu/service/TripRecordingService.kt b/app/src/main/java/com/hawhamburg/micr0bu/service/TripRecordingService.kt index a2b7bde..65a658d 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/service/TripRecordingService.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/service/TripRecordingService.kt @@ -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, diff --git a/app/src/main/java/com/hawhamburg/micr0bu/ui/components/StatusTopBar.kt b/app/src/main/java/com/hawhamburg/micr0bu/ui/components/StatusTopBar.kt index 79c40b3..ebda3e0 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/ui/components/StatusTopBar.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/ui/components/StatusTopBar.kt @@ -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 +} diff --git a/app/src/main/java/com/hawhamburg/micr0bu/ui/navigation/AppNavigation.kt b/app/src/main/java/com/hawhamburg/micr0bu/ui/navigation/AppNavigation.kt index d86afef..8b0763f 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/ui/navigation/AppNavigation.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/ui/navigation/AppNavigation.kt @@ -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, diff --git a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/MqttTopicViewerScreen.kt b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/MqttTopicViewerScreen.kt index c0f1139..ca5ac02 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/MqttTopicViewerScreen.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/MqttTopicViewerScreen.kt @@ -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, diff --git a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/RecordingScreen.kt b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/RecordingScreen.kt index 2546163..03e9a38 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/RecordingScreen.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/RecordingScreen.kt @@ -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) } diff --git a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/SettingsScreen.kt b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/SettingsScreen.kt index a064d5f..a5402ea 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/SettingsScreen.kt @@ -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)) diff --git a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/TripHistoryScreen.kt b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/TripHistoryScreen.kt index d54a4a1..c0909c5 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/TripHistoryScreen.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/TripHistoryScreen.kt @@ -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(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) } diff --git a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/V2xLiveMapView.kt b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/V2xLiveMapView.kt index 70e4cde..91e3630 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/V2xLiveMapView.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/V2xLiveMapView.kt @@ -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) { diff --git a/app/src/main/java/com/hawhamburg/micr0bu/viewmodel/SensorViewModel.kt b/app/src/main/java/com/hawhamburg/micr0bu/viewmodel/SensorViewModel.kt index dd37f49..142cefa 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/viewmodel/SensorViewModel.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/viewmodel/SensorViewModel.kt @@ -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) diff --git a/app/src/main/java/com/hawhamburg/micr0bu/viewmodel/TripRecordingViewModel.kt b/app/src/main/java/com/hawhamburg/micr0bu/viewmodel/TripRecordingViewModel.kt index 59ddea8..31a0e47 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/viewmodel/TripRecordingViewModel.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/viewmodel/TripRecordingViewModel.kt @@ -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().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), + ) + } + } } diff --git a/app/src/main/res/drawable/ic_map_own.xml b/app/src/main/res/drawable/ic_map_own.xml new file mode 100644 index 0000000..53394c3 --- /dev/null +++ b/app/src/main/res/drawable/ic_map_own.xml @@ -0,0 +1,24 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_map_remote_station.xml b/app/src/main/res/drawable/ic_map_remote_station.xml new file mode 100644 index 0000000..8fd6274 --- /dev/null +++ b/app/src/main/res/drawable/ic_map_remote_station.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index a7b71d7..4a99c16 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -110,6 +110,7 @@ Sitzung #%1$d CSV teilen Auf Gerät speichern + Fahrt als CSV exportieren Sitzung löschen Noch keine Sitzungen Gehe zu Aufnahme und starte eine Sitzung @@ -211,6 +212,7 @@ %1$.1f km/h · Kurs %2$.0f° %1$.0f m — m + %1$d dBm Gefahr: Ursache %1$d/%2$d (Station %3$d) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c45bd94..4a3bb55 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -111,6 +111,7 @@ Session #%1$d Share CSV Save to device + Export trip CSV Delete session No sessions yet Go to Record and start a session @@ -212,6 +213,7 @@ %1$.1f km/h · heading %2$.0f° %1$.0f m — m + %1$d dBm Hazard: cause %1$d/%2$d (station %3$d)