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