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. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
528637dab6
commit
611f7b69eb
@@ -28,6 +28,9 @@ import androidx.navigation.compose.NavHost
|
|||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import androidx.navigation.navArgument
|
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.components.StatusTopBar
|
||||||
import com.hawhamburg.micr0bu.ui.navigation.BottomNavBar
|
import com.hawhamburg.micr0bu.ui.navigation.BottomNavBar
|
||||||
import com.hawhamburg.micr0bu.ui.navigation.Screen
|
import com.hawhamburg.micr0bu.ui.navigation.Screen
|
||||||
@@ -122,7 +125,14 @@ class MainActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = { StatusTopBar(state, mqttConnectionState) },
|
topBar = {
|
||||||
|
StatusTopBar(
|
||||||
|
state = state,
|
||||||
|
mqttConnectionState = mqttConnectionState,
|
||||||
|
isEsp32 = obuHardware == ObuHardware.ESP32_C5,
|
||||||
|
usbSerialState = usbSerialState,
|
||||||
|
)
|
||||||
|
},
|
||||||
bottomBar = { BottomNavBar(navController) },
|
bottomBar = { BottomNavBar(navController) },
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
NavHost(
|
NavHost(
|
||||||
@@ -165,12 +175,19 @@ class MainActivity : AppCompatActivity() {
|
|||||||
RecordingScreen(
|
RecordingScreen(
|
||||||
state = state,
|
state = state,
|
||||||
mqttConnectionState = mqttConnectionState,
|
mqttConnectionState = mqttConnectionState,
|
||||||
|
obuConnected = if (obuHardware == ObuHardware.ESP32_C5)
|
||||||
|
usbSerialState == UsbSerialState.CONNECTED
|
||||||
|
else
|
||||||
|
mqttConnectionState == MqttConnectionState.CONNECTED,
|
||||||
tripServiceState = tripServiceState,
|
tripServiceState = tripServiceState,
|
||||||
showBatteryOptPrompt = showBatteryOptPrompt,
|
showBatteryOptPrompt = showBatteryOptPrompt,
|
||||||
onToggleRecording = {
|
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()
|
viewModel.toggleRecording()
|
||||||
tripViewModel.toggleRecording()
|
tripViewModel.toggleRecording(viewModel.activeSessionId)
|
||||||
},
|
},
|
||||||
onOpenSessionLog = {
|
onOpenSessionLog = {
|
||||||
navController.navigate(Screen.Log.route)
|
navController.navigate(Screen.Log.route)
|
||||||
@@ -294,6 +311,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
DeveloperSettingsScreen(
|
DeveloperSettingsScreen(
|
||||||
state = state,
|
state = state,
|
||||||
onDeveloperMode = viewModel::setDeveloperMode,
|
onDeveloperMode = viewModel::setDeveloperMode,
|
||||||
|
onOpenSensorMonitor = { navController.navigate(Screen.Sensors.route) },
|
||||||
onBack = { navController.popBackStack() },
|
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
|
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.AppDatabase
|
||||||
import com.hawhamburg.micr0bu.data.db.DetectedEventEntity
|
import com.hawhamburg.micr0bu.data.db.DetectedEventEntity
|
||||||
import com.hawhamburg.micr0bu.data.db.RecordedTripEntity
|
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.cam.Cam
|
||||||
import com.hawhamburg.micr0bu.domain.detection.DetectedEvent
|
import com.hawhamburg.micr0bu.domain.detection.DetectedEvent
|
||||||
import kotlinx.coroutines.flow.Flow
|
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.
|
* 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
|
* 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.
|
* 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 dao = db.tripDao()
|
||||||
|
private val sessionDao = db.sessionDao()
|
||||||
|
|
||||||
// ── Trips ─────────────────────────────────────────────────────────────────
|
// ── Trips ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -30,6 +37,7 @@ class TripRepository(db: AppDatabase) {
|
|||||||
distanceMetres: Float = 0f,
|
distanceMetres: Float = 0f,
|
||||||
eventCount: Int = 0,
|
eventCount: Int = 0,
|
||||||
gpsTrackJson: String = "[]",
|
gpsTrackJson: String = "[]",
|
||||||
|
sessionId: String? = null,
|
||||||
): Long = dao.insertTrip(
|
): Long = dao.insertTrip(
|
||||||
RecordedTripEntity(
|
RecordedTripEntity(
|
||||||
startTime = startTime,
|
startTime = startTime,
|
||||||
@@ -37,6 +45,7 @@ class TripRepository(db: AppDatabase) {
|
|||||||
distanceMetres = distanceMetres,
|
distanceMetres = distanceMetres,
|
||||||
eventCount = eventCount,
|
eventCount = eventCount,
|
||||||
gpsTrackJson = gpsTrackJson,
|
gpsTrackJson = gpsTrackJson,
|
||||||
|
sessionId = sessionId,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -65,7 +74,40 @@ class TripRepository(db: AppDatabase) {
|
|||||||
/** Emits all trips ordered by startTime DESC, updating whenever the DB changes. */
|
/** Emits all trips ordered by startTime DESC, updating whenever the DB changes. */
|
||||||
fun getAllTrips(): Flow<List<RecordedTripEntity>> = dao.getAllTrips()
|
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 ────────────────────────────────────────────────────────────────
|
// ── Events ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -112,6 +154,7 @@ class TripRepository(db: AppDatabase) {
|
|||||||
speedMps = cam.speedMps,
|
speedMps = cam.speedMps,
|
||||||
headingDeg = cam.headingDeg,
|
headingDeg = cam.headingDeg,
|
||||||
yawRateDps = cam.yawRateDps,
|
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 {
|
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) {
|
private fun handleCamFromSerial(payload: ByteArray) {
|
||||||
if (payload.isEmpty()) return
|
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 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) {
|
if (cam == null) {
|
||||||
// Logged, not silently dropped: "the app shows nothing" has two completely different
|
// 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
|
// causes - frames not arriving at all, versus arriving and failing to decode - and
|
||||||
@@ -278,12 +293,12 @@ class CamUseCaseRepository @Inject constructor(
|
|||||||
Log.w(
|
Log.w(
|
||||||
TAG,
|
TAG,
|
||||||
"handleCamFromSerial: decode FAILED for ${camBytes.size}-byte CAM " +
|
"handleCamFromSerial: decode FAILED for ${camBytes.size}-byte CAM " +
|
||||||
"(rssi=${payload[0].toInt()}) - first bytes: ${camBytes.toHexPreview()}",
|
"(rssi=$rssiDbm dBm) - first bytes: ${camBytes.toHexPreview()}",
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
Log.d(TAG, "handleCamFromSerial: decoded station=${cam.stationId} " +
|
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
|
if (_ownStationId.value != null && cam.stationId == _ownStationId.value) return // self-heard TX
|
||||||
engine.onRemoteCam(cam)
|
engine.onRemoteCam(cam)
|
||||||
_processedCam.tryEmit(cam)
|
_processedCam.tryEmit(cam)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
|
|||||||
DetectedEventEntity::class,
|
DetectedEventEntity::class,
|
||||||
V2xMessageEntity::class,
|
V2xMessageEntity::class,
|
||||||
],
|
],
|
||||||
version = 3,
|
version = 4,
|
||||||
exportSchema = false,
|
exportSchema = false,
|
||||||
)
|
)
|
||||||
abstract class AppDatabase : RoomDatabase() {
|
abstract class AppDatabase : RoomDatabase() {
|
||||||
@@ -34,13 +34,30 @@ abstract class AppDatabase : RoomDatabase() {
|
|||||||
AppDatabase::class.java,
|
AppDatabase::class.java,
|
||||||
"micr0bu.db",
|
"micr0bu.db",
|
||||||
)
|
)
|
||||||
.addMigrations(MIGRATION_1_2, MIGRATION_2_3)
|
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4)
|
||||||
.build()
|
.build()
|
||||||
.also { INSTANCE = it }
|
.also { INSTANCE = it }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Migrations ────────────────────────────────────────────────────────
|
// ── 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.
|
* Adds the `trips` and `detected_events` tables introduced in Phase A.
|
||||||
* The existing `sessions` table is left untouched.
|
* The existing `sessions` table is left untouched.
|
||||||
|
|||||||
@@ -18,4 +18,16 @@ data class RecordedTripEntity(
|
|||||||
val distanceMetres: Float,
|
val distanceMetres: Float,
|
||||||
val eventCount: Int,
|
val eventCount: Int,
|
||||||
val gpsTrackJson: String,
|
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 speedMps: Double,
|
||||||
val headingDeg: Double,
|
val headingDeg: Double,
|
||||||
val yawRateDps: 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. */
|
/** Optional longitudinal acceleration control field, m/s², where available. */
|
||||||
val accelerationMps2: Double? = null,
|
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. */
|
/** Wall-clock ms this CAM was received/processed. */
|
||||||
val timestamp: Long,
|
val timestamp: Long,
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,13 @@ class TripRecordingService : Service() {
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val ACTION_START = "com.hawhamburg.micr0bu.TRIP_START"
|
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"
|
const val ACTION_STOP = "com.hawhamburg.micr0bu.TRIP_STOP"
|
||||||
|
|
||||||
private const val NOTIFICATION_ID = 9001
|
private const val NOTIFICATION_ID = 9001
|
||||||
@@ -215,13 +222,13 @@ class TripRecordingService : Service() {
|
|||||||
super.onCreate()
|
super.onCreate()
|
||||||
sensorManager = getSystemService(SENSOR_SERVICE) as SensorManager
|
sensorManager = getSystemService(SENSOR_SERVICE) as SensorManager
|
||||||
fusedLocation = LocationServices.getFusedLocationProviderClient(this)
|
fusedLocation = LocationServices.getFusedLocationProviderClient(this)
|
||||||
repository = TripRepository(AppDatabase.getInstance(applicationContext))
|
repository = TripRepository(AppDatabase.getInstance(applicationContext), applicationContext)
|
||||||
createNotificationChannel()
|
createNotificationChannel()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
when (intent?.action) {
|
when (intent?.action) {
|
||||||
ACTION_START -> startTrip()
|
ACTION_START -> startTrip(intent.getStringExtra(EXTRA_SESSION_ID))
|
||||||
ACTION_STOP -> stopTrip()
|
ACTION_STOP -> stopTrip()
|
||||||
}
|
}
|
||||||
return START_NOT_STICKY
|
return START_NOT_STICKY
|
||||||
@@ -236,7 +243,7 @@ class TripRecordingService : Service() {
|
|||||||
|
|
||||||
// ── Trip control ─────────────────────────────────────────────────────────
|
// ── 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
|
// 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().
|
// are delivered reliably when the screen is off. Released in stopTrip().
|
||||||
val pm = getSystemService(POWER_SERVICE) as PowerManager
|
val pm = getSystemService(POWER_SERVICE) as PowerManager
|
||||||
@@ -260,7 +267,7 @@ class TripRecordingService : Service() {
|
|||||||
|
|
||||||
// Insert placeholder trip row and get the auto-generated ID
|
// Insert placeholder trip row and get the auto-generated ID
|
||||||
serviceScope.launch {
|
serviceScope.launch {
|
||||||
currentTripId = repository.insertTrip(startTime = startTime)
|
currentTripId = repository.insertTrip(startTime = startTime, sessionId = sessionId)
|
||||||
TripServiceBus.update {
|
TripServiceBus.update {
|
||||||
copy(
|
copy(
|
||||||
isRecording = true,
|
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.GpsOff
|
||||||
import androidx.compose.material.icons.filled.Sensors
|
import androidx.compose.material.icons.filled.Sensors
|
||||||
import androidx.compose.material.icons.filled.SensorsOff
|
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.Wifi
|
||||||
import androidx.compose.material.icons.filled.WifiOff
|
import androidx.compose.material.icons.filled.WifiOff
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
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.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
|
import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
|
||||||
|
import com.hawhamburg.micr0bu.data.transport.UsbSerialState
|
||||||
import com.hawhamburg.micr0bu.viewmodel.SensorUiState
|
import com.hawhamburg.micr0bu.viewmodel.SensorUiState
|
||||||
|
|
||||||
private val GreenActive = Color(0xFF4CAF50)
|
private val GreenActive = Color(0xFF4CAF50)
|
||||||
@@ -46,7 +49,12 @@ private val GrayInactive = Color(0xFF555E6A)
|
|||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun StatusTopBar(state: SensorUiState, mqttConnectionState: MqttConnectionState) {
|
fun StatusTopBar(
|
||||||
|
state: SensorUiState,
|
||||||
|
mqttConnectionState: MqttConnectionState,
|
||||||
|
isEsp32: Boolean = false,
|
||||||
|
usbSerialState: UsbSerialState = UsbSerialState.DISCONNECTED,
|
||||||
|
) {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = {
|
||||||
Text(
|
Text(
|
||||||
@@ -85,7 +93,7 @@ fun StatusTopBar(state: SensorUiState, mqttConnectionState: MqttConnectionState)
|
|||||||
)
|
)
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
|
|
||||||
ObuStatusIcon(mqttConnectionState)
|
ObuStatusIcon(mqttConnectionState, isEsp32, usbSerialState)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
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
|
@Composable
|
||||||
private fun ObuStatusIcon(state: MqttConnectionState) {
|
private fun ObuStatusIcon(
|
||||||
val icon = when (state) {
|
mqttState: MqttConnectionState,
|
||||||
MqttConnectionState.CONNECTED,
|
isEsp32: Boolean,
|
||||||
MqttConnectionState.CONNECTING -> Icons.Default.Wifi
|
usbSerialState: UsbSerialState,
|
||||||
MqttConnectionState.ERROR,
|
) {
|
||||||
MqttConnectionState.DISCONNECTED -> Icons.Default.WifiOff
|
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) {
|
val tint = when (state) {
|
||||||
MqttConnectionState.CONNECTED -> GreenActive
|
MqttConnectionState.CONNECTED -> GreenActive
|
||||||
@@ -167,3 +189,15 @@ private fun RecordingPulse() {
|
|||||||
.background(Color(0xFFFF5252).copy(alpha = alpha)),
|
.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)
|
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(
|
private val bottomNavItems = listOf(
|
||||||
Screen.Dashboard,
|
Screen.Dashboard,
|
||||||
Screen.Sensors,
|
|
||||||
Screen.Record,
|
Screen.Record,
|
||||||
Screen.TripHistory,
|
Screen.TripHistory,
|
||||||
Screen.MqttViewer,
|
Screen.MqttViewer,
|
||||||
|
|||||||
@@ -530,6 +530,14 @@ private fun ReceivedCamRow(
|
|||||||
fontFamily = FontFamily.Monospace,
|
fontFamily = FontFamily.Monospace,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
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(
|
||||||
text = timeFormat.format(Date(cam.timestamp)),
|
text = timeFormat.format(Date(cam.timestamp)),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
|||||||
@@ -45,6 +45,13 @@ import com.hawhamburg.micr0bu.viewmodel.SensorUiState
|
|||||||
fun RecordingScreen(
|
fun RecordingScreen(
|
||||||
state: SensorUiState,
|
state: SensorUiState,
|
||||||
mqttConnectionState: MqttConnectionState,
|
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,
|
tripServiceState: TripServiceBus.State,
|
||||||
showBatteryOptPrompt: Boolean,
|
showBatteryOptPrompt: Boolean,
|
||||||
onToggleRecording: () -> Unit,
|
onToggleRecording: () -> Unit,
|
||||||
@@ -213,7 +220,7 @@ fun RecordingScreen(
|
|||||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
|
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
|
||||||
StreamRow(stringResource(R.string.stream_baro), state.barometerEnabled && state.pressureHpa != null)
|
StreamRow(stringResource(R.string.stream_baro), state.barometerEnabled && state.pressureHpa != null)
|
||||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
|
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)
|
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
|
||||||
StreamRow(stringResource(R.string.rec_stream_event_detection), tripServiceState.isRecording)
|
StreamRow(stringResource(R.string.rec_stream_event_detection), tripServiceState.isRecording)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -397,11 +397,16 @@ fun UseCaseAlertsSettingsScreen(
|
|||||||
fun DeveloperSettingsScreen(
|
fun DeveloperSettingsScreen(
|
||||||
state: SensorUiState,
|
state: SensorUiState,
|
||||||
onDeveloperMode: (Boolean) -> Unit,
|
onDeveloperMode: (Boolean) -> Unit,
|
||||||
|
onOpenSensorMonitor: () -> Unit,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
) {
|
) {
|
||||||
SubScreen(stringResource(R.string.settings_developer), onBack) {
|
SubScreen(stringResource(R.string.settings_developer), onBack) {
|
||||||
SectionCard {
|
SectionCard {
|
||||||
SettingToggleRow(stringResource(R.string.settings_dev_mode), state.developerMode, onDeveloperMode)
|
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) {
|
if (state.developerMode) {
|
||||||
Divider()
|
Divider()
|
||||||
DisabledRow(stringResource(R.string.settings_wifi), stringResource(R.string.settings_wifi_val))
|
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.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material.icons.filled.Share
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -33,6 +34,7 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -51,6 +53,7 @@ fun TripHistoryScreen(
|
|||||||
onOpenTrip: (Long) -> Unit,
|
onOpenTrip: (Long) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
val trips by viewModel.trips.collectAsState(initial = emptyList())
|
val trips by viewModel.trips.collectAsState(initial = emptyList())
|
||||||
var pendingDelete by remember { mutableStateOf<RecordedTripEntity?>(null) }
|
var pendingDelete by remember { mutableStateOf<RecordedTripEntity?>(null) }
|
||||||
|
|
||||||
@@ -99,6 +102,7 @@ fun TripHistoryScreen(
|
|||||||
index = trips.size - index,
|
index = trips.size - index,
|
||||||
onOpen = { onOpenTrip(trip.id) },
|
onOpen = { onOpenTrip(trip.id) },
|
||||||
onDelete = { pendingDelete = trip },
|
onDelete = { pendingDelete = trip },
|
||||||
|
onExport = { viewModel.exportTrip(context, trip.id) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -112,6 +116,7 @@ private fun TripCard(
|
|||||||
index: Int,
|
index: Int,
|
||||||
onOpen: () -> Unit,
|
onOpen: () -> Unit,
|
||||||
onDelete: () -> Unit,
|
onDelete: () -> Unit,
|
||||||
|
onExport: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -146,6 +151,13 @@ private fun TripCard(
|
|||||||
IconButton(onClick = onOpen) {
|
IconButton(onClick = onOpen) {
|
||||||
Icon(Icons.Default.Map, contentDescription = stringResource(R.string.trip_review_title), tint = MaterialTheme.colorScheme.primary)
|
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) {
|
IconButton(onClick = onDelete) {
|
||||||
Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.log_delete_cd), tint = MaterialTheme.colorScheme.error)
|
Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.log_delete_cd), tint = MaterialTheme.colorScheme.error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,13 +112,14 @@ fun V2xLiveMapView(
|
|||||||
update = { mv ->
|
update = { mv ->
|
||||||
mv.overlays.clear()
|
mv.overlays.clear()
|
||||||
|
|
||||||
// Own marker — distinct from remotes via a dedicated title prefix; osmdroid
|
// Own position: a centred "you are here" dot, not a pin. Own position is a fact
|
||||||
// doesn't tint default pins per-instance without a custom drawable, so color
|
// about the viewer rather than one of the tracked objects, and when both used
|
||||||
// differentiation for now relies on the title label shown on tap.
|
// osmdroid's identical default pin the two were indistinguishable at a glance.
|
||||||
mv.overlays.add(
|
mv.overlays.add(
|
||||||
Marker(mv).apply {
|
Marker(mv).apply {
|
||||||
position = ownGeoPoint
|
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)
|
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)
|
AlertLevel.INFO -> context.getString(R.string.v2x_map_remote_info, stationId)
|
||||||
null -> context.getString(R.string.v2x_map_remote_plain, 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(
|
mv.overlays.add(
|
||||||
Marker(mv).apply {
|
Marker(mv).apply {
|
||||||
position = GeoPoint(cam.latitude, cam.longitude)
|
position = GeoPoint(cam.latitude, cam.longitude)
|
||||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
||||||
|
icon = pin
|
||||||
title = label
|
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
|
@Composable
|
||||||
private fun NoFixPlaceholder(modifier: Modifier) {
|
private fun NoFixPlaceholder(modifier: Modifier) {
|
||||||
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
|||||||
@@ -93,6 +93,13 @@ class SensorViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
|
|
||||||
private var recordingTimerJob: Job? = null
|
private var recordingTimerJob: Job? = null
|
||||||
private var recordingSessionId: String = ""
|
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 var csvWriter: BufferedWriter? = null
|
||||||
|
|
||||||
private val isoFmt = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US)
|
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.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.hawhamburg.micr0bu.data.TripRepository
|
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.AppDatabase
|
||||||
import com.hawhamburg.micr0bu.data.db.DetectedEventEntity
|
import com.hawhamburg.micr0bu.data.db.DetectedEventEntity
|
||||||
import com.hawhamburg.micr0bu.data.db.RecordedTripEntity
|
import com.hawhamburg.micr0bu.data.db.RecordedTripEntity
|
||||||
@@ -27,7 +28,7 @@ import kotlinx.coroutines.launch
|
|||||||
*/
|
*/
|
||||||
class TripRecordingViewModel(application: Application) : AndroidViewModel(application) {
|
class TripRecordingViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
|
|
||||||
private val repository = TripRepository(AppDatabase.getInstance(application))
|
private val repository = TripRepository(AppDatabase.getInstance(application), application)
|
||||||
|
|
||||||
// ── Battery optimisation prompt ───────────────────────────────────────────
|
// ── Battery optimisation prompt ───────────────────────────────────────────
|
||||||
// Shown once on the Recording screen to ask the user to exempt the app so
|
// 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,
|
* Starts the foreground recording service if not already running,
|
||||||
* or stops it if a trip is already active.
|
* or stops it if a trip is already active.
|
||||||
*/
|
*/
|
||||||
fun toggleRecording() {
|
fun toggleRecording(sessionId: String? = null) {
|
||||||
if (serviceState.value.isRecording) stopRecording() else startRecording()
|
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)
|
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)
|
getApplication<Application>().startForegroundService(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,4 +115,26 @@ class TripRecordingViewModel(application: Application) : AndroidViewModel(applic
|
|||||||
fun deleteTrip(tripId: Long) {
|
fun deleteTrip(tripId: Long) {
|
||||||
viewModelScope.launch { repository.deleteTrip(tripId) }
|
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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<!--
|
||||||
|
Live-map marker for the ego bike's own position.
|
||||||
|
|
||||||
|
Deliberately a "you are here" dot rather than a pin: own position is a fact about the viewer,
|
||||||
|
not one of the objects being tracked, and giving it the same pin shape as remote stations made
|
||||||
|
the two indistinguishable at a glance on a crowded map. White ring keeps it readable over dark
|
||||||
|
map features.
|
||||||
|
-->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M12,12m-10,0a10,10 0 1,0 20,0a10,10 0 1,0 -20,0" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF1E88E5"
|
||||||
|
android:pathData="M12,12m-7.5,0a7.5,7.5 0 1,0 15,0a7.5,7.5 0 1,0 -15,0" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M12,12m-3,0a3,3 0 1,0 6,0a3,3 0 1,0 -6,0" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<!--
|
||||||
|
Live-map marker for a remote station heard over CAM.
|
||||||
|
|
||||||
|
A teardrop pin, anchored at its tip, so it clearly points at a location rather than covering it
|
||||||
|
the way the ego dot does. The body is drawn in plain white and tinted at runtime by the
|
||||||
|
station's most severe active alert level (see V2xLiveMapView) - which is why the fill here is
|
||||||
|
white rather than a fixed colour: setTint on a coloured fill would muddy it.
|
||||||
|
The dark outline is drawn first so the tinted body always sits inside a readable border.
|
||||||
|
-->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="30dp"
|
||||||
|
android:height="30dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
|
||||||
|
<!-- Outline -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF1A1A1A"
|
||||||
|
android:pathData="M12,0.8C7.4,0.8 3.7,4.5 3.7,9.1c0,6.2 8.3,14.1 8.3,14.1s8.3,-7.9 8.3,-14.1C20.3,4.5 16.6,0.8 12,0.8z" />
|
||||||
|
|
||||||
|
<!-- Body: tinted at runtime -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M12,2.4C8.3,2.4 5.3,5.4 5.3,9.1c0,5.2 6.7,11.7 6.7,11.7s6.7,-6.5 6.7,-11.7C18.7,5.4 15.7,2.4 12,2.4z" />
|
||||||
|
|
||||||
|
<!-- Inner dot, so the pin still reads as a marker once tinted -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF1A1A1A"
|
||||||
|
android:pathData="M12,9.1m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
|
||||||
|
</vector>
|
||||||
@@ -110,6 +110,7 @@
|
|||||||
<string name="log_session_number">Sitzung #%1$d</string>
|
<string name="log_session_number">Sitzung #%1$d</string>
|
||||||
<string name="log_share_cd">CSV teilen</string>
|
<string name="log_share_cd">CSV teilen</string>
|
||||||
<string name="log_save_cd">Auf Gerät speichern</string>
|
<string name="log_save_cd">Auf Gerät speichern</string>
|
||||||
|
<string name="trip_export_cd">Fahrt als CSV exportieren</string>
|
||||||
<string name="log_delete_cd">Sitzung löschen</string>
|
<string name="log_delete_cd">Sitzung löschen</string>
|
||||||
<string name="log_empty_title">Noch keine Sitzungen</string>
|
<string name="log_empty_title">Noch keine Sitzungen</string>
|
||||||
<string name="log_empty_desc">Gehe zu Aufnahme und starte eine Sitzung</string>
|
<string name="log_empty_desc">Gehe zu Aufnahme und starte eine Sitzung</string>
|
||||||
@@ -211,6 +212,7 @@
|
|||||||
<string name="v2x_cam_rx_kinematics">%1$.1f km/h · Kurs %2$.0f°</string>
|
<string name="v2x_cam_rx_kinematics">%1$.1f km/h · Kurs %2$.0f°</string>
|
||||||
<string name="v2x_cam_rx_distance">%1$.0f m</string>
|
<string name="v2x_cam_rx_distance">%1$.0f m</string>
|
||||||
<string name="v2x_cam_rx_distance_unknown">— m</string>
|
<string name="v2x_cam_rx_distance_unknown">— m</string>
|
||||||
|
<string name="v2x_cam_rx_rssi">%1$d dBm</string>
|
||||||
|
|
||||||
<!-- DENM-Kartenmarker -->
|
<!-- DENM-Kartenmarker -->
|
||||||
<string name="v2x_map_denm_labeled">Gefahr: Ursache %1$d/%2$d (Station %3$d)</string>
|
<string name="v2x_map_denm_labeled">Gefahr: Ursache %1$d/%2$d (Station %3$d)</string>
|
||||||
|
|||||||
@@ -111,6 +111,7 @@
|
|||||||
<string name="log_session_number">Session #%1$d</string>
|
<string name="log_session_number">Session #%1$d</string>
|
||||||
<string name="log_share_cd">Share CSV</string>
|
<string name="log_share_cd">Share CSV</string>
|
||||||
<string name="log_save_cd">Save to device</string>
|
<string name="log_save_cd">Save to device</string>
|
||||||
|
<string name="trip_export_cd">Export trip CSV</string>
|
||||||
<string name="log_delete_cd">Delete session</string>
|
<string name="log_delete_cd">Delete session</string>
|
||||||
<string name="log_empty_title">No sessions yet</string>
|
<string name="log_empty_title">No sessions yet</string>
|
||||||
<string name="log_empty_desc">Go to Record and start a session</string>
|
<string name="log_empty_desc">Go to Record and start a session</string>
|
||||||
@@ -212,6 +213,7 @@
|
|||||||
<string name="v2x_cam_rx_kinematics">%1$.1f km/h · heading %2$.0f°</string>
|
<string name="v2x_cam_rx_kinematics">%1$.1f km/h · heading %2$.0f°</string>
|
||||||
<string name="v2x_cam_rx_distance">%1$.0f m</string>
|
<string name="v2x_cam_rx_distance">%1$.0f m</string>
|
||||||
<string name="v2x_cam_rx_distance_unknown">— m</string>
|
<string name="v2x_cam_rx_distance_unknown">— m</string>
|
||||||
|
<string name="v2x_cam_rx_rssi">%1$d dBm</string>
|
||||||
|
|
||||||
<!-- DENM map pins -->
|
<!-- DENM map pins -->
|
||||||
<string name="v2x_map_denm_labeled">Hazard: cause %1$d/%2$d (station %3$d)</string>
|
<string name="v2x_map_denm_labeled">Hazard: cause %1$d/%2$d (station %3$d)</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user