Compare commits
2
Commits
main
..
ca57e5702b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca57e5702b | ||
|
|
0e9525162d |
@@ -54,12 +54,3 @@ sdkconfig.old
|
|||||||
# ESP-IDF component manager downloads (espressif/esp-boost for obu-firmware's vanetza-idf), ~125 MB.
|
# ESP-IDF component manager downloads (espressif/esp-boost for obu-firmware's vanetza-idf), ~125 MB.
|
||||||
# dependencies.lock beside the project pins them and is committed; this is its cache.
|
# dependencies.lock beside the project pins them and is committed; this is its cache.
|
||||||
managed_components/
|
managed_components/
|
||||||
|
|
||||||
# The colleague's standalone ESP32-C5 VRU station (its own repository, HAW GitLab
|
|
||||||
# urban-mobility-lab/microbu/microbu-esp32c5). Kept beside the project on the lab laptop, for
|
|
||||||
# reference and because the V2X2MAP bridge runs from its tools/, but not part of this repository:
|
|
||||||
# obu-firmware only needs its vanetza-idf, which is copied to obu-firmware/external/vanetza-idf.
|
|
||||||
/microbu-esp32c5/
|
|
||||||
|
|
||||||
# draw.io keeps a backup beside an open diagram.
|
|
||||||
*.drawio.bkp
|
|
||||||
|
|||||||
@@ -5,25 +5,6 @@ Engineering to-do list. The reviewer-facing open items live in
|
|||||||
|
|
||||||
## Waiting on hardware
|
## 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)
|
### 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
|
obu-firmware is now a port of the colleague's `microbu-esp32c5` station (vanetza-idf, TS 103 097
|
||||||
@@ -94,7 +75,7 @@ resets the board, the phone is on the other port.
|
|||||||
Signed CAMs from the pinger already show "signature verified" live. Switch to VAM with signing
|
Signed CAMs from the pinger already show "signature verified" live. Switch to VAM with signing
|
||||||
on: the VAM must be decoded (cyclist, position) and show "signature verified" too.
|
on: the VAM must be decoded (cyclist, position) and show "signature verified" too.
|
||||||
Confirmed by the user 2026-09-23: signed VAMs decode and verify.
|
Confirmed by the user 2026-09-23: signed VAMs decode and verify.
|
||||||
- [x] **VAM.** Transmit VAM: BTP port 2018, psid 638; with `tools/wireshark/psid-vru.lua` from the colleague's microbu-esp32c5 repository
|
- [x] **VAM.** Transmit VAM: BTP port 2018, psid 638; with `microbu-esp32c5/tools/wireshark/psid-vru.lua`
|
||||||
Wireshark decodes the VAM (stationType cyclist, bicyclist profile in every ~2 s VAM). Rate:
|
Wireshark decodes the VAM (stationType cyclist, bicyclist profile in every ~2 s VAM). Rate:
|
||||||
≥1 per 5 s standing still, about one per GNSS fix while riding.
|
≥1 per 5 s standing still, about one per GNSS fix while riding.
|
||||||
Covered by the V2X2MAP check above (decoded VAM, psid 638); Wireshark not needed.
|
Covered by the V2X2MAP check above (decoded VAM, psid 638); Wireshark not needed.
|
||||||
|
|||||||
@@ -72,12 +72,6 @@
|
|||||||
android:foregroundServiceType="location"
|
android:foregroundServiceType="location"
|
||||||
android:exported="false" />
|
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
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
android:authorities="${applicationId}.fileprovider"
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
|||||||
@@ -56,12 +56,10 @@ import com.hawhamburg.micr0bu.ui.screens.SensorsSettingsScreen
|
|||||||
import com.hawhamburg.micr0bu.ui.screens.SettingsScreen
|
import com.hawhamburg.micr0bu.ui.screens.SettingsScreen
|
||||||
import com.hawhamburg.micr0bu.ui.screens.UnitsSettingsScreen
|
import com.hawhamburg.micr0bu.ui.screens.UnitsSettingsScreen
|
||||||
import com.hawhamburg.micr0bu.ui.screens.UseCaseAlertsSettingsScreen
|
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.ui.theme.MicrOBUTheme
|
||||||
import com.hawhamburg.micr0bu.viewmodel.MqttViewModel
|
import com.hawhamburg.micr0bu.viewmodel.MqttViewModel
|
||||||
import com.hawhamburg.micr0bu.viewmodel.SensorViewModel
|
import com.hawhamburg.micr0bu.viewmodel.SensorViewModel
|
||||||
import com.hawhamburg.micr0bu.viewmodel.TripRecordingViewModel
|
import com.hawhamburg.micr0bu.viewmodel.TripRecordingViewModel
|
||||||
import com.hawhamburg.micr0bu.viewmodel.V2xLogViewModel
|
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
@@ -70,7 +68,6 @@ class MainActivity : AppCompatActivity() {
|
|||||||
private val viewModel: SensorViewModel by viewModels()
|
private val viewModel: SensorViewModel by viewModels()
|
||||||
private val mqttViewModel: MqttViewModel by viewModels()
|
private val mqttViewModel: MqttViewModel by viewModels()
|
||||||
private val tripViewModel: TripRecordingViewModel by viewModels()
|
private val tripViewModel: TripRecordingViewModel by viewModels()
|
||||||
private val logViewModel: V2xLogViewModel by viewModels()
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
installSplashScreen()
|
installSplashScreen()
|
||||||
@@ -102,8 +99,6 @@ class MainActivity : AppCompatActivity() {
|
|||||||
val denmEvents by mqttViewModel.denmEvents.collectAsState()
|
val denmEvents by mqttViewModel.denmEvents.collectAsState()
|
||||||
val spatIntersections by mqttViewModel.spatIntersections.collectAsState()
|
val spatIntersections by mqttViewModel.spatIntersections.collectAsState()
|
||||||
val ownCamPosition by mqttViewModel.ownCamPosition.collectAsState()
|
val ownCamPosition by mqttViewModel.ownCamPosition.collectAsState()
|
||||||
val v2xLogState by logViewModel.state.collectAsState()
|
|
||||||
val v2xLogFiles by logViewModel.files.collectAsState()
|
|
||||||
|
|
||||||
MicrOBUTheme(darkTheme = state.darkTheme) {
|
MicrOBUTheme(darkTheme = state.darkTheme) {
|
||||||
val view = LocalView.current
|
val view = LocalView.current
|
||||||
@@ -370,21 +365,9 @@ class MainActivity : AppCompatActivity() {
|
|||||||
state = state,
|
state = state,
|
||||||
onDeveloperMode = viewModel::setDeveloperMode,
|
onDeveloperMode = viewModel::setDeveloperMode,
|
||||||
onOpenSensorMonitor = { navController.navigate(Screen.Sensors.route) },
|
onOpenSensorMonitor = { navController.navigate(Screen.Sensors.route) },
|
||||||
onOpenV2xLogger = { navController.navigate(Screen.SettingsV2xLog.route) },
|
|
||||||
onBack = { navController.popBackStack() },
|
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) {
|
composable(Screen.SettingsAbout.route) {
|
||||||
AboutSettingsScreen(onBack = { navController.popBackStack() })
|
AboutSettingsScreen(onBack = { navController.popBackStack() })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import com.hawhamburg.micr0bu.data.mqtt.MqttRepository
|
|||||||
import com.hawhamburg.micr0bu.data.mqtt.ObuHardwarePreferences
|
import com.hawhamburg.micr0bu.data.mqtt.ObuHardwarePreferences
|
||||||
import com.hawhamburg.micr0bu.data.mqtt.RAW_CAM_TOPIC
|
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_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.RAW_SPATEM_TOPIC
|
||||||
import com.hawhamburg.micr0bu.data.mqtt.RecvV2xMessage
|
import com.hawhamburg.micr0bu.data.mqtt.RecvV2xMessage
|
||||||
import com.hawhamburg.micr0bu.data.mqtt.UseCaseAlertPreferences
|
import com.hawhamburg.micr0bu.data.mqtt.UseCaseAlertPreferences
|
||||||
@@ -242,8 +241,6 @@ class CamUseCaseRepository @Inject constructor(
|
|||||||
source = "mqtt",
|
source = "mqtt",
|
||||||
)
|
)
|
||||||
RAW_SPATEM_TOPIC -> handleSpatUper(envelope.payload, rssiDbm = null, 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}")
|
else -> Log.w(TAG, "rawV2x: unexpected topic ${raw.topic}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,235 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,413 +0,0 @@
|
|||||||
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,9 +48,6 @@ private val SUBSCRIBED_TOPICS = listOf(
|
|||||||
RAW_CAM_TOPIC,
|
RAW_CAM_TOPIC,
|
||||||
RAW_DENM_TOPIC,
|
RAW_DENM_TOPIC,
|
||||||
RAW_SPATEM_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
|
// 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.
|
// raw topics, and because the Use Case app is still the only source of its own alert output.
|
||||||
"v2x-uca/output/json/cam",
|
"v2x-uca/output/json/cam",
|
||||||
@@ -64,9 +61,8 @@ private val SUBSCRIBED_TOPICS = listOf(
|
|||||||
const val RAW_CAM_TOPIC = "v2x/rx/cam"
|
const val RAW_CAM_TOPIC = "v2x/rx/cam"
|
||||||
const val RAW_DENM_TOPIC = "v2x/rx/denm"
|
const val RAW_DENM_TOPIC = "v2x/rx/denm"
|
||||||
const val RAW_SPATEM_TOPIC = "v2x/rx/spatem"
|
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, RAW_MAPEM_TOPIC)
|
private val RAW_V2X_TOPICS = setOf(RAW_CAM_TOPIC, RAW_DENM_TOPIC, RAW_SPATEM_TOPIC)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A message straight off a `v2x/rx` topic, before the protobuf envelope is opened.
|
* A message straight off a `v2x/rx` topic, before the protobuf envelope is opened.
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
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,7 +51,6 @@ sealed class Screen(val route: String, val labelRes: Int) {
|
|||||||
data object SettingsMqtt : Screen("settings/mqtt", R.string.settings_mqtt_section)
|
data object SettingsMqtt : Screen("settings/mqtt", R.string.settings_mqtt_section)
|
||||||
data object SettingsUseCaseAlerts : Screen("settings/usecases", R.string.settings_usecase_alerts)
|
data object SettingsUseCaseAlerts : Screen("settings/usecases", R.string.settings_usecase_alerts)
|
||||||
data object SettingsDeveloper : Screen("settings/developer", R.string.settings_developer)
|
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)
|
data object SettingsAbout : Screen("settings/about", R.string.settings_about)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ private fun MenuRow(label: String, onClick: () -> Unit) {
|
|||||||
// ── Sub-screen shell ────────────────────────────────────────────────────────
|
// ── Sub-screen shell ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun SubScreen(title: String, onBack: () -> Unit, content: @Composable () -> Unit) {
|
private fun SubScreen(title: String, onBack: () -> Unit, content: @Composable () -> Unit) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -448,7 +448,6 @@ fun DeveloperSettingsScreen(
|
|||||||
state: SensorUiState,
|
state: SensorUiState,
|
||||||
onDeveloperMode: (Boolean) -> Unit,
|
onDeveloperMode: (Boolean) -> Unit,
|
||||||
onOpenSensorMonitor: () -> Unit,
|
onOpenSensorMonitor: () -> Unit,
|
||||||
onOpenV2xLogger: () -> Unit,
|
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
) {
|
) {
|
||||||
SubScreen(stringResource(R.string.settings_developer), onBack) {
|
SubScreen(stringResource(R.string.settings_developer), onBack) {
|
||||||
@@ -458,9 +457,6 @@ fun DeveloperSettingsScreen(
|
|||||||
// Sensor Monitor lives here rather than in the bottom nav: a live phone-sensor feed is
|
// 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.
|
// a bench-diagnosis tool, not something a rider needs mid-ride.
|
||||||
MenuRow(stringResource(R.string.sensor_monitor_title), onOpenSensorMonitor)
|
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) {
|
if (state.developerMode) {
|
||||||
Divider()
|
Divider()
|
||||||
DisabledRow(stringResource(R.string.settings_wifi), stringResource(R.string.settings_wifi_val))
|
DisabledRow(stringResource(R.string.settings_wifi), stringResource(R.string.settings_wifi_val))
|
||||||
@@ -485,7 +481,7 @@ fun AboutSettingsScreen(onBack: () -> Unit) {
|
|||||||
// ── Internal components ─────────────────────────────────────────────────────
|
// ── Internal components ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun SectionCard(content: @Composable () -> Unit) {
|
private fun SectionCard(content: @Composable () -> Unit) {
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||||
@@ -703,7 +699,7 @@ private fun TwoWayChoice(
|
|||||||
private fun RowDivider() = HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.4f))
|
private fun RowDivider() = HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.4f))
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun Divider() = HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.4f))
|
private fun Divider() = HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.4f))
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SettingToggleRow(
|
private fun SettingToggleRow(
|
||||||
@@ -727,7 +723,7 @@ private fun SettingToggleRow(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun DisabledRow(label: String, value: String) {
|
private fun DisabledRow(label: String, value: String) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp),
|
modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
|||||||
@@ -1,247 +0,0 @@
|
|||||||
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)))
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
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,28 +332,4 @@
|
|||||||
<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">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_on">an</string>
|
||||||
<string name="conn_esp32_signing_off">aus</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>
|
</resources>
|
||||||
|
|||||||
@@ -339,28 +339,4 @@
|
|||||||
<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">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_on">on</string>
|
||||||
<string name="conn_esp32_signing_off">off</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>
|
</resources>
|
||||||
|
|||||||
@@ -2,6 +2,4 @@
|
|||||||
<paths>
|
<paths>
|
||||||
<!-- Covers context.cacheDir — used for CSV exports -->
|
<!-- Covers context.cacheDir — used for CSV exports -->
|
||||||
<cache-path name="shared_csv" path="." />
|
<cache-path name="shared_csv" path="." />
|
||||||
<!-- V2X data logs (V2xLogger), shared from Settings > Developer -->
|
|
||||||
<files-path name="v2x_logs" path="v2x_logs/" />
|
|
||||||
</paths>
|
</paths>
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
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"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -35,10 +35,10 @@ fit together, how it was verified, and what is still open. Hardware checks still
|
|||||||
|
|
||||||
## Firmware (obu-firmware)
|
## Firmware (obu-firmware)
|
||||||
|
|
||||||
obu-firmware is now a port of `microbu-esp32c5/firmware`, from the colleague's own repository
|
obu-firmware is now a port of `microbu-esp32c5/firmware`: the colleague's repository, of which
|
||||||
(not part of this one; nothing is pushed there). The C-ITS library it needs is copied into this
|
this repository keeps a copy in `microbu-esp32c5/` (their commit cf4b99f plus our V2X2MAP signature
|
||||||
repository as `obu-firmware/external/vanetza-idf` (their commit cf4b99f, unchanged), so
|
verification; nothing is pushed to their repository). vanetza-idf is taken from
|
||||||
obu-firmware builds from a plain clone. It builds
|
`microbu-esp32c5/external/vanetza-idf`. It builds
|
||||||
with **ESP-IDF 6.0.2 only**: the raw-TX path uses private Wi-Fi driver structures that vanetza-idf
|
with **ESP-IDF 6.0.2 only**: the raw-TX path uses private Wi-Fi driver structures that vanetza-idf
|
||||||
pins to that version. The previous C firmware (IDF 6.1) is backed up as a full flash image in
|
pins to that version. The previous C firmware (IDF 6.1) is backed up as a full flash image in
|
||||||
`firmware-backups/` (gitignored, restore command in its README.txt); its sources stay on disk,
|
`firmware-backups/` (gitignored, restore command in its README.txt); its sources stay on disk,
|
||||||
@@ -64,7 +64,7 @@ Main changes against the colleague's firmware:
|
|||||||
|
|
||||||
## Link protocol
|
## Link protocol
|
||||||
|
|
||||||
Station-link v1 (colleague's repository, `station-link/README.md`): `[opcode][flags][sequence LE][body]`,
|
Station-link v1 (`microbu-esp32c5/station-link/README.md`): `[opcode][flags][sequence LE][body]`,
|
||||||
little-endian, at most 512 octets. Over USB each message is one `0xAA55` frame of type `0x10`
|
little-endian, at most 512 octets. Over USB each message is one `0xAA55` frame of type `0x10`
|
||||||
(the old framing and CRC). Over BLE each message is one GATT value on service
|
(the old framing and CRC). Over BLE each message is one GATT value on service
|
||||||
`0000C175-BA5E-4C17-8000-00805F9B34FB` (the README describes a different, Nordic-UART layout; the
|
`0000C175-BA5E-4C17-8000-00805F9B34FB` (the README describes a different, Nordic-UART layout; the
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Binary file not shown.
|
Before Width: | Height: | Size: 660 KiB |
@@ -0,0 +1,43 @@
|
|||||||
|
# --- ESP-IDF build output ---
|
||||||
|
firmware/build/
|
||||||
|
firmware/build-*/
|
||||||
|
firmware/managed_components/
|
||||||
|
firmware/.cache/
|
||||||
|
firmware/.vscode/
|
||||||
|
firmware/sdkconfig
|
||||||
|
firmware/sdkconfig.old
|
||||||
|
firmware/*.log
|
||||||
|
firmware/rf_characterization_output/
|
||||||
|
|
||||||
|
# --- Secrets: never commit real private key material ---
|
||||||
|
# (this repo only ever ships the disposable, non-registered demo chain)
|
||||||
|
*.vkey
|
||||||
|
*.ekey
|
||||||
|
private/
|
||||||
|
*_private_encrypted.pem
|
||||||
|
*_RCA_NEW_*/
|
||||||
|
|
||||||
|
# --- PKI reference-generator: regenerate locally, do not commit ---
|
||||||
|
# (vendored Rust crates for the offline/deterministic c-its build path; see
|
||||||
|
# pki/uml-l0-rca/reference-generator/README.md to rebuild with `cargo vendor`)
|
||||||
|
pki/uml-l0-rca/reference-generator/vendor/
|
||||||
|
pki/uml-l0-rca/reference-generator/_build_cits/
|
||||||
|
pki/uml-l0-rca/reference-generator/_cargo_vendor/
|
||||||
|
|
||||||
|
# --- Python ---
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.egg-info/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
|
||||||
|
# --- v2x2map bridge local artifacts ---
|
||||||
|
tools/v2x2map-0.3.0/bridge/recordings/
|
||||||
|
tools/v2x2map-0.3.0/bridge/dist/
|
||||||
|
tools/v2x2map-0.3.0/bridge/build/
|
||||||
|
*.pcap
|
||||||
|
|
||||||
|
# --- OS / editor ---
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
*.swp
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
# micrOBU ESP32-C5
|
||||||
|
|
||||||
|
A standalone ITS-G5 (802.11p) VRU (Vulnerable Road User) ITS-S station on the
|
||||||
|
ESP32-C5: firmware, the embedded [Vanetza](https://github.com/riebl/vanetza)
|
||||||
|
C-ITS protocol stack (`external/vanetza-idf/`, see
|
||||||
|
[PROVENANCE.md](external/vanetza-idf/PROVENANCE.md)), a phone-emulator
|
||||||
|
example for the station-internal link, a localhost PKI reference chain, a
|
||||||
|
Wireshark VAM dissector and the [V2X2MAP](https://github.com/711it/v2x2map)
|
||||||
|
receiver bridge.
|
||||||
|
|
||||||
|
A fresh clone can send a real, signed VAM (VRU Awareness Message) over the
|
||||||
|
air in a handful of commands — see [Send a signed VAM](#4-send-a-signed-vam-in-a-few-commands)
|
||||||
|
below. `station-link/python/demo-chain.vcr` is a disposable, **non-EU-registered**
|
||||||
|
test credential chain generated specifically for this purpose (see
|
||||||
|
[Security note on credentials](#security-note-on-credentials)); it carries no
|
||||||
|
real-world trust and is safe to ship.
|
||||||
|
|
||||||
|
## Quickstart
|
||||||
|
|
||||||
|
### 1. Build & flash the ESP32-C5 firmware
|
||||||
|
|
||||||
|
Requires [ESP-IDF](https://docs.espressif.com/projects/esp-idf/en/latest/esp32c5/get-started/) 6.0.2 with the `esp32c5` target.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd firmware
|
||||||
|
idf.py set-target esp32c5
|
||||||
|
idf.py build
|
||||||
|
idf.py -p COM<PORT> flash monitor
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Install the Wireshark VAM dissector
|
||||||
|
|
||||||
|
Wireshark decodes IEEE 1609.2 / ETSI TS 103 097 secured packets only down to
|
||||||
|
`unsecuredData` unless the PSID is registered in its dissector table. **PSID
|
||||||
|
638** (VRU Awareness Service, ETSI TS 102 965) is not registered by default
|
||||||
|
in Wireshark 4.x, so signed VAM traffic stops decoding at the security
|
||||||
|
envelope without this plugin.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Windows
|
||||||
|
Copy-Item tools\wireshark\psid-vru.lua "$env:APPDATA\Wireshark\plugins\"
|
||||||
|
```
|
||||||
|
```bash
|
||||||
|
# Linux
|
||||||
|
mkdir -p ~/.local/lib/wireshark/plugins && cp tools/wireshark/psid-vru.lua ~/.local/lib/wireshark/plugins/
|
||||||
|
# macOS
|
||||||
|
mkdir -p ~/.config/wireshark/plugins && cp tools/wireshark/psid-vru.lua ~/.config/wireshark/plugins/
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify under **Help → About Wireshark → Plugins**, or use it directly with `tshark`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tshark -X lua_script:tools/wireshark/psid-vru.lua -r capture.pcap
|
||||||
|
```
|
||||||
|
|
||||||
|
See [tools/wireshark/README.md](tools/wireshark/README.md) for details.
|
||||||
|
|
||||||
|
### 3. Install Python dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r station-link/python/requirements.txt
|
||||||
|
pip install -r tools/v2x2map-0.3.0/bridge/requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Send a signed VAM in a few commands
|
||||||
|
|
||||||
|
With the firmware flashed (step 1) and the ESP32-C5 connected over USB
|
||||||
|
Serial/JTAG, the phone emulator provisions the disposable demo credential
|
||||||
|
chain and starts a small VRU basic service that assembles and transmits
|
||||||
|
VAMs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python station-link/python/phone_emulator.py \
|
||||||
|
--port COM<PORT> --bundle station-link/python/demo-chain.vcr \
|
||||||
|
--radio txrx --duration 30
|
||||||
|
```
|
||||||
|
|
||||||
|
That's it — the micrOBU signs every VAM with the demo AT ticket (VRU ITS-AID
|
||||||
|
638, `psid 638 ssp 01`) and transmits it over ITS-G5. Capture it with a
|
||||||
|
second ITS-G5-capable radio (or the [V2X2MAP bridge](#5-run-the-v2x2map-receiver-bridge-optional)
|
||||||
|
below) and decode it with the [Wireshark dissector](#2-install-the-wireshark-vam-dissector)
|
||||||
|
from step 2.
|
||||||
|
|
||||||
|
To provision over BLE instead of USB, or to mirror packets to a `.pcap`
|
||||||
|
without radiating, see the header of
|
||||||
|
[`phone_emulator.py`](station-link/python/phone_emulator.py) for the
|
||||||
|
`--ble`, `--radio off --divert --pcap` and full `--pki-*` (real online TS
|
||||||
|
102 941 enrolment/authorization) variants, and
|
||||||
|
[station-link/README.md](station-link/README.md) for the link protocol
|
||||||
|
itself.
|
||||||
|
|
||||||
|
### 5. Run the V2X2MAP receiver bridge (optional)
|
||||||
|
|
||||||
|
A second ESP32-C5 flashed with the receiver firmware in
|
||||||
|
[`tools/v2x2map-0.3.0/bridge/firmware/`](tools/v2x2map-0.3.0/bridge/firmware/)
|
||||||
|
can feed a live web dashboard:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python tools/v2x2map-0.3.0/bridge/its_g5_bridge.py --port COM<PORT> --dashboard-port 8080 --open-browser
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://localhost:8080` if it doesn't open automatically. This tool
|
||||||
|
decodes VAMs for display but does **not** verify signatures (see
|
||||||
|
[tools/v2x2map-0.3.0/README.md](tools/v2x2map-0.3.0/README.md)).
|
||||||
|
|
||||||
|
## Repository layout
|
||||||
|
|
||||||
|
| Path | Contents |
|
||||||
|
|---|---|
|
||||||
|
| `firmware/` | ESP-IDF firmware project for the ESP32-C5 VRU ITS-S |
|
||||||
|
| `external/vanetza-idf/` | Vanetza C-ITS stack + ESP-IDF port (upstream provenance in [PROVENANCE.md](external/vanetza-idf/PROVENANCE.md)) |
|
||||||
|
| `station-link/` | Station-internal link protocol, Python client library, phone emulator |
|
||||||
|
| `pki/` | Localhost PKI reference chain (root/AA/AT tooling); see [security note](#security-note-on-credentials) |
|
||||||
|
| `tools/wireshark/` | PSID 638 (VRU) Wireshark Lua dissector |
|
||||||
|
| `tools/v2x2map-0.3.0/` | Vendored [V2X2MAP](https://github.com/711it/v2x2map) receiver bridge and live dashboard |
|
||||||
|
|
||||||
|
## Security note on credentials
|
||||||
|
|
||||||
|
`pki/uml-l0-rca/` documents the tooling for a **real, EU CCMS L0 ECTL-registered**
|
||||||
|
root CA used elsewhere in the wider micrOBU project. Its private key material
|
||||||
|
is intentionally **not** in this repository — `.gitignore` also backstops
|
||||||
|
this (`*.vkey`, `*.ekey`, `private/`).
|
||||||
|
|
||||||
|
`station-link/python/demo-chain.vcr` is unrelated: a separate, throwaway,
|
||||||
|
**non-registered** root/AA/AT chain generated specifically for this repo's
|
||||||
|
quickstart with `pki/uml-l0-rca/bin/windows/vidf_issue.exe`. Its
|
||||||
|
`HashedId8` values do not match the registered root, it grants no real-world
|
||||||
|
trust, and regenerating it is safe:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$pool = "<some scratch directory>"
|
||||||
|
$exe = "pki\uml-l0-rca\bin\windows\vidf_issue.exe"
|
||||||
|
openssl ecparam -name prime256v1 -genkey -noout -out "$pool\demo_root_key.pem"
|
||||||
|
& $exe root --key "$pool\demo_root_key.pem" --name "Demo Root (NOT REGISTERED)" --id DEMO_RCA --out $pool --years 10
|
||||||
|
& $exe authority --issuer "$pool\DEMO_RCA.oer" --issuer-key "$pool\demo_root_key.pem" --name "Demo AA" --id DEMO_AA --out $pool --years 5
|
||||||
|
& $exe ticket --issuer "$pool\DEMO_AA.oer" --issuer-key "$pool\DEMO_AA.vkey" --id DEMO_AT --out $pool --hours 8760 --root "$pool\DEMO_RCA.oer"
|
||||||
|
python external\vanetza-idf\ports\esp_idf\tools\credential_bundle.py build `
|
||||||
|
--pool $pool --root DEMO_RCA --aa DEMO_AA --at DEMO_AT --out station-link\python\demo-chain.vcr
|
||||||
|
```
|
||||||
|
|
||||||
|
`pki/uml-l0-rca/reference-generator/` cross-validates certificate generation
|
||||||
|
against an independent Rust implementation ([`TheEnbyperor/c-its`](https://github.com/TheEnbyperor/c-its),
|
||||||
|
pinned commit in [`reference-generator/README.md`](pki/uml-l0-rca/reference-generator/README.md)).
|
||||||
|
Its vendored crates (`vendor/`) are excluded from this repository by
|
||||||
|
`.gitignore` for size; regenerate with `cargo vendor` from that directory's
|
||||||
|
`Cargo.lock`, or use `vidf_issue` directly as shown above — the vendor tree
|
||||||
|
is only needed for that independent cross-check, not for ordinary use.
|
||||||
|
|
||||||
|
## License / provenance
|
||||||
|
|
||||||
|
- Vanetza and its ESP-IDF port: BSD-3-Clause, see
|
||||||
|
[external/vanetza-idf/LICENSE.md](external/vanetza-idf/LICENSE.md) and
|
||||||
|
[external/vanetza-idf/PROVENANCE.md](external/vanetza-idf/PROVENANCE.md).
|
||||||
|
- V2X2MAP bridge: MIT, see
|
||||||
|
[tools/v2x2map-0.3.0/LICENSE](tools/v2x2map-0.3.0/LICENSE).
|
||||||
Vendored
Vendored
Vendored
Vendored
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user