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:
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user