Add a V2X data logger to Settings > Developer

Records every received V2X message (CAM, DENM, SPATEM, MAPEM, VAM) into one
SQLite file per run, with a timestamp, the phone's GNSS fix, the OBU's GNSS fix
where there is one (CiT One), and RSSI where the hardware reports it (ESP32-C5).
Works on both hardware paths: V2X_RX frames from the ESP32 link, and the raw
v2x/rx plus processed v2x-uca topics over MQTT. The UPER bytes are stored
untouched; CAM, DENM and SPATEM also get a few decoded columns.

A foreground service keeps it recording with the screen locked. Subscribes to
v2x/rx/mapem so MAPEM is received on the CiT One.

The current ESP32-C5 firmware forwards only BTP ports 2001, 2002 and 2004, so
MAPEM and VAM appear in an ESP32 log only once it accepts 2003 and 2018.

Checked on a Pixel 9 Pro against a simulated car and an RSU: 10 minutes with the
screen locked and forced deep Doze, no gaps, files pass integrity_check.
This commit is contained in:
Ashin Walpola
2026-10-02 16:16:02 +02:00
parent d3fc9bf66c
commit c13c3891c9
17 changed files with 1336 additions and 5 deletions
+19
View File
@@ -5,6 +5,25 @@ Engineering to-do list. The reviewer-facing open items live in
## Waiting on hardware
### On-air check of the V2X data logger (added 2026-10-02)
Needs the phone, a second station sending (the simulated car works) and, for the CiT One part, the OBU.
Settings > Developer > V2X data logger. 110 unit tests pass. ESP32-C5 path checked on the Pixel 9 Pro on
2026-10-02 (see the ticked item); the rest has not run on a device.
- [x] **ESP32-C5 (done 2026-10-02, simulated car + RSU on air):** 153 CAMs and 60 SPATEMs in 30 s, RSSI -56..-36 dBm,
phone GNSS on every row, `obu_*` NULL, file integrity ok. MAPEM 0 as expected (firmware drops port 2003). Original steps: start a log, let the simulated car's CAMs arrive, stop. Pull the `.db` from
`files/v2x_logs/` and check `messages`: `msg_type='CAM'`, `source='esp32'`, `rssi_dbm` filled,
`phone_lat/lon` filled, `obu_*` NULL, `payload` decodes with `asn1tools`.
- [ ] **CiT One:** same, `source='cit_one'`, `rssi_dbm` NULL, `obu_*` filled from `v2x/rx/obu_gnss`.
Expect both `channel='raw'` and `channel='processed'` rows for the same CAM.
- [ ] **Screen off for a few minutes** with the log running: rows keep arriving (foreground service).
- [ ] **MAPEM and VAM on the ESP32-C5 need a firmware change.** `gn_unwrap.c` accepts only BTP ports
2001, 2002, 2004; add 2003 (MAPEM) and 2018 (VAM) and reflash. Real MAPEMs are mostly over the
512-byte link limit, so most will still be counted as oversize drops. The app side already logs
any port. On the CiT One, MAPEM arrives via `v2x/rx/mapem` (subscribed now) or the processed
`v2x-uca/output/json/map` topic; the API lists no VAM topic, so no VAM rows are expected there.
### Signed-TX firmware (vanetza-idf port), VAM and BLE: first on-air checks (added 2026-09-23)
obu-firmware is now a port of the colleague's `microbu-esp32c5` station (vanetza-idf, TS 103 097
+6
View File
@@ -72,6 +72,12 @@
android:foregroundServiceType="location"
android:exported="false" />
<!-- Developer V2X data logger: keeps the process and GNSS alive while a log records. -->
<service
android:name=".service.V2xLogService"
android:foregroundServiceType="location"
android:exported="false" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
@@ -56,10 +56,12 @@ import com.hawhamburg.micr0bu.ui.screens.SensorsSettingsScreen
import com.hawhamburg.micr0bu.ui.screens.SettingsScreen
import com.hawhamburg.micr0bu.ui.screens.UnitsSettingsScreen
import com.hawhamburg.micr0bu.ui.screens.UseCaseAlertsSettingsScreen
import com.hawhamburg.micr0bu.ui.screens.V2xLogSettingsScreen
import com.hawhamburg.micr0bu.ui.theme.MicrOBUTheme
import com.hawhamburg.micr0bu.viewmodel.MqttViewModel
import com.hawhamburg.micr0bu.viewmodel.SensorViewModel
import com.hawhamburg.micr0bu.viewmodel.TripRecordingViewModel
import com.hawhamburg.micr0bu.viewmodel.V2xLogViewModel
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
@@ -68,6 +70,7 @@ class MainActivity : AppCompatActivity() {
private val viewModel: SensorViewModel by viewModels()
private val mqttViewModel: MqttViewModel by viewModels()
private val tripViewModel: TripRecordingViewModel by viewModels()
private val logViewModel: V2xLogViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
@@ -99,6 +102,8 @@ class MainActivity : AppCompatActivity() {
val denmEvents by mqttViewModel.denmEvents.collectAsState()
val spatIntersections by mqttViewModel.spatIntersections.collectAsState()
val ownCamPosition by mqttViewModel.ownCamPosition.collectAsState()
val v2xLogState by logViewModel.state.collectAsState()
val v2xLogFiles by logViewModel.files.collectAsState()
MicrOBUTheme(darkTheme = state.darkTheme) {
val view = LocalView.current
@@ -365,9 +370,21 @@ class MainActivity : AppCompatActivity() {
state = state,
onDeveloperMode = viewModel::setDeveloperMode,
onOpenSensorMonitor = { navController.navigate(Screen.Sensors.route) },
onOpenV2xLogger = { navController.navigate(Screen.SettingsV2xLog.route) },
onBack = { navController.popBackStack() },
)
}
composable(Screen.SettingsV2xLog.route) {
V2xLogSettingsScreen(
state = v2xLogState,
files = v2xLogFiles,
obuHardware = obuHardware,
onStart = logViewModel::start,
onStop = logViewModel::stop,
onDelete = logViewModel::delete,
onBack = { navController.popBackStack() },
)
}
composable(Screen.SettingsAbout.route) {
AboutSettingsScreen(onBack = { navController.popBackStack() })
}
@@ -9,6 +9,7 @@ import com.hawhamburg.micr0bu.data.mqtt.MqttRepository
import com.hawhamburg.micr0bu.data.mqtt.ObuHardwarePreferences
import com.hawhamburg.micr0bu.data.mqtt.RAW_CAM_TOPIC
import com.hawhamburg.micr0bu.data.mqtt.RAW_DENM_TOPIC
import com.hawhamburg.micr0bu.data.mqtt.RAW_MAPEM_TOPIC
import com.hawhamburg.micr0bu.data.mqtt.RAW_SPATEM_TOPIC
import com.hawhamburg.micr0bu.data.mqtt.RecvV2xMessage
import com.hawhamburg.micr0bu.data.mqtt.UseCaseAlertPreferences
@@ -241,6 +242,8 @@ class CamUseCaseRepository @Inject constructor(
source = "mqtt",
)
RAW_SPATEM_TOPIC -> handleSpatUper(envelope.payload, rssiDbm = null, source = "mqtt")
// No MAPEM decoder yet; it is received for the V2X data logger only.
RAW_MAPEM_TOPIC -> Unit
else -> Log.w(TAG, "rawV2x: unexpected topic ${raw.topic}")
}
}
@@ -0,0 +1,235 @@
package com.hawhamburg.micr0bu.data.log
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteStatement
import com.hawhamburg.micr0bu.domain.log.ItsMessageKind
import java.io.File
/** One GNSS fix, as stored next to a logged message. Phone and OBU fixes share this shape. */
data class LogGnss(
val latitude: Double,
val longitude: Double,
val altitudeM: Double? = null,
val speedMps: Double? = null,
val headingDeg: Double? = null,
val accuracyM: Double? = null,
/** Epoch ms the fix was taken, so a stale fix shows as one instead of passing for current. */
val fixMs: Long,
)
/** One received V2X message, ready to be written. */
class LogMessage(
val tsMs: Long,
/** `esp32` or `cit_one`: which link delivered it. */
val source: String,
/** `raw` (UPER bytes in [payload]) or `processed` (the Use Case app's JSON in [payloadText]). */
val channel: String,
val kind: ItsMessageKind,
val messageId: Int?,
val btpPort: Int?,
val stationId: Long?,
val isOwn: Boolean?,
val rssiDbm: Int?,
val geoRadiusM: Int?,
val senderLat: Double?,
val senderLon: Double?,
val senderSpeedMps: Double?,
val senderHeadingDeg: Double?,
val summary: String?,
val payload: ByteArray?,
val payloadText: String?,
val phone: LogGnss?,
val obu: LogGnss?,
)
/** One GNSS fix for the `gnss` table: the receiver's own track, independent of any message. */
class LogGnssSample(val tsMs: Long, val source: String, val fix: LogGnss)
/**
* A V2X log: one self-contained SQLite file. Not Room, on purpose: a log is a file the developer
* pulls off the phone and opens in any SQLite tool or pandas, so it carries its own schema and
* needs no migration path or app-side entity classes. The app never reads one back.
*
* Not thread-safe; [V2xLogger] drives it from one writer coroutine. Each [writeBatch] is one
* transaction on the default rollback journal, so a killed process leaves a file that is
* complete up to the last batch, and a single file with no `-wal` sidecar to forget when sharing.
*
* Columns that do not apply to a row are NULL rather than invented: `rssi_dbm` is NULL for the
* CiT One, `obu_*` is NULL when no OBU position is available (always on the ESP32-C5, which has no
* GNSS), and the `sender_*` fields are NULL for messages the app has no decoder for.
*/
class V2xLogWriter(val file: File, meta: Map<String, String>) {
private val db: SQLiteDatabase = SQLiteDatabase.openOrCreateDatabase(file, null)
private val insertMessage: SQLiteStatement
private val insertGnss: SQLiteStatement
init {
db.execSQL(
"""
CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT)
""".trimIndent()
)
db.execSQL(
"""
CREATE TABLE messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts_ms INTEGER NOT NULL,
source TEXT NOT NULL,
channel TEXT NOT NULL,
msg_type TEXT NOT NULL,
message_id INTEGER,
btp_port INTEGER,
station_id INTEGER,
is_own INTEGER,
rssi_dbm INTEGER,
geo_radius_m INTEGER,
sender_lat REAL,
sender_lon REAL,
sender_speed_mps REAL,
sender_heading_deg REAL,
summary TEXT,
payload BLOB,
payload_text TEXT,
phone_lat REAL,
phone_lon REAL,
phone_alt_m REAL,
phone_speed_mps REAL,
phone_heading_deg REAL,
phone_accuracy_m REAL,
phone_fix_ms INTEGER,
obu_lat REAL,
obu_lon REAL,
obu_alt_m REAL,
obu_speed_mps REAL,
obu_heading_deg REAL,
obu_fix_ms INTEGER
)
""".trimIndent()
)
db.execSQL("CREATE INDEX idx_messages_ts ON messages(ts_ms)")
db.execSQL("CREATE INDEX idx_messages_type ON messages(msg_type)")
db.execSQL(
"""
CREATE TABLE gnss (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts_ms INTEGER NOT NULL,
source TEXT NOT NULL,
lat REAL NOT NULL,
lon REAL NOT NULL,
alt_m REAL,
speed_mps REAL,
heading_deg REAL,
accuracy_m REAL,
fix_ms INTEGER NOT NULL
)
""".trimIndent()
)
db.execSQL("CREATE INDEX idx_gnss_ts ON gnss(ts_ms)")
// Same rows, with the timestamp also as readable UTC, for anyone browsing in a SQLite GUI.
db.execSQL(
"CREATE VIEW messages_v AS SELECT *, " +
"strftime('%Y-%m-%d %H:%M:%f', ts_ms / 1000.0, 'unixepoch') AS ts_utc FROM messages"
)
db.execSQL("PRAGMA user_version = $SCHEMA_VERSION")
for ((k, v) in meta) setMeta(k, v)
setMeta("schema_version", SCHEMA_VERSION.toString())
insertMessage = db.compileStatement(
"INSERT INTO messages (ts_ms, source, channel, msg_type, message_id, btp_port, station_id, " +
"is_own, rssi_dbm, geo_radius_m, sender_lat, sender_lon, sender_speed_mps, " +
"sender_heading_deg, summary, payload, payload_text, " +
"phone_lat, phone_lon, phone_alt_m, phone_speed_mps, phone_heading_deg, " +
"phone_accuracy_m, phone_fix_ms, obu_lat, obu_lon, obu_alt_m, obu_speed_mps, " +
"obu_heading_deg, obu_fix_ms) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)"
)
insertGnss = db.compileStatement(
"INSERT INTO gnss (ts_ms, source, lat, lon, alt_m, speed_mps, heading_deg, accuracy_m, fix_ms) " +
"VALUES (?,?,?,?,?,?,?,?,?)"
)
}
fun setMeta(key: String, value: String) {
db.execSQL("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)", arrayOf(key, value))
}
/** Writes [messages] and [gnss] in one transaction. */
fun writeBatch(messages: List<LogMessage>, gnss: List<LogGnssSample>) {
if (messages.isEmpty() && gnss.isEmpty()) return
db.beginTransaction()
try {
for (m in messages) bindAndRun(m)
for (g in gnss) bindAndRun(g)
db.setTransactionSuccessful()
} finally {
db.endTransaction()
}
}
fun close() {
runCatching { insertMessage.close() }
runCatching { insertGnss.close() }
runCatching { db.close() }
}
private fun bindAndRun(m: LogMessage) {
val s = insertMessage
s.clearBindings()
s.bind(1, m.tsMs)
s.bind(2, m.source)
s.bind(3, m.channel)
s.bind(4, m.kind.label)
s.bind(5, m.messageId?.toLong())
s.bind(6, m.btpPort?.toLong())
s.bind(7, m.stationId)
s.bind(8, m.isOwn?.let { if (it) 1L else 0L })
s.bind(9, m.rssiDbm?.toLong())
s.bind(10, m.geoRadiusM?.toLong())
s.bind(11, m.senderLat)
s.bind(12, m.senderLon)
s.bind(13, m.senderSpeedMps)
s.bind(14, m.senderHeadingDeg)
s.bind(15, m.summary)
s.bind(16, m.payload)
s.bind(17, m.payloadText)
s.bind(18, m.phone?.latitude)
s.bind(19, m.phone?.longitude)
s.bind(20, m.phone?.altitudeM)
s.bind(21, m.phone?.speedMps)
s.bind(22, m.phone?.headingDeg)
s.bind(23, m.phone?.accuracyM)
s.bind(24, m.phone?.fixMs)
s.bind(25, m.obu?.latitude)
s.bind(26, m.obu?.longitude)
s.bind(27, m.obu?.altitudeM)
s.bind(28, m.obu?.speedMps)
s.bind(29, m.obu?.headingDeg)
s.bind(30, m.obu?.fixMs)
s.executeInsert()
}
private fun bindAndRun(g: LogGnssSample) {
val s = insertGnss
s.clearBindings()
s.bind(1, g.tsMs)
s.bind(2, g.source)
s.bind(3, g.fix.latitude)
s.bind(4, g.fix.longitude)
s.bind(5, g.fix.altitudeM)
s.bind(6, g.fix.speedMps)
s.bind(7, g.fix.headingDeg)
s.bind(8, g.fix.accuracyM)
s.bind(9, g.fix.fixMs)
s.executeInsert()
}
// clearBindings() leaves every parameter NULL, so a null value is simply not bound.
private fun SQLiteStatement.bind(i: Int, v: Long?) { if (v != null) bindLong(i, v) }
private fun SQLiteStatement.bind(i: Int, v: Double?) { if (v != null) bindDouble(i, v) }
private fun SQLiteStatement.bind(i: Int, v: String?) { if (v != null) bindString(i, v) }
private fun SQLiteStatement.bind(i: Int, v: ByteArray?) { if (v != null) bindBlob(i, v) }
companion object {
const val SCHEMA_VERSION = 1
}
}
@@ -0,0 +1,413 @@
package com.hawhamburg.micr0bu.data.log
import android.content.Context
import android.os.Build
import android.util.Log
import com.hawhamburg.micr0bu.data.GnssReading
import com.hawhamburg.micr0bu.data.SensorRepository
import com.hawhamburg.micr0bu.data.cam.CamUseCaseRepository
import com.hawhamburg.micr0bu.data.mqtt.MessageDirection
import com.hawhamburg.micr0bu.data.mqtt.MqttRepository
import com.hawhamburg.micr0bu.data.mqtt.RAW_CAM_TOPIC
import com.hawhamburg.micr0bu.data.mqtt.RAW_DENM_TOPIC
import com.hawhamburg.micr0bu.data.mqtt.RAW_MAPEM_TOPIC
import com.hawhamburg.micr0bu.data.mqtt.RAW_SPATEM_TOPIC
import com.hawhamburg.micr0bu.data.mqtt.RecvV2xMessage
import com.hawhamburg.micr0bu.data.transport.Esp32Link
import com.hawhamburg.micr0bu.data.transport.Esp32LinkState
import com.hawhamburg.micr0bu.data.transport.ObuHardware
import com.hawhamburg.micr0bu.data.transport.SerialFrameType
import com.hawhamburg.micr0bu.data.transport.V2xRxFrame
import com.hawhamburg.micr0bu.domain.asn1.DenmUperCodec
import com.hawhamburg.micr0bu.domain.asn1.RealAsn1UperCodec
import com.hawhamburg.micr0bu.domain.asn1.SpatemUperCodec
import com.hawhamburg.micr0bu.domain.cam.ObuGnssParser
import com.hawhamburg.micr0bu.domain.log.ItsMessageKind
import com.hawhamburg.micr0bu.domain.log.classifyItsPdu
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import javax.inject.Inject
import javax.inject.Singleton
private const val TAG = "V2xLogger"
private const val LOG_DIR = "v2x_logs"
private const val PROCESSED_PREFIX = "v2x-uca/output/json/"
private const val OBU_GNSS_TOPIC = "v2x/rx/obu_gnss"
private const val SOURCE_ESP32 = "esp32"
private const val SOURCE_CIT_ONE = "cit_one"
private const val SOURCE_PHONE = "phone"
private const val SOURCE_OBU = "obu"
/** How long the writer collects rows before it commits them. */
private const val FLUSH_INTERVAL_MS = 250L
/** What the logging screen shows about the log in progress. */
data class V2xLogState(
val active: Boolean = false,
val fileName: String? = null,
val startedMs: Long = 0L,
/** Rows written so far, by kind. Kinds never heard are absent. */
val counts: Map<ItsMessageKind, Int> = emptyMap(),
val total: Int = 0,
val fileBytes: Long = 0L,
/** Age of the newest phone / OBU fix at the time of the last update; null if none yet. */
val phoneFixAgeMs: Long? = null,
val obuFixAgeMs: Long? = null,
/** Set when the file could not be created or written. */
val error: String? = null,
)
/** A log file on the device. */
data class V2xLogFile(val file: File, val sizeBytes: Long, val modifiedMs: Long, val active: Boolean) {
val name: String get() = file.name
}
/**
* Developer-tool logger: records every V2X message the phone receives, with a timestamp, the
* phone's GNSS fix, the OBU's GNSS fix where there is one, and RSSI where the hardware reports it,
* into one SQLite file per run (see [V2xLogWriter] for the schema).
*
* Works on both hardware paths and listens to whichever is selected:
* - **ESP32-C5**: the V2X_RX frames from [Esp32Link], with the firmware's RSSI. No OBU position,
* since that board has no GNSS.
* - **CiT One**: the raw `v2x/rx` protobuf topics over MQTT, and the Use Case app's processed
* `v2x-uca/output/json` topics as a second channel (the only source of MAPEM when the OBU does
* not publish `v2x/rx/mapem`). The OBU's position comes from `v2x/rx/obu_gnss`. The API carries
* no RSSI, so that column stays NULL.
*
* Every message is stored, including kinds the app cannot decode (MAPEM, VAM): the UPER bytes go
* in the file untouched, and where a decoder exists a few fields are decoded alongside for
* convenience. A message is never skipped because decoding failed.
*
* What it can hear is bounded by the hardware, not by this class: the current ESP32-C5 firmware
* forwards only BTP ports 2001, 2002 and 2004 (see `gn_unwrap.c`), so MAPEM and VAM appear in an
* ESP32 log only once the firmware accepts ports 2003 and 2018.
*
* A singleton, like the repositories it listens to. [com.hawhamburg.micr0bu.service.V2xLogService]
* keeps the process alive and the CPU awake while a log runs; this class does the work.
*/
@Singleton
class V2xLogger @Inject constructor(
@ApplicationContext private val context: Context,
private val mqttRepository: MqttRepository,
private val esp32Link: Esp32Link,
private val camUseCaseRepository: CamUseCaseRepository,
private val camCodec: RealAsn1UperCodec,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val _state = MutableStateFlow(V2xLogState())
val state: StateFlow<V2xLogState> = _state.asStateFlow()
private val _files = MutableStateFlow<List<V2xLogFile>>(emptyList())
/** Logs on the device, newest first. Call [refreshFiles] after anything that changes the folder. */
val files: StateFlow<List<V2xLogFile>> = _files.asStateFlow()
private val logDir: File get() = File(context.filesDir, LOG_DIR).apply { mkdirs() }
private var session: Job? = null
private var rows = Channel<Any>(Channel.UNLIMITED)
@Volatile private var phoneFix: LogGnss? = null
@Volatile private var obuFix: LogGnss? = null
fun start() {
if (session?.isActive == true) return
val startedMs = System.currentTimeMillis()
val hardware = mqttRepository.obuHardware.value
val file = File(logDir, fileNameFor(startedMs, hardware))
val writer = try {
V2xLogWriter(file, metaFor(startedMs, hardware))
} catch (e: Exception) {
Log.e(TAG, "cannot create ${file.name}", e)
file.delete()
_state.value = V2xLogState(error = e.message ?: e.javaClass.simpleName)
return
}
phoneFix = null
obuFix = null
rows = Channel(Channel.UNLIMITED)
_state.value = V2xLogState(active = true, fileName = file.name, startedMs = startedMs)
refreshFiles()
session = scope.launch {
val sources = listOf(
launch { collectEsp32() },
launch { collectCitOneRaw() },
launch { collectCitOneMessages() },
launch { collectPhoneGnss() },
)
try {
runWriter(writer, file)
} finally {
sources.forEach { it.cancel() }
runCatching { writer.setMeta("ended_ms", System.currentTimeMillis().toString()) }
writer.close()
}
}
}
/** Stops the log and waits for the last rows to reach the file. */
suspend fun stop() {
val running = session ?: return
// Closing the channel lets the writer drain everything already queued before it ends.
rows.close()
withTimeoutOrNull(5_000L) { running.join() } ?: running.cancelAndJoin()
session = null
_state.update { it.copy(active = false) }
refreshFiles()
}
fun delete(log: V2xLogFile): Boolean {
if (log.active) return false
val ok = log.file.delete()
refreshFiles()
return ok
}
fun refreshFiles() {
val activeName = _state.value.fileName.takeIf { _state.value.active }
_files.value = logDir.listFiles { f -> f.isFile && f.extension == "db" }
.orEmpty()
.map { V2xLogFile(it, it.length(), it.lastModified(), it.name == activeName) }
.sortedByDescending { it.modifiedMs }
}
// ── Sources ───────────────────────────────────────────────────────────────
private suspend fun collectEsp32() {
esp32Link.incomingFrames.collect { frame ->
if (frame.type != SerialFrameType.V2X_RX) return@collect
if (mqttRepository.obuHardware.value != ObuHardware.ESP32_C5) return@collect
if (esp32Link.state.value != Esp32LinkState.CONNECTED) return@collect
val v2x = V2xRxFrame.parse(frame.payload) ?: return@collect
enqueueRaw(
source = SOURCE_ESP32, uper = v2x.uper, btpPort = v2x.btpPort,
rssiDbm = v2x.rssiDbm, geoRadiusM = v2x.geoArea?.radiusMeters,
)
}
}
private suspend fun collectCitOneRaw() {
mqttRepository.rawV2x.collect { raw ->
if (mqttRepository.obuHardware.value != ObuHardware.CIT_ONE) return@collect
val envelope = RecvV2xMessage.parse(raw.bytes)
if (envelope == null) {
Log.w(TAG, "unparseable RecvV2XMessage on ${raw.topic}, ${raw.bytes.size} bytes")
return@collect
}
enqueueRaw(
source = SOURCE_CIT_ONE, uper = envelope.payload,
// The envelope's own port when it has one; the topic says the same thing otherwise.
btpPort = envelope.destinationPort ?: portOfRawTopic(raw.topic),
rssiDbm = null, geoRadiusM = envelope.destAreaRadiusM,
tsMs = raw.timestamp,
)
}
}
private suspend fun collectCitOneMessages() {
mqttRepository.messages.collect { msg ->
if (msg.direction != MessageDirection.RX) return@collect
if (mqttRepository.obuHardware.value != ObuHardware.CIT_ONE) return@collect
if (msg.topic == OBU_GNSS_TOPIC) {
val ego = ObuGnssParser.parseEgo(msg.payload, timestamp = msg.timestamp) ?: return@collect
val fix = LogGnss(
latitude = ego.latitude, longitude = ego.longitude,
speedMps = ego.speedMps, headingDeg = ego.headingDeg, fixMs = msg.timestamp,
)
obuFix = fix
rows.trySend(LogGnssSample(msg.timestamp, SOURCE_OBU, fix))
return@collect
}
if (!msg.topic.startsWith(PROCESSED_PREFIX)) return@collect
val kind = ItsMessageKind.fromProcessedTopicName(msg.topic.removePrefix(PROCESSED_PREFIX))
?: return@collect
rows.trySend(
LogMessage(
tsMs = msg.timestamp, source = SOURCE_CIT_ONE, channel = "processed", kind = kind,
messageId = null, btpPort = null, stationId = null, isOwn = null, rssiDbm = null,
geoRadiusM = null, senderLat = null, senderLon = null, senderSpeedMps = null,
senderHeadingDeg = null, summary = null, payload = null, payloadText = msg.payload,
phone = phoneFix, obu = obuFix,
)
)
}
}
/**
* Phone GNSS, re-subscribed in a loop for the same reason [CamUseCaseRepository] does: a log
* can be started before the location permission is granted, and a single attempt would then
* leave every row without a phone position for the whole run.
*/
private suspend fun collectPhoneGnss() {
val sensors = SensorRepository(context)
while (true) {
runCatching {
sensors.gnssFlow().collect { reading ->
val fix = reading.toLogGnss()
phoneFix = fix
rows.trySend(LogGnssSample(System.currentTimeMillis(), SOURCE_PHONE, fix))
}
}
delay(5_000L)
}
}
// ── Rows ──────────────────────────────────────────────────────────────────
private fun enqueueRaw(
source: String,
uper: ByteArray,
btpPort: Int?,
rssiDbm: Int?,
geoRadiusM: Int?,
tsMs: Long = System.currentTimeMillis(),
) {
val cls = classifyItsPdu(uper, btpPort)
val decoded = decode(cls.kind, uper, rssiDbm, geoRadiusM, tsMs)
rows.trySend(
LogMessage(
tsMs = tsMs, source = source, channel = "raw", kind = cls.kind,
messageId = cls.messageId, btpPort = btpPort, stationId = cls.stationId,
isOwn = cls.stationId?.let { camUseCaseRepository.isOwnStationId(it) },
rssiDbm = rssiDbm, geoRadiusM = geoRadiusM,
senderLat = decoded.lat, senderLon = decoded.lon,
senderSpeedMps = decoded.speedMps, senderHeadingDeg = decoded.headingDeg,
summary = decoded.summary, payload = uper, payloadText = null,
phone = phoneFix, obu = obuFix,
)
)
}
private class Decoded(
val lat: Double? = null, val lon: Double? = null,
val speedMps: Double? = null, val headingDeg: Double? = null,
val summary: String? = null,
)
/** Decoded convenience fields for the kinds the app can decode. A failure just leaves them NULL. */
private fun decode(kind: ItsMessageKind, uper: ByteArray, rssiDbm: Int?, geoRadiusM: Int?, tsMs: Long): Decoded =
runCatching {
when (kind) {
ItsMessageKind.CAM -> camCodec.decodeCam(uper, tsMs)?.let {
Decoded(it.latitude, it.longitude, it.speedMps, it.headingDeg, "stationType=${it.stationType}")
}
ItsMessageKind.DENM -> DenmUperCodec.decode(uper, tsMs, rssiDbm, geoRadiusM)?.let {
Decoded(
it.latitude, it.longitude,
summary = "seq=${it.sequenceNumber} cause=${it.causeCode}/${it.subCauseCode}" +
if (it.isTermination) " termination" else "",
)
}
ItsMessageKind.SPATEM -> SpatemUperCodec.decode(uper, tsMs, rssiDbm)?.let { s ->
Decoded(summary = "intersections=" + s.intersections.joinToString(",") { it.key } +
" movements=" + s.intersections.sumOf { it.movements.size })
}
else -> null
}
}.getOrNull() ?: Decoded()
// ── Writer ────────────────────────────────────────────────────────────────
/** Drains [rows] into [writer] in short batches, until the channel is closed and empty. */
private suspend fun runWriter(writer: V2xLogWriter, file: File) {
val counts = HashMap<ItsMessageKind, Int>()
var total = 0
var failure: String? = null
val messages = ArrayList<LogMessage>()
val gnss = ArrayList<LogGnssSample>()
while (true) {
// Block for the first row of a batch, give more a moment to arrive, then take
// whatever is queued. No timeout around receive: a cancelled receive can lose the
// element it was handing over, which a logger must not do.
val first = rows.receiveCatching().getOrNull() ?: break
add(first, messages, gnss)
delay(FLUSH_INTERVAL_MS)
while (true) {
val next = rows.tryReceive().getOrNull() ?: break
add(next, messages, gnss)
}
try {
writer.writeBatch(messages, gnss)
} catch (e: Exception) {
// Disk full or the file went away. Say so on screen rather than logging into a void.
Log.e(TAG, "write failed", e)
failure = e.message ?: e.javaClass.simpleName
rows.close()
break
}
for (m in messages) counts.merge(m.kind, 1, Int::plus)
total += messages.size
messages.clear(); gnss.clear()
val now = System.currentTimeMillis()
_state.update {
it.copy(
counts = counts.toMap(), total = total, fileBytes = file.length(),
phoneFixAgeMs = phoneFix?.let { f -> now - f.fixMs },
obuFixAgeMs = obuFix?.let { f -> now - f.fixMs },
)
}
}
_state.update { it.copy(fileBytes = file.length(), error = failure ?: it.error, active = it.active && failure == null) }
}
private fun add(row: Any, messages: MutableList<LogMessage>, gnss: MutableList<LogGnssSample>) {
when (row) {
is LogMessage -> messages += row
is LogGnssSample -> gnss += row
}
}
// ── Helpers ───────────────────────────────────────────────────────────────
private fun portOfRawTopic(topic: String): Int? = when (topic) {
RAW_CAM_TOPIC -> 2001
RAW_DENM_TOPIC -> 2002
RAW_MAPEM_TOPIC -> 2003
RAW_SPATEM_TOPIC -> 2004
else -> null
}
private fun GnssReading.toLogGnss() = LogGnss(
latitude = latitude, longitude = longitude, altitudeM = altitude,
speedMps = speedMs.toDouble(), headingDeg = bearingDeg.toDouble(),
accuracyM = accuracyM.toDouble(), fixMs = timestamp,
)
private fun fileNameFor(startedMs: Long, hardware: ObuHardware): String {
val stamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date(startedMs))
val mode = if (hardware == ObuHardware.ESP32_C5) "esp32" else "citone"
return "v2x_log_${stamp}_$mode.db"
}
private fun metaFor(startedMs: Long, hardware: ObuHardware) = mapOf(
"started_ms" to startedMs.toString(),
"hardware" to if (hardware == ObuHardware.ESP32_C5) "esp32_c5" else "cit_one",
"phone" to "${Build.MANUFACTURER} ${Build.MODEL}",
"android_sdk" to Build.VERSION.SDK_INT.toString(),
"app_version" to runCatching {
context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "?"
}.getOrDefault("?"),
"note" to "ts_ms is phone wall-clock epoch ms. payload is the ITS PDU as UPER bytes. " +
"rssi_dbm is NULL on the CiT One; obu_* is NULL on the ESP32-C5.",
)
}
@@ -48,6 +48,9 @@ private val SUBSCRIBED_TOPICS = listOf(
RAW_CAM_TOPIC,
RAW_DENM_TOPIC,
RAW_SPATEM_TOPIC,
// Nothing decodes MAPEM yet; subscribed so the V2X data logger records it. An OBU product
// configuration that does not publish it simply never delivers anything here.
RAW_MAPEM_TOPIC,
// Kept subscribed as a fallback for an OBU whose product configuration does not publish the
// raw topics, and because the Use Case app is still the only source of its own alert output.
"v2x-uca/output/json/cam",
@@ -61,8 +64,9 @@ private val SUBSCRIBED_TOPICS = listOf(
const val RAW_CAM_TOPIC = "v2x/rx/cam"
const val RAW_DENM_TOPIC = "v2x/rx/denm"
const val RAW_SPATEM_TOPIC = "v2x/rx/spatem"
const val RAW_MAPEM_TOPIC = "v2x/rx/mapem"
private val RAW_V2X_TOPICS = setOf(RAW_CAM_TOPIC, RAW_DENM_TOPIC, RAW_SPATEM_TOPIC)
private val RAW_V2X_TOPICS = setOf(RAW_CAM_TOPIC, RAW_DENM_TOPIC, RAW_SPATEM_TOPIC, RAW_MAPEM_TOPIC)
/**
* A message straight off a `v2x/rx` topic, before the protobuf envelope is opened.
@@ -0,0 +1,79 @@
package com.hawhamburg.micr0bu.domain.log
/**
* Which ITS message a received PDU is, for the V2X data logger.
*
* The logger records every message it hears, including ones the app has no decoder for (MAPEM,
* VAM), so classification cannot depend on a codec. It reads the two things every ETSI ITS PDU
* has in common instead: the `ItsPduHeader` at the start of the UPER bytes
* (`protocolVersion` 8 bits, `messageID` 8 bits, `stationID` 32 bits, none of it extensible, so
* it sits byte-aligned at the front), and the BTP destination port the transport reports.
*
* messageID is the primary source. The port is the fallback for a payload too short to carry a
* header. Mind the crossover: SPATEM is messageID 4 on port 2004 but MAPEM is messageID 5 on port
* 2003; the two numbering schemes are unrelated.
*/
enum class ItsMessageKind(val label: String, val messageId: Int, val btpPort: Int?) {
DENM("DENM", 1, 2002),
CAM("CAM", 2, 2001),
SPATEM("SPATEM", 4, 2004),
MAPEM("MAPEM", 5, 2003),
CPM("CPM", 14, null),
VAM("VAM", 16, 2018),
OTHER("OTHER", -1, null);
companion object {
/** The five kinds the logger counts and shows on screen. */
val TRACKED = listOf(CAM, DENM, SPATEM, MAPEM, VAM)
fun fromMessageId(id: Int): ItsMessageKind? = entries.firstOrNull { it.messageId == id && it != OTHER }
fun fromBtpPort(port: Int): ItsMessageKind? = entries.firstOrNull { it.btpPort == port }
/** Matches the `v2x-uca/output/json/<name>` suffix, for the CiT One's processed topics. */
fun fromProcessedTopicName(name: String): ItsMessageKind? = when (name) {
"cam" -> CAM
"denm" -> DENM
"spat" -> SPATEM
"map" -> MAPEM
"cpm" -> CPM
else -> null
}
}
}
/** The fixed 6-byte header at the start of every ETSI ITS PDU. */
data class ItsPduHeader(val protocolVersion: Int, val messageId: Int, val stationId: Long) {
companion object {
const val SIZE = 6
/** Null when [uper] is too short to hold a header. */
fun parse(uper: ByteArray): ItsPduHeader? {
if (uper.size < SIZE) return null
var station = 0L
for (i in 2 until SIZE) station = (station shl 8) or (uper[i].toLong() and 0xFF)
return ItsPduHeader(
protocolVersion = uper[0].toInt() and 0xFF,
messageId = uper[1].toInt() and 0xFF,
stationId = station,
)
}
}
}
/** What [classifyItsPdu] worked out about one received PDU. */
data class ItsClassification(val kind: ItsMessageKind, val messageId: Int?, val stationId: Long?)
/**
* Classifies [uper], preferring its header's messageID over [btpPort].
*
* A message whose id and port disagree is classified by the id, since the id is what the sender
* actually put in the PDU; the port is only where it happened to arrive.
*/
fun classifyItsPdu(uper: ByteArray, btpPort: Int?): ItsClassification {
val header = ItsPduHeader.parse(uper)
val kind = header?.let { ItsMessageKind.fromMessageId(it.messageId) }
?: btpPort?.let { ItsMessageKind.fromBtpPort(it) }
?: ItsMessageKind.OTHER
return ItsClassification(kind, header?.messageId, header?.stationId)
}
@@ -0,0 +1,135 @@
package com.hawhamburg.micr0bu.service
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.IBinder
import android.os.PowerManager
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import com.hawhamburg.micr0bu.MainActivity
import com.hawhamburg.micr0bu.R
import com.hawhamburg.micr0bu.data.log.V2xLogger
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
/**
* Keeps the process alive, and the CPU awake, while [V2xLogger] records.
*
* The logger does the work; this exists only because a log is most useful on a drive, with the
* screen off, and a process with no foreground service is one the system may kill and whose GNSS
* callbacks it throttles. Same shape as [TripRecordingService], which does this for trips.
*
* Stops itself when the logger stops, including when the logger stops on a write failure.
*/
@AndroidEntryPoint
class V2xLogService : Service() {
@Inject lateinit var logger: V2xLogger
private val job = SupervisorJob()
private val scope = CoroutineScope(Dispatchers.Default + job)
private var watchJob: Job? = null
private var wakeLock: PowerManager.WakeLock? = null
override fun onCreate() {
super.onCreate()
(getSystemService(NOTIFICATION_SERVICE) as NotificationManager).createNotificationChannel(
NotificationChannel(CHANNEL_ID, "V2X logging", NotificationManager.IMPORTANCE_LOW).apply {
description = "Shown while the developer V2X data logger is recording"
}
)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_START -> begin()
ACTION_STOP -> end()
}
return START_NOT_STICKY
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onDestroy() {
wakeLock?.let { if (it.isHeld) it.release() }
job.cancel()
super.onDestroy()
}
private fun begin() {
// Foreground first: Android allows five seconds from startForegroundService.
startForeground(NOTIFICATION_ID, notification(0), ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION)
wakeLock = (getSystemService(POWER_SERVICE) as PowerManager)
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "micr0bu:V2xLog")
.also { it.acquire() }
logger.start()
if (!logger.state.value.active) { // the file could not be created; the screen shows why
end()
return
}
watchJob?.cancel()
watchJob = scope.launch {
logger.state.collect { state ->
if (!state.active) {
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
} else {
(getSystemService(NOTIFICATION_SERVICE) as NotificationManager)
.notify(NOTIFICATION_ID, notification(state.total))
}
}
}
}
private fun end() {
scope.launch {
logger.stop()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
}
private fun notification(messages: Int) =
NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("Logging V2X messages")
.setContentText("$messages messages recorded")
.setSmallIcon(R.mipmap.ic_launcher_foreground)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setContentIntent(
PendingIntent.getActivity(
this, 0,
Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
},
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
)
.build()
companion object {
const val ACTION_START = "com.hawhamburg.micr0bu.V2X_LOG_START"
const val ACTION_STOP = "com.hawhamburg.micr0bu.V2X_LOG_STOP"
private const val NOTIFICATION_ID = 9002
private const val CHANNEL_ID = "v2x_logging"
fun start(context: Context) = ContextCompat.startForegroundService(
context, Intent(context, V2xLogService::class.java).setAction(ACTION_START)
)
fun stop(context: Context) {
context.startService(Intent(context, V2xLogService::class.java).setAction(ACTION_STOP))
}
}
}
@@ -51,6 +51,7 @@ sealed class Screen(val route: String, val labelRes: Int) {
data object SettingsMqtt : Screen("settings/mqtt", R.string.settings_mqtt_section)
data object SettingsUseCaseAlerts : Screen("settings/usecases", R.string.settings_usecase_alerts)
data object SettingsDeveloper : Screen("settings/developer", R.string.settings_developer)
data object SettingsV2xLog : Screen("settings/developer/v2x_log", R.string.v2xlog_title)
data object SettingsAbout : Screen("settings/about", R.string.settings_about)
}
@@ -137,7 +137,7 @@ private fun MenuRow(label: String, onClick: () -> Unit) {
// ── Sub-screen shell ────────────────────────────────────────────────────────
@Composable
private fun SubScreen(title: String, onBack: () -> Unit, content: @Composable () -> Unit) {
internal fun SubScreen(title: String, onBack: () -> Unit, content: @Composable () -> Unit) {
Column(
modifier = Modifier
.fillMaxSize()
@@ -448,6 +448,7 @@ fun DeveloperSettingsScreen(
state: SensorUiState,
onDeveloperMode: (Boolean) -> Unit,
onOpenSensorMonitor: () -> Unit,
onOpenV2xLogger: () -> Unit,
onBack: () -> Unit,
) {
SubScreen(stringResource(R.string.settings_developer), onBack) {
@@ -457,6 +458,9 @@ fun DeveloperSettingsScreen(
// 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)
Divider()
// Records every received V2X message to a database file, on either OBU hardware.
MenuRow(stringResource(R.string.v2xlog_title), onOpenV2xLogger)
if (state.developerMode) {
Divider()
DisabledRow(stringResource(R.string.settings_wifi), stringResource(R.string.settings_wifi_val))
@@ -481,7 +485,7 @@ fun AboutSettingsScreen(onBack: () -> Unit) {
// ── Internal components ─────────────────────────────────────────────────────
@Composable
private fun SectionCard(content: @Composable () -> Unit) {
internal fun SectionCard(content: @Composable () -> Unit) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
@@ -699,7 +703,7 @@ private fun TwoWayChoice(
private fun RowDivider() = HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.4f))
@Composable
private fun Divider() = HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.4f))
internal fun Divider() = HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.4f))
@Composable
private fun SettingToggleRow(
@@ -723,7 +727,7 @@ private fun SettingToggleRow(
}
@Composable
private fun DisabledRow(label: String, value: String) {
internal fun DisabledRow(label: String, value: String) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp),
horizontalArrangement = Arrangement.SpaceBetween,
@@ -0,0 +1,247 @@
package com.hawhamburg.micr0bu.ui.screens
import android.content.Context
import android.content.Intent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.FiberManualRecord
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.core.content.FileProvider
import com.hawhamburg.micr0bu.R
import com.hawhamburg.micr0bu.data.log.V2xLogFile
import com.hawhamburg.micr0bu.data.log.V2xLogState
import com.hawhamburg.micr0bu.data.transport.ObuHardware
import com.hawhamburg.micr0bu.domain.log.ItsMessageKind
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
private val modifiedFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
/**
* Settings > Developer > V2X data logger.
*
* Start and stop one log at a time, watch what it is catching, and share or delete the finished
* files. What is recorded, and from where, is described on [com.hawhamburg.micr0bu.data.log.V2xLogger].
*/
@Composable
fun V2xLogSettingsScreen(
state: V2xLogState,
files: List<V2xLogFile>,
obuHardware: ObuHardware,
onStart: () -> Unit,
onStop: () -> Unit,
onDelete: (V2xLogFile) -> Unit,
onBack: () -> Unit,
) {
val context = LocalContext.current
var pendingDelete by remember { mutableStateOf<V2xLogFile?>(null) }
val isEsp32 = obuHardware == ObuHardware.ESP32_C5
pendingDelete?.let { log ->
AlertDialog(
onDismissRequest = { pendingDelete = null },
title = { Text(stringResource(R.string.v2xlog_delete_title)) },
text = { Text(stringResource(R.string.v2xlog_delete_text, log.name)) },
confirmButton = {
TextButton(onClick = { onDelete(log); pendingDelete = null }) {
Text(stringResource(R.string.log_delete_confirm), color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { pendingDelete = null }) { Text(stringResource(R.string.log_cancel)) }
},
)
}
SubScreen(stringResource(R.string.v2xlog_title), onBack) {
Text(
stringResource(R.string.v2xlog_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
SectionCard {
Spacer(Modifier.height(8.dp))
Text(
stringResource(if (isEsp32) R.string.v2xlog_mode_esp32 else R.string.v2xlog_mode_cit),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// Said up front, not discovered later as two rows of zeros: the firmware decides this.
if (isEsp32) {
Spacer(Modifier.height(4.dp))
Text(
stringResource(R.string.v2xlog_esp32_firmware_note),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.height(12.dp))
Button(
onClick = if (state.active) onStop else onStart,
modifier = Modifier.fillMaxWidth(),
colors = if (state.active) ButtonDefaults.buttonColors(containerColor = Color(0xFFB3261E))
else ButtonDefaults.buttonColors(),
) {
Icon(
if (state.active) Icons.Default.Stop else Icons.Default.FiberManualRecord,
contentDescription = null,
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(stringResource(if (state.active) R.string.v2xlog_stop else R.string.v2xlog_start))
}
state.error?.let {
Spacer(Modifier.height(8.dp))
Text(
stringResource(R.string.v2xlog_error, it),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
Spacer(Modifier.height(12.dp))
}
if (state.active) {
SectionCard {
DisabledRow(stringResource(R.string.v2xlog_file), state.fileName.orEmpty())
Divider()
DisabledRow(
stringResource(R.string.v2xlog_total),
stringResource(R.string.v2xlog_total_val, state.total, formatBytes(state.fileBytes)),
)
ItsMessageKind.TRACKED.forEach { kind ->
Divider()
DisabledRow(kind.label, (state.counts[kind] ?: 0).toString())
}
Divider()
DisabledRow(stringResource(R.string.v2xlog_phone_gnss), fixAge(state.phoneFixAgeMs))
Divider()
DisabledRow(
stringResource(R.string.v2xlog_obu_gnss),
if (isEsp32) stringResource(R.string.v2xlog_not_available) else fixAge(state.obuFixAgeMs),
)
}
if (state.total == 0) {
Text(
stringResource(R.string.v2xlog_waiting),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Text(
stringResource(R.string.v2xlog_files_title, files.size),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (files.isEmpty()) {
Text(
stringResource(R.string.v2xlog_none),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
SectionCard {
files.forEachIndexed { i, log ->
if (i > 0) Divider()
LogFileRow(
log = log,
onShare = { shareLog(context, log) },
onDelete = { pendingDelete = log },
)
}
}
}
}
}
@Composable
private fun LogFileRow(log: V2xLogFile, onShare: () -> Unit, onDelete: () -> Unit) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Column(modifier = Modifier.weight(1f)) {
Text(
log.name,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.SemiBold,
)
Text(
"${modifiedFormat.format(Date(log.modifiedMs))} · ${formatBytes(log.sizeBytes)}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// The file being written is open and incomplete; sharing or deleting it would hand over a
// half-written database or pull it out from under the writer.
IconButton(onClick = onShare, enabled = !log.active) {
Icon(Icons.Default.Share, contentDescription = stringResource(R.string.v2xlog_share_cd))
}
IconButton(onClick = onDelete, enabled = !log.active) {
Icon(
Icons.Default.Delete,
contentDescription = stringResource(R.string.v2xlog_delete_cd),
tint = if (log.active) LocalContentColorDisabled() else MaterialTheme.colorScheme.error,
)
}
}
}
@Composable
private fun LocalContentColorDisabled(): Color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
@Composable
private fun fixAge(ageMs: Long?): String =
if (ageMs == null) stringResource(R.string.v2xlog_no_fix)
else stringResource(R.string.v2xlog_fix_age, (ageMs / 1000).toInt())
private fun formatBytes(bytes: Long): String = when {
bytes < 1024 -> "$bytes B"
bytes < 1024 * 1024 -> "%.1f kB".format(Locale.US, bytes / 1024.0)
else -> "%.1f MB".format(Locale.US, bytes / (1024.0 * 1024.0))
}
private fun shareLog(context: Context, log: V2xLogFile) {
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", log.file)
val intent = Intent(Intent.ACTION_SEND).apply {
type = "application/vnd.sqlite3"
putExtra(Intent.EXTRA_STREAM, uri)
putExtra(Intent.EXTRA_SUBJECT, "MicrOBU V2X log - ${log.name}")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(Intent.createChooser(intent, context.getString(R.string.v2xlog_share_chooser)))
}
@@ -0,0 +1,45 @@
package com.hawhamburg.micr0bu.viewmodel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.hawhamburg.micr0bu.data.log.V2xLogFile
import com.hawhamburg.micr0bu.data.log.V2xLogState
import com.hawhamburg.micr0bu.data.log.V2xLogger
import com.hawhamburg.micr0bu.service.V2xLogService
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
/** Backs Settings > Developer > V2X data logger. */
@HiltViewModel
class V2xLogViewModel @Inject constructor(
application: Application,
private val logger: V2xLogger,
) : AndroidViewModel(application) {
val state: StateFlow<V2xLogState> = logger.state
val files: StateFlow<List<V2xLogFile>> = logger.files
init {
logger.refreshFiles()
// The running file grows between state updates, so keep the list's sizes current while a
// log is on screen.
viewModelScope.launch {
while (true) {
delay(2_000L)
if (logger.state.value.active) logger.refreshFiles()
}
}
}
fun start() = V2xLogService.start(getApplication())
fun stop() = V2xLogService.stop(getApplication())
fun delete(log: V2xLogFile) {
logger.delete(log)
}
}
+24
View File
@@ -332,4 +332,28 @@
<string name="conn_esp32_signing">Signieren %1$s · Tickets %2$d · signiert %3$d · abgelehnt %4$d · gesendet %5$d</string>
<string name="conn_esp32_signing_on">an</string>
<string name="conn_esp32_signing_off">aus</string>
<string name="v2xlog_title">V2X-Datenlogger</string>
<string name="v2xlog_desc">Zeichnet jede empfangene V2X-Nachricht (CAM, DENM, SPATEM, MAPEM, VAM) in einer Datenbankdatei auf, mit Zeitstempel, der GNSS-Position des Telefons und, wenn vorhanden, der GNSS-Position der OBU und der Signalstärke (RSSI).</string>
<string name="v2xlog_mode_esp32">ESP32-C5: protokolliert, was das Board über die Verbindung weiterleitet, mit RSSI. Das Board hat kein GNSS, daher gibt es keine OBU-Position.</string>
<string name="v2xlog_mode_cit">CiT One: protokolliert die MQTT-Topics. Die API liefert keine RSSI; die OBU-Position stammt aus v2x/rx/obu_gnss.</string>
<string name="v2xlog_esp32_firmware_note">Die aktuelle ESP32-C5-Firmware leitet nur CAM, DENM und SPATEM weiter; MAPEM und VAM erscheinen erst, wenn die Firmware sie annimmt.</string>
<string name="v2xlog_start">Aufzeichnung starten</string>
<string name="v2xlog_stop">Aufzeichnung beenden</string>
<string name="v2xlog_error">Aufzeichnung fehlgeschlagen: %1$s</string>
<string name="v2xlog_file">Datei</string>
<string name="v2xlog_total">Aufgezeichnet</string>
<string name="v2xlog_total_val">%1$d Nachrichten · %2$s</string>
<string name="v2xlog_phone_gnss">Telefon-GNSS</string>
<string name="v2xlog_obu_gnss">OBU-GNSS</string>
<string name="v2xlog_fix_age">Fix vor %1$d s</string>
<string name="v2xlog_no_fix">kein Fix</string>
<string name="v2xlog_not_available">nicht verfügbar</string>
<string name="v2xlog_waiting">Warte auf Nachrichten. Ist die OBU noch nicht verbunden, verbinde sie auf dem Verbindungsbildschirm.</string>
<string name="v2xlog_files_title">Logdateien (%1$d)</string>
<string name="v2xlog_none">Noch keine Logs</string>
<string name="v2xlog_share_cd">Log teilen</string>
<string name="v2xlog_delete_cd">Log löschen</string>
<string name="v2xlog_share_chooser">V2X-Log teilen</string>
<string name="v2xlog_delete_title">Log löschen?</string>
<string name="v2xlog_delete_text">%1$s wird dauerhaft vom Gerät entfernt.</string>
</resources>
+24
View File
@@ -339,4 +339,28 @@
<string name="conn_esp32_signing">Signing %1$s · tickets %2$d · signed %3$d · refused %4$d · on air %5$d</string>
<string name="conn_esp32_signing_on">on</string>
<string name="conn_esp32_signing_off">off</string>
<string name="v2xlog_title">V2X data logger</string>
<string name="v2xlog_desc">Records every V2X message received (CAM, DENM, SPATEM, MAPEM, VAM) into a database file, with a timestamp, the phone GNSS fix and, where available, the OBU GNSS fix and the signal strength (RSSI).</string>
<string name="v2xlog_mode_esp32">ESP32-C5: logging what the board forwards over the link, with RSSI. The board has no GNSS, so there is no OBU position.</string>
<string name="v2xlog_mode_cit">CiT One: logging the MQTT topics. The API carries no RSSI; the OBU position comes from v2x/rx/obu_gnss.</string>
<string name="v2xlog_esp32_firmware_note">The current ESP32-C5 firmware forwards only CAM, DENM and SPATEM, so MAPEM and VAM appear only once the firmware accepts them.</string>
<string name="v2xlog_start">Start logging</string>
<string name="v2xlog_stop">Stop logging</string>
<string name="v2xlog_error">Logging failed: %1$s</string>
<string name="v2xlog_file">File</string>
<string name="v2xlog_total">Recorded</string>
<string name="v2xlog_total_val">%1$d messages · %2$s</string>
<string name="v2xlog_phone_gnss">Phone GNSS</string>
<string name="v2xlog_obu_gnss">OBU GNSS</string>
<string name="v2xlog_fix_age">fix %1$d s ago</string>
<string name="v2xlog_no_fix">no fix</string>
<string name="v2xlog_not_available">not available</string>
<string name="v2xlog_waiting">Waiting for messages. If the OBU is not connected yet, connect it on the Connection screen.</string>
<string name="v2xlog_files_title">Log files (%1$d)</string>
<string name="v2xlog_none">No logs yet</string>
<string name="v2xlog_share_cd">Share log</string>
<string name="v2xlog_delete_cd">Delete log</string>
<string name="v2xlog_share_chooser">Share V2X log</string>
<string name="v2xlog_delete_title">Delete log?</string>
<string name="v2xlog_delete_text">This permanently removes %1$s from the device.</string>
</resources>
+2
View File
@@ -2,4 +2,6 @@
<paths>
<!-- Covers context.cacheDir — used for CSV exports -->
<cache-path name="shared_csv" path="." />
<!-- V2X data logs (V2xLogger), shared from Settings > Developer -->
<files-path name="v2x_logs" path="v2x_logs/" />
</paths>
@@ -0,0 +1,73 @@
package com.hawhamburg.micr0bu
import com.hawhamburg.micr0bu.domain.log.ItsMessageKind
import com.hawhamburg.micr0bu.domain.log.ItsPduHeader
import com.hawhamburg.micr0bu.domain.log.classifyItsPdu
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/**
* Pins how the V2X data logger names a message it may have no decoder for. The CAM fixture is the
* golden UPER frame from [CamEncodeGoldenTest] (protocolVersion 2, messageID 2, stationID 999999);
* the others only need a valid ItsPduHeader, which is all the classifier reads.
*/
class ItsMessageKindTest {
private fun String.hexToBytes() = chunked(2).map { it.toInt(16).toByte() }.toByteArray()
private val goldenCam =
"0202000f423f3700402ab215af6e286477dffffffc23b7743e0027ffc0d0fe0118329337feebfff6000000".hexToBytes()
/** An ItsPduHeader with [messageId] and stationID 0x01020304, then a stand-in body. */
private fun pdu(messageId: Int) =
byteArrayOf(2, messageId.toByte(), 1, 2, 3, 4, 0x55, 0x66)
@Test fun `header of the golden CAM is read`() {
val h = ItsPduHeader.parse(goldenCam)!!
assertEquals(2, h.protocolVersion)
assertEquals(2, h.messageId)
assertEquals(999_999L, h.stationId)
}
@Test fun `station id above 2^31 is not sign-extended`() {
val h = ItsPduHeader.parse(byteArrayOf(2, 2, 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFE.toByte()))!!
assertEquals(0xFFFFFFFEL, h.stationId)
}
@Test fun `every tracked kind is recognised by messageID alone`() {
assertEquals(ItsMessageKind.DENM, classifyItsPdu(pdu(1), null).kind)
assertEquals(ItsMessageKind.CAM, classifyItsPdu(pdu(2), null).kind)
assertEquals(ItsMessageKind.SPATEM, classifyItsPdu(pdu(4), null).kind)
assertEquals(ItsMessageKind.MAPEM, classifyItsPdu(pdu(5), null).kind)
assertEquals(ItsMessageKind.VAM, classifyItsPdu(pdu(16), null).kind)
}
@Test fun `messageID wins over the BTP port, and the SPATEM-MAPEM numbers are not swapped`() {
// SPATEM is port 2004 but messageID 4; MAPEM is port 2003 but messageID 5.
assertEquals(ItsMessageKind.SPATEM, classifyItsPdu(pdu(4), 2004).kind)
assertEquals(ItsMessageKind.MAPEM, classifyItsPdu(pdu(5), 2003).kind)
// A PDU that disagrees with the port it arrived on is what its header says it is.
assertEquals(ItsMessageKind.MAPEM, classifyItsPdu(pdu(5), 2004).kind)
}
@Test fun `a PDU too short for a header falls back to the BTP port`() {
val c = classifyItsPdu(byteArrayOf(2, 5), 2018)
assertEquals(ItsMessageKind.VAM, c.kind)
assertNull(c.messageId)
assertNull(c.stationId)
}
@Test fun `an unknown messageID is kept as OTHER with its id`() {
val c = classifyItsPdu(pdu(99), null)
assertEquals(ItsMessageKind.OTHER, c.kind)
assertEquals(99, c.messageId)
assertEquals(0x01020304L, c.stationId)
}
@Test fun `processed topic names map to kinds`() {
assertEquals(ItsMessageKind.MAPEM, ItsMessageKind.fromProcessedTopicName("map"))
assertEquals(ItsMessageKind.SPATEM, ItsMessageKind.fromProcessedTopicName("spat"))
assertNull(ItsMessageKind.fromProcessedTopicName("unknown"))
}
}