Compare commits
3
Commits
f1770e11dd
...
611f7b69eb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
611f7b69eb | ||
|
|
528637dab6 | ||
|
|
f3ae81a8fe |
@@ -28,6 +28,9 @@ import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
|
||||
import com.hawhamburg.micr0bu.data.transport.UsbSerialState
|
||||
import com.hawhamburg.micr0bu.data.transport.ObuHardware
|
||||
import com.hawhamburg.micr0bu.ui.components.StatusTopBar
|
||||
import com.hawhamburg.micr0bu.ui.navigation.BottomNavBar
|
||||
import com.hawhamburg.micr0bu.ui.navigation.Screen
|
||||
@@ -84,7 +87,6 @@ class MainActivity : AppCompatActivity() {
|
||||
val useCaseEnabledMap by mqttViewModel.useCaseEnabledMap.collectAsState()
|
||||
val obuHardware by mqttViewModel.obuHardware.collectAsState()
|
||||
val usbSerialState by mqttViewModel.usbSerialState.collectAsState()
|
||||
val espRxMode by mqttViewModel.espRxMode.collectAsState()
|
||||
|
||||
MicrOBUTheme(darkTheme = state.darkTheme) {
|
||||
val view = LocalView.current
|
||||
@@ -123,7 +125,14 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = { StatusTopBar(state, mqttConnectionState) },
|
||||
topBar = {
|
||||
StatusTopBar(
|
||||
state = state,
|
||||
mqttConnectionState = mqttConnectionState,
|
||||
isEsp32 = obuHardware == ObuHardware.ESP32_C5,
|
||||
usbSerialState = usbSerialState,
|
||||
)
|
||||
},
|
||||
bottomBar = { BottomNavBar(navController) },
|
||||
) { innerPadding ->
|
||||
NavHost(
|
||||
@@ -166,12 +175,19 @@ class MainActivity : AppCompatActivity() {
|
||||
RecordingScreen(
|
||||
state = state,
|
||||
mqttConnectionState = mqttConnectionState,
|
||||
obuConnected = if (obuHardware == ObuHardware.ESP32_C5)
|
||||
usbSerialState == UsbSerialState.CONNECTED
|
||||
else
|
||||
mqttConnectionState == MqttConnectionState.CONNECTED,
|
||||
tripServiceState = tripServiceState,
|
||||
showBatteryOptPrompt = showBatteryOptPrompt,
|
||||
onToggleRecording = {
|
||||
// Start / stop both CSV recording and the event detection service
|
||||
// Start / stop both CSV recording and the event detection
|
||||
// service. Order matters on start: the CSV session must exist
|
||||
// before the trip row is written, so the trip can store its
|
||||
// id and later delete the CSV along with itself.
|
||||
viewModel.toggleRecording()
|
||||
tripViewModel.toggleRecording()
|
||||
tripViewModel.toggleRecording(viewModel.activeSessionId)
|
||||
},
|
||||
onOpenSessionLog = {
|
||||
navController.navigate(Screen.Log.route)
|
||||
@@ -253,8 +269,6 @@ class MainActivity : AppCompatActivity() {
|
||||
onMqttPrefsChange = mqttViewModel::updatePrefs,
|
||||
obuHardware = obuHardware,
|
||||
onObuHardwareChange = mqttViewModel::setObuHardware,
|
||||
espRxMode = espRxMode,
|
||||
onEspRxModeChange = mqttViewModel::setEspRxMode,
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
@@ -297,6 +311,7 @@ class MainActivity : AppCompatActivity() {
|
||||
DeveloperSettingsScreen(
|
||||
state = state,
|
||||
onDeveloperMode = viewModel::setDeveloperMode,
|
||||
onOpenSensorMonitor = { navController.navigate(Screen.Sensors.route) },
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
package com.hawhamburg.micr0bu.data
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.content.FileProvider
|
||||
import com.hawhamburg.micr0bu.data.db.DetectedEventEntity
|
||||
import com.hawhamburg.micr0bu.data.db.RecordedTripEntity
|
||||
import com.hawhamburg.micr0bu.data.db.V2xMessageEntity
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONArray
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
private val isoUtc = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US)
|
||||
private val humanLocal = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
|
||||
|
||||
fun tripFileName(trip: RecordedTripEntity): String =
|
||||
"micr0bu_trip_${humanLocal.format(Date(trip.startTime))
|
||||
.replace(" ", "_").replace(":", "-")}.csv"
|
||||
|
||||
/**
|
||||
* Builds a single combined CSV for one trip: the raw sensor samples recorded alongside it, the
|
||||
* events the detector fired, the GPS track, and every V2X message seen during the ride — all in
|
||||
* one file, ordered by time.
|
||||
*
|
||||
* **Why one file rather than a zip of tables.** The point of the export is correlation: what was
|
||||
* the bike doing when that CAM arrived, what did the detector make of it. Splitting those into
|
||||
* separate files pushes the join onto whoever opens it. A leading `type` column keeps the rows
|
||||
* distinguishable, which is the same shape the existing session CSV already uses, so the two
|
||||
* remain readable by the same tooling.
|
||||
*
|
||||
* Columns are the union of what the row types need; a row leaves the fields that don't apply to it
|
||||
* empty rather than inventing values. That is deliberately wide and sparse — spreadsheets and
|
||||
* pandas both handle it fine, and it keeps every value under a self-describing header instead of
|
||||
* a positional one that means different things per row.
|
||||
*
|
||||
* Sensor rows are copied through verbatim from the session CSV where one exists. Trips recorded
|
||||
* before the trip↔session link existed (schema v3 and earlier) have no `sessionId`, so their
|
||||
* export contains everything except the raw sensor stream.
|
||||
*/
|
||||
suspend fun buildTripCsv(
|
||||
context: Context,
|
||||
trip: RecordedTripEntity,
|
||||
events: List<DetectedEventEntity>,
|
||||
v2xMessages: List<V2xMessageEntity>,
|
||||
): String = withContext(Dispatchers.IO) {
|
||||
buildString {
|
||||
appendLine("# MicrOBU Trip Export")
|
||||
appendLine("# Trip ID,${trip.id}")
|
||||
appendLine("# Start,${isoUtc.format(Date(trip.startTime))}")
|
||||
appendLine("# End,${isoUtc.format(Date(trip.endTime))}")
|
||||
appendLine("# Distance_m,${trip.distanceMetres}")
|
||||
appendLine("# Events,${trip.eventCount}")
|
||||
appendLine("# V2X_messages,${v2xMessages.size}")
|
||||
appendLine("# Session_ID,${trip.sessionId ?: "(none - recorded before trip/session linking)"}")
|
||||
appendLine()
|
||||
appendLine(
|
||||
"type,timestamp_ms,timestamp_iso,lat,lon,speed_ms,heading_deg," +
|
||||
"event_type,confidence,peak_accel,peak_gyro,duration_ms," +
|
||||
"station_id,station_type,is_own,yaw_rate_dps,rssi_dbm"
|
||||
)
|
||||
|
||||
// GPS track: stored on the trip row as a JSON array of points, not in a table.
|
||||
for (point in trip.gpsTrackJson.toTrackPoints()) {
|
||||
appendLine(
|
||||
"gps,${point.timestamp},${isoUtc.format(Date(point.timestamp))}," +
|
||||
"${point.lat},${point.lon},,," +
|
||||
",,,,," +
|
||||
",,,"
|
||||
)
|
||||
}
|
||||
|
||||
for (e in events) {
|
||||
appendLine(
|
||||
"event,${e.timestamp},${isoUtc.format(Date(e.timestamp))}," +
|
||||
"${e.latitude},${e.longitude},${e.speedMps},," +
|
||||
"${e.type},${e.confidence},${e.peakAccelMagnitude},${e.peakGyroMagnitude},${e.durationMs}," +
|
||||
",,,,"
|
||||
)
|
||||
}
|
||||
|
||||
for (m in v2xMessages) {
|
||||
appendLine(
|
||||
"v2x,${m.timestamp},${isoUtc.format(Date(m.timestamp))}," +
|
||||
"${m.latitude},${m.longitude},${m.speedMps},${m.headingDeg}," +
|
||||
",,,,," +
|
||||
"${m.stationId},${m.stationType},${m.isOwn},${m.yawRateDps ?: ""},${m.rssiDbm ?: ""}"
|
||||
)
|
||||
}
|
||||
|
||||
// Raw sensor samples, copied verbatim from the session CSV. Appended last rather than
|
||||
// merge-sorted in: a long ride is hundreds of thousands of rows, and sorting them against
|
||||
// the (comparatively tiny) event/V2X sets in memory would defeat the streaming that
|
||||
// CsvExporter deliberately does. Each row carries its own timestamp, so sort on load.
|
||||
val sessionCsv = trip.sessionId?.let { File(File(context.filesDir, "sessions"), "$it.csv") }
|
||||
if (sessionCsv != null && sessionCsv.exists()) {
|
||||
appendLine()
|
||||
appendLine("# --- raw sensor samples (from session ${trip.sessionId}) ---")
|
||||
sessionCsv.forEachLine { line ->
|
||||
if (line.isNotBlank() && !line.startsWith("#")) appendLine(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Shares a trip's combined CSV via Android's share sheet. */
|
||||
suspend fun shareTripCsv(
|
||||
context: Context,
|
||||
trip: RecordedTripEntity,
|
||||
events: List<DetectedEventEntity>,
|
||||
v2xMessages: List<V2xMessageEntity>,
|
||||
) {
|
||||
val fileName = tripFileName(trip)
|
||||
val cacheFile = File(context.cacheDir, fileName)
|
||||
val csv = buildTripCsv(context, trip, events, v2xMessages)
|
||||
|
||||
withContext(Dispatchers.IO) { cacheFile.writeText(csv) }
|
||||
|
||||
val uri = FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.fileprovider",
|
||||
cacheFile,
|
||||
)
|
||||
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/csv"
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
putExtra(Intent.EXTRA_SUBJECT, "MicrOBU Trip Export — $fileName")
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
context.startActivity(Intent.createChooser(intent, "Export trip"))
|
||||
}
|
||||
|
||||
private data class TrackPoint(val timestamp: Long, val lat: Double, val lon: Double)
|
||||
|
||||
/** Parses [RecordedTripEntity.gpsTrackJson]; returns empty on anything malformed. */
|
||||
private fun String.toTrackPoints(): List<TrackPoint> = runCatching {
|
||||
val arr = JSONArray(this)
|
||||
(0 until arr.length()).mapNotNull { i ->
|
||||
val o = arr.optJSONObject(i) ?: return@mapNotNull null
|
||||
// Keys match TripRecordingService.appendGpsPoint: {"lat":..,"lon":..,"ts":..}
|
||||
TrackPoint(
|
||||
timestamp = o.optLong("ts", 0L),
|
||||
lat = o.optDouble("lat", Double.NaN),
|
||||
lon = o.optDouble("lon", Double.NaN),
|
||||
).takeIf { !it.lat.isNaN() && !it.lon.isNaN() }
|
||||
}
|
||||
}.getOrDefault(emptyList())
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.hawhamburg.micr0bu.data
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.hawhamburg.micr0bu.data.db.AppDatabase
|
||||
import com.hawhamburg.micr0bu.data.db.DetectedEventEntity
|
||||
import com.hawhamburg.micr0bu.data.db.RecordedTripEntity
|
||||
@@ -7,6 +9,10 @@ import com.hawhamburg.micr0bu.data.db.V2xMessageEntity
|
||||
import com.hawhamburg.micr0bu.domain.cam.Cam
|
||||
import com.hawhamburg.micr0bu.domain.detection.DetectedEvent
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import java.io.File
|
||||
|
||||
private const val TAG = "TripRepository"
|
||||
|
||||
/**
|
||||
* Repository that abstracts Room access for trips and detected events.
|
||||
@@ -14,9 +20,10 @@ import kotlinx.coroutines.flow.Flow
|
||||
* All suspend functions are safe to call from a coroutine running on any
|
||||
* dispatcher; Room executes the actual SQL on its own I/O thread pool.
|
||||
*/
|
||||
class TripRepository(db: AppDatabase) {
|
||||
class TripRepository(db: AppDatabase, private val context: Context) {
|
||||
|
||||
private val dao = db.tripDao()
|
||||
private val sessionDao = db.sessionDao()
|
||||
|
||||
// ── Trips ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -30,6 +37,7 @@ class TripRepository(db: AppDatabase) {
|
||||
distanceMetres: Float = 0f,
|
||||
eventCount: Int = 0,
|
||||
gpsTrackJson: String = "[]",
|
||||
sessionId: String? = null,
|
||||
): Long = dao.insertTrip(
|
||||
RecordedTripEntity(
|
||||
startTime = startTime,
|
||||
@@ -37,6 +45,7 @@ class TripRepository(db: AppDatabase) {
|
||||
distanceMetres = distanceMetres,
|
||||
eventCount = eventCount,
|
||||
gpsTrackJson = gpsTrackJson,
|
||||
sessionId = sessionId,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -65,7 +74,40 @@ class TripRepository(db: AppDatabase) {
|
||||
/** Emits all trips ordered by startTime DESC, updating whenever the DB changes. */
|
||||
fun getAllTrips(): Flow<List<RecordedTripEntity>> = dao.getAllTrips()
|
||||
|
||||
suspend fun deleteTrip(tripId: Long) = dao.deleteTripById(tripId)
|
||||
/** One-shot read of a single trip row, or null if it no longer exists. */
|
||||
suspend fun getTrip(tripId: Long): RecordedTripEntity? = dao.getTripById(tripId)
|
||||
|
||||
/**
|
||||
* One-shot snapshots for export. The Flow-returning variants above stay observable for the UI;
|
||||
* an export wants a value it can write out, not a stream it has to unsubscribe from.
|
||||
*/
|
||||
suspend fun getEventsForTripOnce(tripId: Long): List<DetectedEventEntity> =
|
||||
dao.getEventsForTrip(tripId).first()
|
||||
|
||||
suspend fun getV2xMessagesForTripOnce(tripId: Long): List<V2xMessageEntity> =
|
||||
dao.getV2xMessagesForTrip(tripId).first()
|
||||
|
||||
/**
|
||||
* Deletes a trip and everything belonging to it: detected events and V2X messages go via the
|
||||
* schema's CASCADE foreign keys, and the CSV recorded alongside it is removed here.
|
||||
*
|
||||
* The CSV is a plain file outside the database, so nothing deletes it implicitly - before
|
||||
* this, every deleted trip left one behind and the user had to clear them by hand.
|
||||
*/
|
||||
suspend fun deleteTrip(tripId: Long) {
|
||||
val sessionId = dao.getTripById(tripId)?.sessionId
|
||||
dao.deleteTripById(tripId)
|
||||
if (sessionId == null) return
|
||||
|
||||
// The CSV lives in two places as far as the user is concerned: a file on disk, and a row
|
||||
// in `sessions` that makes it visible on the Session Log screen. Deleting only the file
|
||||
// left a phantom entry there pointing at nothing, so both go.
|
||||
sessionDao.deleteById(sessionId)
|
||||
val csv = File(File(context.filesDir, "sessions"), "$sessionId.csv")
|
||||
if (csv.exists() && !csv.delete()) {
|
||||
Log.w(TAG, "deleteTrip: failed to delete CSV for session $sessionId")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Events ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -112,6 +154,7 @@ class TripRepository(db: AppDatabase) {
|
||||
speedMps = cam.speedMps,
|
||||
headingDeg = cam.headingDeg,
|
||||
yawRateDps = cam.yawRateDps,
|
||||
rssiDbm = cam.rssiDbm,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
package com.hawhamburg.micr0bu.data.cam
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.hawhamburg.micr0bu.data.GnssReading
|
||||
import com.hawhamburg.micr0bu.data.SensorRepository
|
||||
import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
|
||||
import com.hawhamburg.micr0bu.data.mqtt.MqttRepository
|
||||
import com.hawhamburg.micr0bu.data.mqtt.ObuHardwarePreferences
|
||||
import com.hawhamburg.micr0bu.data.mqtt.UseCaseAlertPreferences
|
||||
import com.hawhamburg.micr0bu.data.transport.EspRxMode
|
||||
import com.hawhamburg.micr0bu.data.transport.ObuHardware
|
||||
import com.hawhamburg.micr0bu.data.transport.SerialFrameType
|
||||
import com.hawhamburg.micr0bu.data.transport.UsbSerialState
|
||||
import com.hawhamburg.micr0bu.data.transport.UsbSerialTransport
|
||||
import com.hawhamburg.micr0bu.domain.asn1.RealAsn1UperCodec
|
||||
import com.hawhamburg.micr0bu.domain.cam.Cam
|
||||
@@ -36,6 +38,7 @@ import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
private const val TAG = "CamUseCaseRepo"
|
||||
private const val CAM_TOPIC = "v2x-uca/output/json/cam"
|
||||
private const val OBU_GNSS_TOPIC = "v2x/rx/obu_gnss"
|
||||
private const val PRUNE_INTERVAL_MS = 1_000L
|
||||
@@ -78,7 +81,8 @@ class CamUseCaseRepository @Inject constructor(
|
||||
private val engine = UseCaseDetectionEngine()
|
||||
private val sensorRepository = SensorRepository(context)
|
||||
|
||||
@Volatile private var espRxMode: EspRxMode = EspRxMode.SEND_AND_RECEIVE
|
||||
/** Latest selected OBU hardware, so serial-link events only act on the ESP32-C5 path. */
|
||||
@Volatile private var currentHardware: ObuHardware = ObuHardware.CIT_ONE
|
||||
|
||||
private val _ownStationId = MutableStateFlow<Long?>(null)
|
||||
/** The ego OBU's own station ID, learned from `v2x/rx/obu_gnss`. Null until known. */
|
||||
@@ -168,13 +172,40 @@ class CamUseCaseRepository @Inject constructor(
|
||||
scope.launch {
|
||||
usbSerialTransport.incomingFrames.collect { frame ->
|
||||
if (frame.type != SerialFrameType.CAM_RX) return@collect
|
||||
if (espRxMode == EspRxMode.SEND_ONLY) return@collect
|
||||
if (usbSerialTransport.state.value != UsbSerialState.CONNECTED) return@collect
|
||||
handleCamFromSerial(frame.payload)
|
||||
}
|
||||
}
|
||||
|
||||
// Drop everything the serial link taught us the moment it goes down. Without this, the
|
||||
// last-seen positions and their alerts linger on the map and in the use-case panel after
|
||||
// an unplug, which reads as live traffic - the worst kind of stale on a safety display.
|
||||
scope.launch {
|
||||
obuHardwarePrefs.espRxModeFlow.collect { espRxMode = it }
|
||||
usbSerialTransport.state.collect { state ->
|
||||
// ESP32-only: on the CiT One path this transport is permanently DISCONNECTED and
|
||||
// resetting here would wipe perfectly good MQTT-derived state.
|
||||
if (currentHardware == ObuHardware.ESP32_C5 && state != UsbSerialState.CONNECTED) {
|
||||
engine.reset()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Our own station ID. On the CiT One path it's learned from v2x/rx/obu_gnss; the ESP32-C5
|
||||
// path has no such topic, so it comes from the same persisted value CamTransmitLoop puts
|
||||
// in outgoing CAMs.
|
||||
//
|
||||
// Without this the ID stayed null on the ESP32 path and the self-heard-TX filter in
|
||||
// [handleCamFromSerial] never fired - so the phone's own CAMs, which the ESP32 hears back
|
||||
// off the air in promiscuous mode, were tracked as a *remote* station: a ghost vehicle
|
||||
// sitting exactly on top of the ego position, fed into the detection engine as a
|
||||
// collision partner for itself.
|
||||
scope.launch {
|
||||
obuHardwarePrefs.obuHardwareFlow.collect { hardware ->
|
||||
currentHardware = hardware
|
||||
if (hardware == ObuHardware.ESP32_C5) {
|
||||
_ownStationId.value = obuHardwarePrefs.getOrCreateOwnStationId()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,10 +283,27 @@ class CamUseCaseRepository @Inject constructor(
|
||||
*/
|
||||
private fun handleCamFromSerial(payload: ByteArray) {
|
||||
if (payload.isEmpty()) return
|
||||
val rssiDbm = payload[0].toInt() // signed dBm from the firmware's promiscuous RX metadata
|
||||
val camBytes = payload.copyOfRange(1, payload.size) // payload[0] is RSSI, not part of the CAM
|
||||
val cam = camCodec.decodeCam(camBytes, System.currentTimeMillis()) ?: return
|
||||
val cam = camCodec.decodeCam(camBytes, System.currentTimeMillis())?.copy(rssiDbm = rssiDbm)
|
||||
if (cam == null) {
|
||||
// Logged, not silently dropped: "the app shows nothing" has two completely different
|
||||
// causes - frames not arriving at all, versus arriving and failing to decode - and
|
||||
// without this line they're indistinguishable from the outside. rssi is signed.
|
||||
Log.w(
|
||||
TAG,
|
||||
"handleCamFromSerial: decode FAILED for ${camBytes.size}-byte CAM " +
|
||||
"(rssi=$rssiDbm dBm) - first bytes: ${camBytes.toHexPreview()}",
|
||||
)
|
||||
return
|
||||
}
|
||||
Log.d(TAG, "handleCamFromSerial: decoded station=${cam.stationId} " +
|
||||
"lat=${cam.latitude} lon=${cam.longitude} speed=${cam.speedMps} rssi=$rssiDbm dBm")
|
||||
if (_ownStationId.value != null && cam.stationId == _ownStationId.value) return // self-heard TX
|
||||
engine.onRemoteCam(cam)
|
||||
_processedCam.tryEmit(cam)
|
||||
}
|
||||
|
||||
private fun ByteArray.toHexPreview(limit: Int = 16): String =
|
||||
take(limit).joinToString(" ") { "%02x".format(it) } + if (size > limit) " ..." else ""
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
DetectedEventEntity::class,
|
||||
V2xMessageEntity::class,
|
||||
],
|
||||
version = 3,
|
||||
version = 4,
|
||||
exportSchema = false,
|
||||
)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
@@ -34,13 +34,30 @@ abstract class AppDatabase : RoomDatabase() {
|
||||
AppDatabase::class.java,
|
||||
"micr0bu.db",
|
||||
)
|
||||
.addMigrations(MIGRATION_1_2, MIGRATION_2_3)
|
||||
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4)
|
||||
.build()
|
||||
.also { INSTANCE = it }
|
||||
}
|
||||
|
||||
// ── Migrations ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Two additions:
|
||||
* - `trips.sessionId` links a trip to the CSV recording session captured alongside it, so
|
||||
* deleting a trip can also delete its `filesDir/sessions/<id>.csv`. Nullable, because
|
||||
* trips recorded before this column existed have no way to identify their CSV - those
|
||||
* files stay orphaned and have to be cleared by hand once.
|
||||
* - `v2x_messages.rssiDbm` stores the received signal strength the ESP32-C5 firmware
|
||||
* already sends in every CAM_RX frame but which the app previously discarded, so
|
||||
* range-vs-signal can be analysed from a recorded ride.
|
||||
*/
|
||||
private val MIGRATION_3_4 = object : Migration(3, 4) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
database.execSQL("ALTER TABLE `trips` ADD COLUMN `sessionId` TEXT")
|
||||
database.execSQL("ALTER TABLE `v2x_messages` ADD COLUMN `rssiDbm` INTEGER")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the `trips` and `detected_events` tables introduced in Phase A.
|
||||
* The existing `sessions` table is left untouched.
|
||||
|
||||
@@ -18,4 +18,16 @@ data class RecordedTripEntity(
|
||||
val distanceMetres: Float,
|
||||
val eventCount: Int,
|
||||
val gpsTrackJson: String,
|
||||
|
||||
/**
|
||||
* ID of the CSV recording session captured alongside this trip, or null for trips recorded
|
||||
* before this column existed (schema v3 and earlier).
|
||||
*
|
||||
* Trips and CSV sessions are written by two independent subsystems - [TripRecordingService]
|
||||
* and SensorViewModel - that the Recording screen happens to start together. Without this
|
||||
* link, deleting a trip left its `filesDir/sessions/<id>.csv` behind forever with nothing in
|
||||
* the UI referencing it. Matching them by timestamp instead was rejected: two recordings
|
||||
* close together would silently delete the wrong file.
|
||||
*/
|
||||
val sessionId: String? = null,
|
||||
)
|
||||
|
||||
@@ -48,4 +48,7 @@ data class V2xMessageEntity(
|
||||
val speedMps: Double,
|
||||
val headingDeg: Double,
|
||||
val yawRateDps: Double?,
|
||||
|
||||
/** Received signal strength, dBm, for CAMs heard over the air. Null on the CiT One path. */
|
||||
val rssiDbm: Int? = null,
|
||||
)
|
||||
|
||||
@@ -2,15 +2,16 @@ package com.hawhamburg.micr0bu.data.mqtt
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import com.hawhamburg.micr0bu.data.transport.EspRxMode
|
||||
import com.hawhamburg.micr0bu.data.transport.ObuHardware
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.random.Random
|
||||
|
||||
private val Context.obuHardwareDataStore by preferencesDataStore(name = "obu_hardware_prefs")
|
||||
|
||||
@@ -25,7 +26,7 @@ class ObuHardwarePreferences @Inject constructor(
|
||||
) {
|
||||
private object Keys {
|
||||
val OBU_HARDWARE = stringPreferencesKey("obu_hardware")
|
||||
val ESP_RX_MODE = stringPreferencesKey("esp_rx_mode")
|
||||
val OWN_STATION_ID = longPreferencesKey("own_station_id")
|
||||
}
|
||||
|
||||
val obuHardwareFlow: Flow<ObuHardware> = context.obuHardwareDataStore.data.map { prefs ->
|
||||
@@ -36,16 +37,31 @@ class ObuHardwarePreferences @Inject constructor(
|
||||
context.obuHardwareDataStore.edit { prefs -> prefs[Keys.OBU_HARDWARE] = hardware.id }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the ESP32-C5 path processes received CAM traffic or only transmits — see
|
||||
* [EspRxMode]'s KDoc for what this does and doesn't actually control. Defaults to
|
||||
* [EspRxMode.SEND_AND_RECEIVE] (full duplex, today's existing behavior).
|
||||
*/
|
||||
val espRxModeFlow: Flow<EspRxMode> = context.obuHardwareDataStore.data.map { prefs ->
|
||||
EspRxMode.entries.firstOrNull { it.id == prefs[Keys.ESP_RX_MODE] } ?: EspRxMode.SEND_AND_RECEIVE
|
||||
/** This device's own CAM StationID, or null if one hasn't been assigned yet. */
|
||||
val ownStationIdFlow: Flow<Long?> = context.obuHardwareDataStore.data.map { prefs ->
|
||||
prefs[Keys.OWN_STATION_ID]
|
||||
}
|
||||
|
||||
suspend fun setEspRxMode(mode: EspRxMode) {
|
||||
context.obuHardwareDataStore.edit { prefs -> prefs[Keys.ESP_RX_MODE] = mode.id }
|
||||
/**
|
||||
* Returns this device's own CAM StationID, generating and persisting a random one on first
|
||||
* call.
|
||||
*
|
||||
* Replaces the previous hardcoded 0: receivers key on StationID to track a station across
|
||||
* successive CAMs, so every MicrOBU broadcasting 0 makes two units in the same area
|
||||
* indistinguishable to any receiver — including this app's own detection engine, which
|
||||
* dedupes remote stations by ID. Random rather than derived from a hardware identifier both
|
||||
* because ETSI expects station IDs to be pseudonymous and because Android hardware IDs aren't
|
||||
* readable without privileged permissions on modern versions.
|
||||
*
|
||||
* Range is 1..2^32-2: StationID is INTEGER(0..4294967295), and 0 is avoided so leftover
|
||||
* placeholder traffic stays distinguishable from a real assignment.
|
||||
*/
|
||||
suspend fun getOrCreateOwnStationId(): Long {
|
||||
val prefs = context.obuHardwareDataStore.edit { p ->
|
||||
if (p[Keys.OWN_STATION_ID] == null) {
|
||||
p[Keys.OWN_STATION_ID] = Random.nextLong(1L, 0xFFFF_FFFEL)
|
||||
}
|
||||
}
|
||||
return prefs[Keys.OWN_STATION_ID]!!
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,9 @@ enum class ObuHardware(val id: String) {
|
||||
*
|
||||
* Real serial link + CAM UPER codec are implemented on both sides — see
|
||||
* [com.hawhamburg.micr0bu.data.transport.UsbSerialTransport] (phone) and
|
||||
* `obu-firmware/main/serial_link.c` (firmware). See also [EspRxMode] for the send-only vs
|
||||
* send-and-receive toggle (Settings > Connection).
|
||||
* `obu-firmware/main/serial_link.c` (firmware). Reception is always on: the ESP32 must keep
|
||||
* its receiver enabled for raw TX to work at all (ESP-IDF only emits raw 802.11 frames while
|
||||
* promiscuous or associated), so there is nothing meaningful for the app to toggle.
|
||||
*/
|
||||
ESP32_C5("esp32_c5"),
|
||||
}
|
||||
|
||||
@@ -128,7 +128,17 @@ object CamUperCodec {
|
||||
|
||||
bw.putBits(1023 - (-1023), 11) // curvatureValue: unavailable (not derived - see class KDoc)
|
||||
bw.putBits(7, 3) // curvatureConfidence: unavailable
|
||||
bw.putBits(2, 2) // curvatureCalculationMode: unavailable
|
||||
|
||||
// CurvatureCalculationMode is the ONE extensible ENUMERATED in this message:
|
||||
// ENUMERATED {yawRateUsed(0), yawRateNotUsed(1), unavailable(2), ...}
|
||||
// UPER encodes an extensible ENUMERATED as an extension bit followed by the index into
|
||||
// the root list - 1 + 2 = 3 bits, not 2. Both this encoder and its `cam.c` ancestor wrote
|
||||
// only the 2-bit index, which shifted yawRate and the whole low-frequency container one
|
||||
// bit early for any standards-compliant receiver. It went unnoticed because both ends of
|
||||
// this project shared the same mistake; phone <-> ESP32 agreed perfectly with each other
|
||||
// and with nothing else.
|
||||
bw.putBits(0, 1) // extension bit: value is in the root list
|
||||
bw.putBits(2, 2) // curvatureCalculationMode: unavailable(2)
|
||||
|
||||
val yawRateCentiDegS = cam.yawRateDps
|
||||
?.let { (it * 100.0).roundToInt().coerceIn(-32766, 32766) }
|
||||
@@ -148,11 +158,18 @@ object CamUperCodec {
|
||||
/**
|
||||
* Decodes a UPER CAM byte string into a domain [Cam] (always `isOwn = false` — this is only
|
||||
* used for CAMs received from other stations; the ego's own CAM never round-trips through
|
||||
* this). Returns null if the bytes aren't a CAM this codec understands: wrong
|
||||
* protocolVersion/messageID, a CamParameters/HighFrequencyContainer/LowFrequencyContainer
|
||||
* shape we don't decode (extension in use, RSU container instead of vehicle, or a
|
||||
* specialVehicleContainer present — none of those are things this project transmits or
|
||||
* currently needs to receive), or a truncated frame.
|
||||
* this).
|
||||
*
|
||||
* Accepts any CAM whose BasicContainer + BasicVehicleContainerHighFrequency are non-extended,
|
||||
* regardless of which optional high-frequency fields the sender includes or whether it carries
|
||||
* a lowFrequencyContainer or a specialVehicleContainer — all of that is declared after the
|
||||
* fields read here, so it neither shifts them nor needs parsing. A bus, an emergency vehicle,
|
||||
* or a car sending lanePosition and steering-wheel angle all decode normally.
|
||||
*
|
||||
* Returns null only when the bytes genuinely can't be read as vehicle kinematics: wrong
|
||||
* protocolVersion/messageID, an extension marker in use on a container this parses,
|
||||
* rsuContainerHighFrequency (roadside infrastructure — carries no heading/speed/yaw at all,
|
||||
* so there is nothing for the detection engine to consume), or a truncated frame.
|
||||
*
|
||||
* [receivedAtEpochMs] becomes [Cam.timestamp] (wall-clock receipt time) — GenerationDeltaTime
|
||||
* alone (a value mod 65536 ms) isn't enough on its own to reconstruct an absolute timestamp
|
||||
@@ -179,9 +196,12 @@ object CamUperCodec {
|
||||
|
||||
val camParamsExt = br.getBitsInt(1)
|
||||
if (camParamsExt != 0) return null // extension in use - unsupported shape
|
||||
val lowFreqPresent = br.getBitsInt(1) == 1
|
||||
val specialVehiclePresent = br.getBitsInt(1) == 1
|
||||
if (specialVehiclePresent) return null // different container shape we don't parse
|
||||
// lowFrequencyContainer / specialVehicleContainer presence bits. Both containers are
|
||||
// declared after highFrequencyContainer, so everything this decoder reads comes first and
|
||||
// neither needs parsing - a public-transport bus or an emergency vehicle now decodes for
|
||||
// its position and kinematics like any other station, instead of being dropped.
|
||||
br.getBits(1)
|
||||
br.getBits(1)
|
||||
|
||||
val basicContainerExt = br.getBitsInt(1)
|
||||
if (basicContainerExt != 0) return null
|
||||
@@ -201,7 +221,17 @@ object CamUperCodec {
|
||||
val highFreqIndex = br.getBitsInt(1)
|
||||
if (highFreqExt != 0 || highFreqIndex != 0) return null // extension, or rsuContainerHighFrequency
|
||||
|
||||
br.getBits(7) // 7 optional-presence bits
|
||||
// Optional-presence bitmap for BasicVehicleContainerHighFrequency's 7 trailing OPTIONAL
|
||||
// fields: accelerationControl, lanePosition, steeringWheelAngle, lateralAcceleration,
|
||||
// verticalAcceleration, performanceClass, cenDsrcTollingZone (see
|
||||
// C-ITS-Parser/autogen/asn.1/cam_1_4_1.asn).
|
||||
//
|
||||
// Consumed but not acted on, and that is correct: UPER writes a SEQUENCE's presence
|
||||
// bitmap up front but each field's VALUE in declaration order, and all seven of these are
|
||||
// declared AFTER yawRate. Everything this decoder extracts (heading..yawRate) therefore
|
||||
// sits between the bitmap and the optionals, at a fixed offset no matter which optionals
|
||||
// a sender includes. Do not "skip" the optional values here - they are not here.
|
||||
br.getBits(7)
|
||||
|
||||
val headingRaw = br.getBitsInt(12)
|
||||
br.getBits(7) // headingConfidence
|
||||
@@ -227,20 +257,27 @@ object CamUperCodec {
|
||||
|
||||
br.getBits(11) // curvatureValue
|
||||
br.getBits(3) // curvatureConfidence
|
||||
br.getBits(2) // curvatureCalculationMode
|
||||
|
||||
// CurvatureCalculationMode: extensible ENUMERATED - extension bit, then the root index.
|
||||
// See the matching comment in [encode]. If the extension bit is set the sender used a
|
||||
// value added in a later spec revision, encoded as a length-prefixed extension addition
|
||||
// this decoder can't skip reliably - bail rather than misread everything after it.
|
||||
if (br.getBitsInt(1) != 0) return null
|
||||
br.getBits(2) // curvatureCalculationMode root index
|
||||
|
||||
val yawRateRaw = br.getBitsInt(16) + (-32766)
|
||||
br.getBits(3) // yawRateConfidence
|
||||
val yawRateDps = if (yawRateRaw == YAW_RATE_UNAVAILABLE) null else yawRateRaw / 100.0
|
||||
|
||||
if (lowFreqPresent) {
|
||||
val lowFreqExt = br.getBitsInt(1)
|
||||
if (lowFreqExt != 0) return null
|
||||
br.getBits(4) // vehicleRole
|
||||
br.getBits(8) // exteriorLights
|
||||
br.getBits(6) // pathHistory count (0..40) - not decoded into path points, just consumed
|
||||
}
|
||||
|
||||
// Everything after yawRate is deliberately left unread: the 7 optional high-frequency
|
||||
// fields, then lowFrequencyContainer (vehicleRole / exteriorLights / pathHistory), then
|
||||
// specialVehicleContainer. None of it maps onto [Cam], and because it all follows the
|
||||
// fields above, not parsing it cannot misalign anything already extracted.
|
||||
//
|
||||
// IMPORTANT: if a future change needs any of those - path history is the likely one - the
|
||||
// 7 optionals must be parsed and consumed first, in declaration order, or every read after
|
||||
// them lands at the wrong bit offset. At that point this hand-written decoder stops being
|
||||
// the right tool; use the generated codec (see C-ITS-Parser) instead.
|
||||
return Cam(
|
||||
stationId = stationId,
|
||||
stationType = stationType,
|
||||
|
||||
@@ -56,6 +56,17 @@ data class Cam(
|
||||
/** Optional longitudinal acceleration control field, m/s², where available. */
|
||||
val accelerationMps2: Double? = null,
|
||||
|
||||
/**
|
||||
* Received signal strength in dBm for a CAM heard over the air, or null when there is no such
|
||||
* measurement.
|
||||
*
|
||||
* Only the ESP32-C5 path populates this: the firmware reads it from the promiscuous RX
|
||||
* callback's packet metadata (`wifi_pkt_rx_ctrl_t.rssi`) and puts it in byte 0 of every
|
||||
* CAM_RX frame. Null for own CAMs and for everything on the CiT One path, whose Use Case API
|
||||
* JSON carries no equivalent field.
|
||||
*/
|
||||
val rssiDbm: Int? = null,
|
||||
|
||||
/** Wall-clock ms this CAM was received/processed. */
|
||||
val timestamp: Long,
|
||||
|
||||
|
||||
@@ -7,13 +7,10 @@ import kotlin.math.abs
|
||||
* Builds an outgoing [Cam] from the phone's own GNSS + gyroscope, for the ESP32-C5 hardware
|
||||
* path (Phase 03, Section 13) where the OBU itself generates no CAM at all — the phone must.
|
||||
*
|
||||
* This class only does the sensor-fusion-into-CAM-fields part, which is independent of the
|
||||
* (not yet defined) wire protocol to the ESP32-C5. It is **not yet wired into any transmit
|
||||
* pipeline** — nothing calls this today. Once the ESP32 firmware protocol is translated to
|
||||
* Kotlin and [com.hawhamburg.micr0bu.domain.asn1.Asn1UperCodec] has a real implementation, the
|
||||
* intended flow is:
|
||||
* This class only does the sensor-fusion-into-CAM-fields part, independent of the wire protocol
|
||||
* to the ESP32-C5. Live flow, driven by [com.hawhamburg.micr0bu.service.CamTransmitLoop]:
|
||||
*
|
||||
* `PhoneCamBuilder.build(...)` → `Asn1UperCodec.encodeCam(...)` → `UsbSerialTransport` (write).
|
||||
* `PhoneCamBuilder.build(...)` → `RealAsn1UperCodec.encodeCam(...)` → `UsbSerialTransport` (write).
|
||||
*
|
||||
* Position/speed/heading come straight from GNSS. Yaw rate is derived from the gyroscope's
|
||||
* z-axis reading (rotation about the vertical axis while the phone is roughly flat/mounted
|
||||
@@ -22,22 +19,27 @@ import kotlin.math.abs
|
||||
*/
|
||||
object PhoneCamBuilder {
|
||||
|
||||
/** Placeholder station ID until real station-ID assignment/config exists for this path. */
|
||||
private const val PLACEHOLDER_OWN_STATION_ID = 0L
|
||||
|
||||
/**
|
||||
* @param gnss latest phone GNSS fix.
|
||||
* @param gyroZRadPerSec latest gyroscope z-axis reading, rad/s (device frame). Positive per
|
||||
* Android's convention is counter-clockwise around +Z; converted to the clockwise-positive
|
||||
* yaw rate convention already used by [Cam.yawRateDps] to match OBU/remote CAM data.
|
||||
* @param stationId this device's own station ID. Defaults to a placeholder until Phase 03
|
||||
* defines how the phone learns/assigns an ID on the ESP32-C5 path (the CiT One path
|
||||
* currently learns this from `v2x/rx/obu_gnss`'s own_info, which doesn't exist here).
|
||||
* @param stationId this device's own station ID, from
|
||||
* [com.hawhamburg.micr0bu.data.mqtt.ObuHardwarePreferences.getOrCreateOwnStationId] — a
|
||||
* persisted random value, not a placeholder. Receivers use it to track this station across
|
||||
* successive CAMs, so it must be stable for the life of the install and distinct per device.
|
||||
* @param longitudinalAccelMps2 along-track acceleration, signed (positive = accelerating).
|
||||
* Derived from successive GNSS speed samples by [com.hawhamburg.micr0bu.service.CamTransmitLoop]
|
||||
* rather than from the accelerometer: CAM wants acceleration along the direction of travel,
|
||||
* and the raw accelerometer is in the device frame with gravity mixed in, so it can't supply
|
||||
* that without full orientation estimation. Null when it can't be computed (no previous fix,
|
||||
* stale sample), which encodes as the ASN.1 `unavailable` sentinel.
|
||||
*/
|
||||
fun build(
|
||||
gnss: GnssReading,
|
||||
gyroZRadPerSec: Float?,
|
||||
stationId: Long = PLACEHOLDER_OWN_STATION_ID,
|
||||
stationId: Long,
|
||||
longitudinalAccelMps2: Double? = null,
|
||||
): Cam {
|
||||
val yawRateDps = gyroZRadPerSec?.let { -it * RAD_TO_DEG } // negate: CCW+ -> CW+ convention
|
||||
|
||||
@@ -49,6 +51,7 @@ object PhoneCamBuilder {
|
||||
speedMps = gnss.speedMs.toDouble(),
|
||||
headingDeg = normalizeHeading(gnss.bearingDeg.toDouble()),
|
||||
yawRateDps = yawRateDps?.let { if (abs(it) < YAW_RATE_NOISE_FLOOR_DPS) 0.0 else it },
|
||||
accelerationMps2 = longitudinalAccelMps2,
|
||||
timestamp = gnss.timestamp,
|
||||
isOwn = true,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.hawhamburg.micr0bu.domain.denm
|
||||
|
||||
import com.hawhamburg.micr0bu.domain.cam.JsonFieldReader
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* A decentralized environmental notification received from another station — a hazard at a fixed
|
||||
* place, as opposed to [com.hawhamburg.micr0bu.domain.cam.Cam]'s "here I am, moving" beacon.
|
||||
*
|
||||
* Only the fields needed to put a pin on the live map are modelled. DENM carries a great deal
|
||||
* more (validity duration, relevance area, traffic direction, trace paths); none of it is used
|
||||
* yet, and inventing a fuller model before there's a consumer for it would just be guesswork.
|
||||
*
|
||||
* **Availability:** DENM reaches the app only on the CiT One path, via the Use Case API's
|
||||
* `v2x-uca/output/json/denm` topic. The ESP32-C5 path receives none — the firmware's
|
||||
* `gn_unwrap.c` accepts BTP-B destination port 2001 (CAM) only and drops port 2002 (DENM) before
|
||||
* anything is forwarded over the serial link. See that file's header comment.
|
||||
*/
|
||||
data class DenmEvent(
|
||||
/** Originating station ID. */
|
||||
val stationId: Long,
|
||||
|
||||
/** Event position (WGS84 degrees) — where the hazard is, not where the sender is. */
|
||||
val latitude: Double,
|
||||
val longitude: Double,
|
||||
|
||||
/** ETSI TS 102 894-2 CauseCode, or null if the payload didn't carry one. */
|
||||
val causeCode: Int?,
|
||||
|
||||
/** SubCauseCode qualifying [causeCode], or null. */
|
||||
val subCauseCode: Int?,
|
||||
|
||||
/** Wall-clock ms this DENM was received. */
|
||||
val timestamp: Long,
|
||||
) {
|
||||
/**
|
||||
* Stable identity for map/list dedup: successive DENMs about the same hazard from the same
|
||||
* station should replace each other rather than pile up as separate pins. ETSI's real identity
|
||||
* is actionID (stationID + sequenceNumber); this approximates it with the cause, since the
|
||||
* Use Case API's JSON doesn't reliably expose a sequence number.
|
||||
*/
|
||||
val dedupKey: String get() = "$stationId/${causeCode ?: -1}/${subCauseCode ?: -1}"
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the processed DENM JSON published by the consider it Use Case API on
|
||||
* `v2x-uca/output/json/denm`.
|
||||
*
|
||||
* Same field-name tolerance approach as [com.hawhamburg.micr0bu.domain.cam.CamParser] — confirmed
|
||||
* spellings first, plausible alternatives as fallbacks via [JsonFieldReader] — because the exact
|
||||
* schema hasn't been pinned against real OBU payloads yet. Returns null rather than a
|
||||
* half-populated event when position is missing: a DENM with no position is useless to a map and
|
||||
* worse than absent on a hazard display.
|
||||
*/
|
||||
object DenmParser {
|
||||
|
||||
fun parse(json: String, timestamp: Long = System.currentTimeMillis()): DenmEvent? {
|
||||
val obj = runCatching { JSONObject(json) }.getOrNull() ?: return null
|
||||
|
||||
// Event position may sit at the top level or nested under an eventPosition/
|
||||
// situation-style object, depending on how the API flattens the ASN.1.
|
||||
val (lat, lon) = JsonFieldReader.firstLatLon(obj)
|
||||
?: obj.optJSONObject("eventPosition")?.let { JsonFieldReader.firstLatLon(it) }
|
||||
?: obj.optJSONObject("management")?.optJSONObject("eventPosition")
|
||||
?.let { JsonFieldReader.firstLatLon(it) }
|
||||
?: return null
|
||||
|
||||
val stationId = JsonFieldReader.firstLong(obj, "stationId", "stationID", "station_id")
|
||||
?: obj.optJSONObject("management")?.let {
|
||||
JsonFieldReader.firstLong(it, "stationId", "stationID", "station_id")
|
||||
}
|
||||
?: return null
|
||||
|
||||
val situation = obj.optJSONObject("situation")
|
||||
val causeCode = JsonFieldReader.firstInt(obj, "causeCode", "cause_code", "cause")
|
||||
?: situation?.let { JsonFieldReader.firstInt(it, "causeCode", "cause_code", "cause") }
|
||||
val subCauseCode = JsonFieldReader.firstInt(obj, "subCauseCode", "sub_cause_code", "subCause")
|
||||
?: situation?.let { JsonFieldReader.firstInt(it, "subCauseCode", "sub_cause_code", "subCause") }
|
||||
|
||||
return DenmEvent(
|
||||
stationId = stationId,
|
||||
latitude = lat,
|
||||
longitude = lon,
|
||||
causeCode = causeCode,
|
||||
subCauseCode = subCauseCode,
|
||||
timestamp = timestamp,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package com.hawhamburg.micr0bu.service
|
||||
|
||||
import android.content.Context
|
||||
import com.hawhamburg.micr0bu.data.GnssReading
|
||||
import com.hawhamburg.micr0bu.data.SensorRepository
|
||||
import com.hawhamburg.micr0bu.data.transport.UsbSerialTransport
|
||||
import com.hawhamburg.micr0bu.domain.asn1.RealAsn1UperCodec
|
||||
import com.hawhamburg.micr0bu.domain.cam.PhoneCamBuilder
|
||||
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.coroutineScope
|
||||
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 javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Manual bench-test CAM transmitter for the ESP32-C5 path (Phase 03) — a fixed-rate (1 Hz) CAM
|
||||
* ping built from the phone's real GNSS and gyroscope, independent of [CamTransmitLoop] and not
|
||||
* tied to an active trip recording. Purpose: verify the phone <-> ESP32-C5 serial link and the
|
||||
* ESP32's TX/RX radio path end-to-end without needing a full recording session — the CAM
|
||||
* equivalent of the CiT One path's manual DENM trigger
|
||||
* ([com.hawhamburg.micr0bu.data.mqtt.MqttRepository.activateDenm]).
|
||||
*
|
||||
* Uses the same [PhoneCamBuilder] as the real transmit path, so what goes on air here is a
|
||||
* properly populated CAM — real position, speed, heading, yaw rate and along-track acceleration —
|
||||
* not a synthetic frame. Previously this beaconed a hardcoded bench coordinate with speed and
|
||||
* heading pinned to zero, which exercised the link but told you nothing about whether the sensor
|
||||
* pipeline produced sane CAM content.
|
||||
*
|
||||
* Requires a GNSS fix: with no fix there is no position to put in a CAM, so the loop sends
|
||||
* nothing and reports that via [hasFix] rather than transmitting a placeholder.
|
||||
*
|
||||
* Entirely user-triggered (Start/Stop in the V2X Monitor screen) — never started automatically,
|
||||
* and does not interact with [CamTransmitLoop]'s recording-gated loop. Both could in theory run at
|
||||
* once (nothing prevents it); they use distinct station IDs so the two streams stay separable.
|
||||
*/
|
||||
@Singleton
|
||||
class CamPinger @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val usbSerialTransport: UsbSerialTransport,
|
||||
private val codec: RealAsn1UperCodec,
|
||||
) {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private val sensorRepository = SensorRepository(context)
|
||||
private var job: Job? = null
|
||||
|
||||
@Volatile private var latestGnss: GnssReading? = null
|
||||
@Volatile private var latestGyroZ: Float? = null
|
||||
@Volatile private var previousGnss: GnssReading? = null
|
||||
|
||||
private val _isActive = MutableStateFlow(false)
|
||||
val isActive: StateFlow<Boolean> = _isActive.asStateFlow()
|
||||
|
||||
private val _sentCount = MutableStateFlow(0)
|
||||
/** Number of CAM pings sent since [start] was last called. Reset to 0 on each [start]. */
|
||||
val sentCount: StateFlow<Int> = _sentCount.asStateFlow()
|
||||
|
||||
private val _hasFix = MutableStateFlow(false)
|
||||
/** False while the pinger is running but has no GNSS fix yet — nothing is being transmitted. */
|
||||
val hasFix: StateFlow<Boolean> = _hasFix.asStateFlow()
|
||||
|
||||
fun start() {
|
||||
if (job?.isActive == true) return
|
||||
_sentCount.value = 0
|
||||
_hasFix.value = false
|
||||
previousGnss = null
|
||||
latestGnss = null
|
||||
_isActive.value = true
|
||||
job = scope.launch { runPingLoop() }
|
||||
}
|
||||
|
||||
private suspend fun runPingLoop() = coroutineScope {
|
||||
launch { sensorRepository.gnssFlow().collect { latestGnss = it } }
|
||||
launch { sensorRepository.gyroscopeFlow().collect { latestGyroZ = it.z } }
|
||||
|
||||
while (true) {
|
||||
val gnss = latestGnss
|
||||
_hasFix.value = gnss != null
|
||||
if (gnss != null) {
|
||||
val cam = PhoneCamBuilder.build(
|
||||
gnss = gnss,
|
||||
gyroZRadPerSec = latestGyroZ,
|
||||
stationId = PING_STATION_ID,
|
||||
longitudinalAccelMps2 = longitudinalAccel(gnss),
|
||||
)
|
||||
val bytes = codec.encodeCam(cam)
|
||||
if (usbSerialTransport.sendCamTx(bytes)) {
|
||||
_sentCount.update { it + 1 }
|
||||
}
|
||||
}
|
||||
delay(PING_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Along-track acceleration from successive GNSS speed samples — same derivation and same
|
||||
* reasoning as [CamTransmitLoop.longitudinalAccel] (the accelerometer reads in the device
|
||||
* frame with gravity mixed in, so it can't give signed along-track acceleration without a
|
||||
* full orientation estimate). Null outside a usable sample gap, which encodes as the ASN.1
|
||||
* `unavailable` sentinel.
|
||||
*/
|
||||
private fun longitudinalAccel(gnss: GnssReading): Double? {
|
||||
val prev = previousGnss
|
||||
previousGnss = gnss
|
||||
if (prev == null) return null
|
||||
|
||||
val dtSec = (gnss.timestamp - prev.timestamp) / 1000.0
|
||||
if (dtSec < MIN_ACCEL_DT_SEC || dtSec > MAX_ACCEL_DT_SEC) return null
|
||||
|
||||
return (gnss.speedMs.toDouble() - prev.speedMs.toDouble()) / dtSec
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
job?.cancel()
|
||||
job = null
|
||||
_isActive.value = false
|
||||
_hasFix.value = false
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PING_INTERVAL_MS = 1_000L
|
||||
|
||||
private const val MIN_ACCEL_DT_SEC = 0.2
|
||||
private const val MAX_ACCEL_DT_SEC = 3.0
|
||||
|
||||
/**
|
||||
* Recognizable station id, deliberately distinct from the persisted real one
|
||||
* [CamTransmitLoop] uses, so manual bench pings stay identifiable in captures and can't be
|
||||
* confused with the recording-driven stream if both happen to run at once.
|
||||
*/
|
||||
private const val PING_STATION_ID = 999_999L
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,15 @@ class CamTransmitLoop @Inject constructor(
|
||||
@Volatile private var latestGyroZ: Float? = null
|
||||
@Volatile private var elevatedUntilMs: Long = 0L
|
||||
|
||||
/** Own station id for the ESP32-C5 path — see [PhoneCamBuilder]'s KDoc on why this is a placeholder. */
|
||||
/** Previous GNSS fix, kept only to derive along-track acceleration — see [longitudinalAccel]. */
|
||||
@Volatile private var previousGnss: GnssReading? = null
|
||||
|
||||
/**
|
||||
* Own station id for the ESP32-C5 path, loaded once per [start] from
|
||||
* [ObuHardwarePreferences.getOrCreateOwnStationId]. 0 means "not loaded yet" — the loop waits
|
||||
* for the real value rather than beaconing as station 0, which would be indistinguishable
|
||||
* from every other MicrOBU to any receiver.
|
||||
*/
|
||||
@Volatile var stationId: Long = 0L
|
||||
|
||||
/**
|
||||
@@ -80,7 +88,9 @@ class CamTransmitLoop @Inject constructor(
|
||||
fun start() {
|
||||
if (job?.isActive == true) return
|
||||
elevatedUntilMs = 0L
|
||||
previousGnss = null
|
||||
job = scope.launch {
|
||||
stationId = obuHardwarePrefs.getOrCreateOwnStationId()
|
||||
obuHardwarePrefs.obuHardwareFlow.collectLatest { hardware ->
|
||||
if (hardware != ObuHardware.ESP32_C5) return@collectLatest
|
||||
runTransmitLoop()
|
||||
@@ -101,7 +111,7 @@ class CamTransmitLoop @Inject constructor(
|
||||
while (true) {
|
||||
val gnss = latestGnss
|
||||
if (gnss != null) {
|
||||
val cam = PhoneCamBuilder.build(gnss, latestGyroZ, stationId)
|
||||
val cam = PhoneCamBuilder.build(gnss, latestGyroZ, stationId, longitudinalAccel(gnss))
|
||||
val bytes = codec.encodeCam(cam)
|
||||
usbSerialTransport.sendCamTx(bytes)
|
||||
}
|
||||
@@ -109,6 +119,32 @@ class CamTransmitLoop @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Along-track acceleration in m/s², from the change in GNSS speed since the previous fix.
|
||||
*
|
||||
* Deliberately not from the accelerometer: CAM's `longitudinalAcceleration` is acceleration
|
||||
* along the direction of travel, while the raw accelerometer reads in the device frame with
|
||||
* gravity included — extracting the along-track component from it needs a full orientation
|
||||
* estimate, which this path doesn't have (the detection engine sidesteps the same problem by
|
||||
* working on orientation-independent magnitudes, which is not what CAM wants here).
|
||||
*
|
||||
* Returns null — encoded as ASN.1 `unavailable` — when there's no usable previous fix, when
|
||||
* the gap is too short to divide by safely, or when it's long enough that the two samples
|
||||
* aren't really consecutive. Better an honest "unavailable" than a fabricated number a
|
||||
* receiving vehicle might brake on.
|
||||
*/
|
||||
private fun longitudinalAccel(gnss: GnssReading): Double? {
|
||||
val prev = previousGnss
|
||||
previousGnss = gnss
|
||||
if (prev == null) return null
|
||||
|
||||
val dtSec = (gnss.timestamp - prev.timestamp) / 1000.0
|
||||
if (dtSec < MIN_ACCEL_DT_SEC || dtSec > MAX_ACCEL_DT_SEC) return null
|
||||
|
||||
val dv = gnss.speedMs.toDouble() - prev.speedMs.toDouble()
|
||||
return dv / dtSec
|
||||
}
|
||||
|
||||
private fun currentRateHz(gnss: GnssReading?): Double {
|
||||
val now = System.currentTimeMillis()
|
||||
val inGeofence = gnss != null && config.geofences.any { fence ->
|
||||
@@ -120,5 +156,11 @@ class CamTransmitLoop @Inject constructor(
|
||||
|
||||
companion object {
|
||||
private const val ELEVATED_HOLD_MS = 5_000L
|
||||
|
||||
/** Below this gap, GNSS speed noise divided by a tiny dt produces absurd accelerations. */
|
||||
private const val MIN_ACCEL_DT_SEC = 0.2
|
||||
|
||||
/** Above this gap the two fixes aren't consecutive enough to call the result acceleration. */
|
||||
private const val MAX_ACCEL_DT_SEC = 3.0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,13 @@ class TripRecordingService : Service() {
|
||||
|
||||
companion object {
|
||||
const val ACTION_START = "com.hawhamburg.micr0bu.TRIP_START"
|
||||
|
||||
/**
|
||||
* Optional string extra on [ACTION_START]: the ID of the CSV recording session started
|
||||
* alongside this trip, so deleting the trip can also delete its CSV. Absent if the trip
|
||||
* was started without CSV recording.
|
||||
*/
|
||||
const val EXTRA_SESSION_ID = "com.hawhamburg.micr0bu.EXTRA_SESSION_ID"
|
||||
const val ACTION_STOP = "com.hawhamburg.micr0bu.TRIP_STOP"
|
||||
|
||||
private const val NOTIFICATION_ID = 9001
|
||||
@@ -215,13 +222,13 @@ class TripRecordingService : Service() {
|
||||
super.onCreate()
|
||||
sensorManager = getSystemService(SENSOR_SERVICE) as SensorManager
|
||||
fusedLocation = LocationServices.getFusedLocationProviderClient(this)
|
||||
repository = TripRepository(AppDatabase.getInstance(applicationContext))
|
||||
repository = TripRepository(AppDatabase.getInstance(applicationContext), applicationContext)
|
||||
createNotificationChannel()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
when (intent?.action) {
|
||||
ACTION_START -> startTrip()
|
||||
ACTION_START -> startTrip(intent.getStringExtra(EXTRA_SESSION_ID))
|
||||
ACTION_STOP -> stopTrip()
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
@@ -236,7 +243,7 @@ class TripRecordingService : Service() {
|
||||
|
||||
// ── Trip control ─────────────────────────────────────────────────────────
|
||||
|
||||
private fun startTrip() {
|
||||
private fun startTrip(sessionId: String?) {
|
||||
// Keep the CPU awake for the duration of the trip so sensor and GPS callbacks
|
||||
// are delivered reliably when the screen is off. Released in stopTrip().
|
||||
val pm = getSystemService(POWER_SERVICE) as PowerManager
|
||||
@@ -260,7 +267,7 @@ class TripRecordingService : Service() {
|
||||
|
||||
// Insert placeholder trip row and get the auto-generated ID
|
||||
serviceScope.launch {
|
||||
currentTripId = repository.insertTrip(startTime = startTime)
|
||||
currentTripId = repository.insertTrip(startTime = startTime, sessionId = sessionId)
|
||||
TripServiceBus.update {
|
||||
copy(
|
||||
isRecording = true,
|
||||
|
||||
@@ -19,6 +19,8 @@ import androidx.compose.material.icons.filled.GpsFixed
|
||||
import androidx.compose.material.icons.filled.GpsOff
|
||||
import androidx.compose.material.icons.filled.Sensors
|
||||
import androidx.compose.material.icons.filled.SensorsOff
|
||||
import androidx.compose.material.icons.filled.Usb
|
||||
import androidx.compose.material.icons.filled.UsbOff
|
||||
import androidx.compose.material.icons.filled.Wifi
|
||||
import androidx.compose.material.icons.filled.WifiOff
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -37,6 +39,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
|
||||
import com.hawhamburg.micr0bu.data.transport.UsbSerialState
|
||||
import com.hawhamburg.micr0bu.viewmodel.SensorUiState
|
||||
|
||||
private val GreenActive = Color(0xFF4CAF50)
|
||||
@@ -46,7 +49,12 @@ private val GrayInactive = Color(0xFF555E6A)
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun StatusTopBar(state: SensorUiState, mqttConnectionState: MqttConnectionState) {
|
||||
fun StatusTopBar(
|
||||
state: SensorUiState,
|
||||
mqttConnectionState: MqttConnectionState,
|
||||
isEsp32: Boolean = false,
|
||||
usbSerialState: UsbSerialState = UsbSerialState.DISCONNECTED,
|
||||
) {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
@@ -85,7 +93,7 @@ fun StatusTopBar(state: SensorUiState, mqttConnectionState: MqttConnectionState)
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
|
||||
ObuStatusIcon(mqttConnectionState)
|
||||
ObuStatusIcon(mqttConnectionState, isEsp32, usbSerialState)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
@@ -94,13 +102,27 @@ fun StatusTopBar(state: SensorUiState, mqttConnectionState: MqttConnectionState)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* OBU link indicator. Which transport it reflects depends on the selected hardware: the CiT One
|
||||
* reaches the phone over MQTT (Wi-Fi / USB-C tethering), the ESP32-C5 over a USB-serial link with
|
||||
* no broker at all - so on that path [mqttConnectionState] is permanently DISCONNECTED and would
|
||||
* report the OBU as offline while CAMs were streaming in. Uses a USB glyph there rather than the
|
||||
* Wi-Fi one, since that is literally what the connection is.
|
||||
*/
|
||||
@Composable
|
||||
private fun ObuStatusIcon(state: MqttConnectionState) {
|
||||
val icon = when (state) {
|
||||
MqttConnectionState.CONNECTED,
|
||||
MqttConnectionState.CONNECTING -> Icons.Default.Wifi
|
||||
MqttConnectionState.ERROR,
|
||||
MqttConnectionState.DISCONNECTED -> Icons.Default.WifiOff
|
||||
private fun ObuStatusIcon(
|
||||
mqttState: MqttConnectionState,
|
||||
isEsp32: Boolean,
|
||||
usbSerialState: UsbSerialState,
|
||||
) {
|
||||
val state = if (isEsp32) usbSerialState.asConnectionState() else mqttState
|
||||
val icon = when {
|
||||
isEsp32 && state == MqttConnectionState.CONNECTED -> Icons.Default.Usb
|
||||
isEsp32 && state == MqttConnectionState.CONNECTING -> Icons.Default.Usb
|
||||
isEsp32 -> Icons.Default.UsbOff
|
||||
state == MqttConnectionState.CONNECTED ||
|
||||
state == MqttConnectionState.CONNECTING -> Icons.Default.Wifi
|
||||
else -> Icons.Default.WifiOff
|
||||
}
|
||||
val tint = when (state) {
|
||||
MqttConnectionState.CONNECTED -> GreenActive
|
||||
@@ -167,3 +189,15 @@ private fun RecordingPulse() {
|
||||
.background(Color(0xFFFF5252).copy(alpha = alpha)),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps the ESP32-C5 serial link's lifecycle onto the MQTT connection vocabulary this bar's colour
|
||||
* and pulse logic already speaks, so one indicator serves both transports.
|
||||
*/
|
||||
private fun UsbSerialState.asConnectionState(): MqttConnectionState = when (this) {
|
||||
UsbSerialState.CONNECTED -> MqttConnectionState.CONNECTED
|
||||
UsbSerialState.DEVICE_ATTACHED,
|
||||
UsbSerialState.PERMISSION_REQUESTED -> MqttConnectionState.CONNECTING
|
||||
UsbSerialState.ERROR -> MqttConnectionState.ERROR
|
||||
UsbSerialState.DISCONNECTED -> MqttConnectionState.DISCONNECTED
|
||||
}
|
||||
|
||||
@@ -52,9 +52,11 @@ sealed class Screen(val route: String, val labelRes: Int) {
|
||||
data object SettingsAbout : Screen("settings/about", R.string.settings_about)
|
||||
}
|
||||
|
||||
// Sensors is deliberately absent: a live phone-sensor feed isn't something a rider needs, and the
|
||||
// Dashboard already reports whether GNSS/IMU are active. The screen and its route still exist and
|
||||
// are reachable from Settings > Developer for bench diagnosis - see DeveloperSettingsScreen.
|
||||
private val bottomNavItems = listOf(
|
||||
Screen.Dashboard,
|
||||
Screen.Sensors,
|
||||
Screen.Record,
|
||||
Screen.TripHistory,
|
||||
Screen.MqttViewer,
|
||||
|
||||
@@ -71,6 +71,7 @@ import com.hawhamburg.micr0bu.data.transport.UsbSerialState
|
||||
import com.hawhamburg.micr0bu.domain.cam.CamParser
|
||||
import com.hawhamburg.micr0bu.domain.denm.DenmUseCase
|
||||
import com.hawhamburg.micr0bu.domain.usecase.AlertLevel
|
||||
import com.hawhamburg.micr0bu.domain.usecase.GeoMath
|
||||
import com.hawhamburg.micr0bu.domain.usecase.UseCaseAlert
|
||||
import com.hawhamburg.micr0bu.domain.usecase.UseCaseType
|
||||
import com.hawhamburg.micr0bu.viewmodel.MqttViewModel
|
||||
@@ -123,8 +124,10 @@ fun MqttTopicViewerScreen(
|
||||
val usbSerialState by viewModel.usbSerialState.collectAsState()
|
||||
val camPingerActive by viewModel.camPingerActive.collectAsState()
|
||||
val camPingerSentCount by viewModel.camPingerSentCount.collectAsState()
|
||||
val camPingerHasFix by viewModel.camPingerHasFix.collectAsState()
|
||||
val camSendFailures by viewModel.camSendFailures.collectAsState()
|
||||
val espLinkStatus by viewModel.espLinkStatus.collectAsState()
|
||||
val denmEvents by viewModel.denmEvents.collectAsState()
|
||||
|
||||
// Sort: sys/ topics first (heartbeat/health), then alphabetical
|
||||
val sortedTopics = topicMessages.keys.sortedWith(
|
||||
@@ -135,8 +138,15 @@ fun MqttTopicViewerScreen(
|
||||
viewModel.selectTopic(null)
|
||||
}
|
||||
|
||||
val isConnected = connectionState == MqttConnectionState.CONNECTED
|
||||
val isConnecting = connectionState == MqttConnectionState.CONNECTING
|
||||
val isEsp32 = obuHardware == ObuHardware.ESP32_C5
|
||||
|
||||
// On the ESP32-C5 path there is no MQTT broker, so `connectionState` is permanently
|
||||
// DISCONNECTED and using it here made the screen report "offline" while CAMs streamed in over
|
||||
// serial. Everything on this screen that means "is the OBU link up?" follows the serial link
|
||||
// instead when that hardware is selected.
|
||||
val effectiveState = if (isEsp32) usbSerialState.asConnectionState() else connectionState
|
||||
val isConnected = effectiveState == MqttConnectionState.CONNECTED
|
||||
val isConnecting = effectiveState == MqttConnectionState.CONNECTING
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
|
||||
@@ -172,10 +182,18 @@ fun MqttTopicViewerScreen(
|
||||
Spacer(Modifier.weight(1f))
|
||||
}
|
||||
|
||||
ConnectionChip(connectionState)
|
||||
ConnectionChip(effectiveState)
|
||||
Spacer(Modifier.width(2.dp))
|
||||
IconButton(
|
||||
onClick = { if (isConnected || isConnecting) viewModel.disconnect() else viewModel.connect() },
|
||||
onClick = {
|
||||
// Route to whichever transport this hardware actually uses.
|
||||
if (isEsp32) {
|
||||
if (isConnected || isConnecting) viewModel.disconnectUsbSerial()
|
||||
else viewModel.connectUsbSerial()
|
||||
} else {
|
||||
if (isConnected || isConnecting) viewModel.disconnect() else viewModel.connect()
|
||||
}
|
||||
},
|
||||
colors = IconButtonDefaults.iconButtonColors(
|
||||
contentColor = if (isConnected) ErrorRed else ConnectedGreen,
|
||||
),
|
||||
@@ -203,10 +221,13 @@ fun MqttTopicViewerScreen(
|
||||
activeDenmUseCase = activeDenmUseCase,
|
||||
useCaseAlerts = useCaseAlerts,
|
||||
showDenmTrigger = obuHardware == ObuHardware.CIT_ONE,
|
||||
showCamPinger = obuHardware == ObuHardware.ESP32_C5,
|
||||
showCamPinger = isEsp32,
|
||||
isEsp32 = isEsp32,
|
||||
denmEvents = denmEvents,
|
||||
usbSerialState = usbSerialState,
|
||||
camPingerActive = camPingerActive,
|
||||
camPingerSentCount = camPingerSentCount,
|
||||
camPingerHasFix = camPingerHasFix,
|
||||
camSendFailures = camSendFailures,
|
||||
espLinkStatus = espLinkStatus,
|
||||
ownCamPosition = ownCamPosition,
|
||||
@@ -240,9 +261,12 @@ private fun TopicListPane(
|
||||
useCaseAlerts: List<UseCaseAlert>,
|
||||
showDenmTrigger: Boolean = true,
|
||||
showCamPinger: Boolean = false,
|
||||
isEsp32: Boolean = false,
|
||||
denmEvents: List<com.hawhamburg.micr0bu.domain.denm.DenmEvent> = emptyList(),
|
||||
usbSerialState: UsbSerialState = UsbSerialState.DISCONNECTED,
|
||||
camPingerActive: Boolean = false,
|
||||
camPingerSentCount: Int = 0,
|
||||
camPingerHasFix: Boolean = false,
|
||||
camSendFailures: Int = 0,
|
||||
espLinkStatus: EspLinkStatus? = null,
|
||||
ownCamPosition: com.hawhamburg.micr0bu.domain.cam.Cam? = null,
|
||||
@@ -285,6 +309,7 @@ private fun TopicListPane(
|
||||
usbConnected = usbSerialState == UsbSerialState.CONNECTED,
|
||||
pingerActive = camPingerActive,
|
||||
sentCount = camPingerSentCount,
|
||||
hasFix = camPingerHasFix,
|
||||
sendFailures = camSendFailures,
|
||||
linkStatus = espLinkStatus,
|
||||
onStart = onStartCamPinger,
|
||||
@@ -316,9 +341,20 @@ private fun TopicListPane(
|
||||
) { Text(stringResource(R.string.mqtt_view_map)) }
|
||||
}
|
||||
|
||||
// ── Topic rows / live map ─────────────────────────────────────────────
|
||||
// ── Topic rows / received CAMs / live map ─────────────────────────────
|
||||
if (viewMode == TopicViewMode.MAP) {
|
||||
V2xLiveMapView(
|
||||
own = ownCamPosition,
|
||||
remotes = remoteCamPositions,
|
||||
alerts = useCaseAlerts,
|
||||
denms = denmEvents,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
} else if (isEsp32) {
|
||||
// The MQTT topic list is meaningless on this path - there is no broker, so `topics`
|
||||
// is permanently empty and the list would read as "nothing is happening" even while
|
||||
// CAMs stream in over the serial link. Show the decoded traffic instead.
|
||||
ReceivedCamPane(
|
||||
own = ownCamPosition,
|
||||
remotes = remoteCamPositions,
|
||||
alerts = useCaseAlerts,
|
||||
@@ -358,6 +394,172 @@ private fun TopicListPane(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Received-CAM list for the ESP32-C5 path — one row per remote station, showing that station's
|
||||
* latest decoded CAM.
|
||||
*
|
||||
* **Deliberately one row per station, not one per message.** CAMs arrive at 1-10 Hz *per
|
||||
* station*; rendering a scrolling log of individual messages would repaint constantly, bury the
|
||||
* useful information, and tell you nothing a per-station summary doesn't. The underlying
|
||||
* [com.hawhamburg.micr0bu.data.cam.CamUseCaseRepository] already keeps only the latest CAM per
|
||||
* station, so this pane is bounded by the number of road users nearby, not by traffic rate or
|
||||
* session length.
|
||||
*
|
||||
* Sorted nearest-first: on a bike, the closest station is the one that matters. Rows are tinted
|
||||
* by that station's most severe active alert, matching [UseCaseAlertPanel] and the map markers.
|
||||
*/
|
||||
@Composable
|
||||
private fun ReceivedCamPane(
|
||||
own: com.hawhamburg.micr0bu.domain.cam.Cam?,
|
||||
remotes: Map<Long, com.hawhamburg.micr0bu.domain.cam.Cam>,
|
||||
alerts: List<UseCaseAlert>,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (remotes.isEmpty()) {
|
||||
Box(modifier = modifier, contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
stringResource(R.string.v2x_cam_rx_none),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
stringResource(R.string.v2x_cam_rx_none_hint),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
|
||||
)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val alertByStation = remember(alerts) {
|
||||
alerts.groupBy { it.remoteStationId }
|
||||
.mapValues { (_, a) -> a.maxByOrNull { it.alertLevel.ordinal }?.alertLevel }
|
||||
}
|
||||
|
||||
// Distance is computed once per recomposition per station rather than inside each row, so
|
||||
// sorting and display agree and the haversine isn't run twice per station.
|
||||
val rows = remember(remotes, own) {
|
||||
remotes.values
|
||||
.map { cam ->
|
||||
val distance = own?.let {
|
||||
GeoMath.haversineMeters(it.latitude, it.longitude, cam.latitude, cam.longitude)
|
||||
}
|
||||
cam to distance
|
||||
}
|
||||
.sortedBy { (_, d) -> d ?: Double.MAX_VALUE }
|
||||
}
|
||||
|
||||
Column(modifier = modifier) {
|
||||
Text(
|
||||
text = stringResource(R.string.v2x_cam_rx_count, rows.size),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f))
|
||||
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(rows, key = { (cam, _) -> cam.stationId }) { (cam, distance) ->
|
||||
ReceivedCamRow(cam, distance, alertByStation[cam.stationId])
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReceivedCamRow(
|
||||
cam: com.hawhamburg.micr0bu.domain.cam.Cam,
|
||||
distanceMeters: Double?,
|
||||
alertLevel: AlertLevel?,
|
||||
) {
|
||||
val accent = when (alertLevel) {
|
||||
AlertLevel.WARNING -> WarningRed
|
||||
AlertLevel.AWARENESS -> AwarenessAmber
|
||||
AlertLevel.INFO -> InfoBlue
|
||||
null -> MaterialTheme.colorScheme.primary
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Circle,
|
||||
contentDescription = null,
|
||||
tint = accent,
|
||||
modifier = Modifier.size(8.dp),
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = stringResource(
|
||||
R.string.v2x_cam_rx_station,
|
||||
cam.stationId,
|
||||
stationTypeLabel(cam.stationType),
|
||||
),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = accent,
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
text = stringResource(
|
||||
R.string.v2x_cam_rx_kinematics,
|
||||
cam.speedMps * 3.6,
|
||||
cam.headingDeg,
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
}
|
||||
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
Text(
|
||||
text = distanceMeters
|
||||
?.let { stringResource(R.string.v2x_cam_rx_distance, it) }
|
||||
?: stringResource(R.string.v2x_cam_rx_distance_unknown),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
cam.rssiDbm?.let { rssi ->
|
||||
Text(
|
||||
text = stringResource(R.string.v2x_cam_rx_rssi, rssi),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = timeFormat.format(Date(cam.timestamp)),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Human label for the ETSI stationType values this project is likely to actually see. */
|
||||
@Composable
|
||||
private fun stationTypeLabel(stationType: Int): String = when (stationType) {
|
||||
1 -> stringResource(R.string.station_type_pedestrian)
|
||||
2 -> stringResource(R.string.station_type_cyclist)
|
||||
5 -> stringResource(R.string.station_type_car)
|
||||
6 -> stringResource(R.string.station_type_bus)
|
||||
8 -> stringResource(R.string.station_type_truck)
|
||||
15 -> stringResource(R.string.station_type_rsu)
|
||||
else -> stringResource(R.string.station_type_other, stationType)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TopicRow(
|
||||
topic: String,
|
||||
@@ -698,6 +900,7 @@ private fun CamPingerCard(
|
||||
usbConnected: Boolean,
|
||||
pingerActive: Boolean,
|
||||
sentCount: Int,
|
||||
hasFix: Boolean,
|
||||
sendFailures: Int,
|
||||
linkStatus: EspLinkStatus?,
|
||||
onStart: () -> Unit,
|
||||
@@ -752,6 +955,18 @@ private fun CamPingerCard(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
// No GNSS fix means the loop is running but has no position to build a CAM from, so
|
||||
// nothing is going out - without this the card would just sit at "Sent: 0".
|
||||
if (pingerActive && !hasFix) {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
stringResource(R.string.mqtt_cam_pinger_no_fix),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Link diagnostics ──────────────────────────────────────────────
|
||||
// "Sent: 240" is meaningless on its own if all 240 writes failed, or if the ESP32
|
||||
// accepted them and the radio rejected every one. These two lines are the difference
|
||||
@@ -1017,3 +1232,16 @@ private fun prettyPrintJson(raw: String): String {
|
||||
raw
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps the ESP32-C5 serial link's lifecycle onto the MQTT connection vocabulary the shared
|
||||
* connection UI on this screen already speaks, so one indicator can serve both transports rather
|
||||
* than duplicating the chip and its colours per hardware type.
|
||||
*/
|
||||
private fun UsbSerialState.asConnectionState(): MqttConnectionState = when (this) {
|
||||
UsbSerialState.CONNECTED -> MqttConnectionState.CONNECTED
|
||||
UsbSerialState.DEVICE_ATTACHED,
|
||||
UsbSerialState.PERMISSION_REQUESTED -> MqttConnectionState.CONNECTING
|
||||
UsbSerialState.ERROR -> MqttConnectionState.ERROR
|
||||
UsbSerialState.DISCONNECTED -> MqttConnectionState.DISCONNECTED
|
||||
}
|
||||
|
||||
@@ -45,6 +45,13 @@ import com.hawhamburg.micr0bu.viewmodel.SensorUiState
|
||||
fun RecordingScreen(
|
||||
state: SensorUiState,
|
||||
mqttConnectionState: MqttConnectionState,
|
||||
/**
|
||||
* Whether the OBU link is up, whichever transport this hardware uses - MQTT for the CiT One,
|
||||
* the USB-serial link for the ESP32-C5. Passed in rather than derived from
|
||||
* [mqttConnectionState] because that is permanently DISCONNECTED on the ESP32 path, which
|
||||
* showed the OBU stream as offline during a recording that was actively beaconing CAMs.
|
||||
*/
|
||||
obuConnected: Boolean = mqttConnectionState == MqttConnectionState.CONNECTED,
|
||||
tripServiceState: TripServiceBus.State,
|
||||
showBatteryOptPrompt: Boolean,
|
||||
onToggleRecording: () -> Unit,
|
||||
@@ -213,7 +220,7 @@ fun RecordingScreen(
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
|
||||
StreamRow(stringResource(R.string.stream_baro), state.barometerEnabled && state.pressureHpa != null)
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
|
||||
StreamRow(stringResource(R.string.stream_obu), mqttConnectionState == MqttConnectionState.CONNECTED)
|
||||
StreamRow(stringResource(R.string.stream_obu), obuConnected)
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
|
||||
StreamRow(stringResource(R.string.rec_stream_event_detection), tripServiceState.isRecording)
|
||||
}
|
||||
|
||||
@@ -47,7 +47,6 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import com.hawhamburg.micr0bu.R
|
||||
import com.hawhamburg.micr0bu.data.mqtt.MqttPrefs
|
||||
import com.hawhamburg.micr0bu.data.transport.EspRxMode
|
||||
import com.hawhamburg.micr0bu.data.transport.ObuHardware
|
||||
import com.hawhamburg.micr0bu.domain.usecase.UseCaseDetectionConfig
|
||||
import com.hawhamburg.micr0bu.domain.usecase.UseCaseType
|
||||
@@ -166,8 +165,6 @@ fun ConnectionSettingsScreen(
|
||||
onMqttPrefsChange: (MqttPrefs) -> Unit,
|
||||
obuHardware: ObuHardware = ObuHardware.CIT_ONE,
|
||||
onObuHardwareChange: (ObuHardware) -> Unit = {},
|
||||
espRxMode: EspRxMode = EspRxMode.SEND_AND_RECEIVE,
|
||||
onEspRxModeChange: (EspRxMode) -> Unit = {},
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
SubScreen(stringResource(R.string.settings_connection), onBack) {
|
||||
@@ -212,47 +209,6 @@ fun ConnectionSettingsScreen(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
|
||||
// CAM reception mode — see EspRxMode's KDoc for the caveat that this is purely
|
||||
// an app-side filter, not a physical radio-receiver toggle (the ESP32 must keep
|
||||
// its receiver on for TX to keep working at all).
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
stringResource(R.string.settings_esp32_rx_mode),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
val isSendOnly = espRxMode == EspRxMode.SEND_ONLY
|
||||
OutlinedButton(
|
||||
onClick = { onEspRxModeChange(EspRxMode.SEND_ONLY) },
|
||||
modifier = Modifier.weight(1f),
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
containerColor = if (isSendOnly) MaterialTheme.colorScheme.primaryContainer else Color.Transparent,
|
||||
contentColor = if (isSendOnly) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
) { Text(stringResource(R.string.settings_esp32_rx_mode_send_only), fontWeight = if (isSendOnly) FontWeight.Bold else FontWeight.Normal) }
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { onEspRxModeChange(EspRxMode.SEND_AND_RECEIVE) },
|
||||
modifier = Modifier.weight(1f),
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
containerColor = if (!isSendOnly) MaterialTheme.colorScheme.primaryContainer else Color.Transparent,
|
||||
contentColor = if (!isSendOnly) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
) { Text(stringResource(R.string.settings_esp32_rx_mode_send_and_receive), fontWeight = if (!isSendOnly) FontWeight.Bold else FontWeight.Normal) }
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.settings_esp32_rx_mode_desc),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f),
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -441,11 +397,16 @@ fun UseCaseAlertsSettingsScreen(
|
||||
fun DeveloperSettingsScreen(
|
||||
state: SensorUiState,
|
||||
onDeveloperMode: (Boolean) -> Unit,
|
||||
onOpenSensorMonitor: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
SubScreen(stringResource(R.string.settings_developer), onBack) {
|
||||
SectionCard {
|
||||
SettingToggleRow(stringResource(R.string.settings_dev_mode), state.developerMode, onDeveloperMode)
|
||||
Divider()
|
||||
// Sensor Monitor lives here rather than in the bottom nav: a live phone-sensor feed is
|
||||
// a bench-diagnosis tool, not something a rider needs mid-ride.
|
||||
MenuRow(stringResource(R.string.sensor_monitor_title), onOpenSensorMonitor)
|
||||
if (state.developerMode) {
|
||||
Divider()
|
||||
DisabledRow(stringResource(R.string.settings_wifi), stringResource(R.string.settings_wifi_val))
|
||||
|
||||
@@ -21,6 +21,7 @@ import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
@@ -33,6 +34,7 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -51,6 +53,7 @@ fun TripHistoryScreen(
|
||||
onOpenTrip: (Long) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val trips by viewModel.trips.collectAsState(initial = emptyList())
|
||||
var pendingDelete by remember { mutableStateOf<RecordedTripEntity?>(null) }
|
||||
|
||||
@@ -99,6 +102,7 @@ fun TripHistoryScreen(
|
||||
index = trips.size - index,
|
||||
onOpen = { onOpenTrip(trip.id) },
|
||||
onDelete = { pendingDelete = trip },
|
||||
onExport = { viewModel.exportTrip(context, trip.id) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -112,6 +116,7 @@ private fun TripCard(
|
||||
index: Int,
|
||||
onOpen: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
onExport: () -> Unit,
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
@@ -146,6 +151,13 @@ private fun TripCard(
|
||||
IconButton(onClick = onOpen) {
|
||||
Icon(Icons.Default.Map, contentDescription = stringResource(R.string.trip_review_title), tint = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
IconButton(onClick = onExport) {
|
||||
Icon(
|
||||
Icons.Default.Share,
|
||||
contentDescription = stringResource(R.string.trip_export_cd),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onDelete) {
|
||||
Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.log_delete_cd), tint = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
|
||||
@@ -23,11 +23,13 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import com.hawhamburg.micr0bu.R
|
||||
import com.hawhamburg.micr0bu.domain.cam.Cam
|
||||
import com.hawhamburg.micr0bu.domain.denm.DenmEvent
|
||||
import com.hawhamburg.micr0bu.domain.usecase.AlertLevel
|
||||
import com.hawhamburg.micr0bu.domain.usecase.UseCaseAlert
|
||||
import org.osmdroid.config.Configuration
|
||||
@@ -53,6 +55,7 @@ fun V2xLiveMapView(
|
||||
own: Cam?,
|
||||
remotes: Map<Long, Cam>,
|
||||
alerts: List<UseCaseAlert>,
|
||||
denms: List<DenmEvent> = emptyList(),
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
@@ -109,13 +112,14 @@ fun V2xLiveMapView(
|
||||
update = { mv ->
|
||||
mv.overlays.clear()
|
||||
|
||||
// Own marker — distinct from remotes via a dedicated title prefix; osmdroid
|
||||
// doesn't tint default pins per-instance without a custom drawable, so color
|
||||
// differentiation for now relies on the title label shown on tap.
|
||||
// Own position: a centred "you are here" dot, not a pin. Own position is a fact
|
||||
// about the viewer rather than one of the tracked objects, and when both used
|
||||
// osmdroid's identical default pin the two were indistinguishable at a glance.
|
||||
mv.overlays.add(
|
||||
Marker(mv).apply {
|
||||
position = ownGeoPoint
|
||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
|
||||
icon = ContextCompat.getDrawable(context, R.drawable.ic_map_own)
|
||||
title = context.getString(R.string.v2x_map_own_label)
|
||||
}
|
||||
)
|
||||
@@ -128,15 +132,44 @@ fun V2xLiveMapView(
|
||||
AlertLevel.INFO -> context.getString(R.string.v2x_map_remote_info, stationId)
|
||||
null -> context.getString(R.string.v2x_map_remote_plain, stationId)
|
||||
}
|
||||
// Teardrop pin anchored at its tip, tinted by severity. Now that these are
|
||||
// custom drawables, per-instance tinting is possible - severity no longer
|
||||
// depends on tapping the marker to read its label. mutate() is essential:
|
||||
// without it every marker shares one ConstantState and the last tint applied
|
||||
// would recolour all of them.
|
||||
val pin = ContextCompat.getDrawable(context, R.drawable.ic_map_remote_station)
|
||||
?.mutate()
|
||||
?.apply { setTint(level.toMarkerColor()) }
|
||||
mv.overlays.add(
|
||||
Marker(mv).apply {
|
||||
position = GeoPoint(cam.latitude, cam.longitude)
|
||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
||||
icon = pin
|
||||
title = label
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// DENM hazard pins, added last so they draw on top of vehicle markers - a hazard
|
||||
// hidden behind a CAM pin defeats the point of showing it.
|
||||
denms.forEach { denm ->
|
||||
mv.overlays.add(
|
||||
Marker(mv).apply {
|
||||
position = GeoPoint(denm.latitude, denm.longitude)
|
||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
||||
icon = ContextCompat.getDrawable(context, R.drawable.ic_denm_warning)
|
||||
title = denm.causeCode?.let {
|
||||
context.getString(
|
||||
R.string.v2x_map_denm_labeled,
|
||||
it,
|
||||
denm.subCauseCode ?: 0,
|
||||
denm.stationId,
|
||||
)
|
||||
} ?: context.getString(R.string.v2x_map_denm_plain, denm.stationId)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
mv.controller.animateTo(ownGeoPoint)
|
||||
mv.invalidate()
|
||||
},
|
||||
@@ -145,6 +178,18 @@ fun V2xLiveMapView(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marker tint per severity, matching [UseCaseAlertPanel]'s colours so the same station reads the
|
||||
* same way in both places. Stations with no active alert stay neutral grey-blue rather than green:
|
||||
* green would imply "checked and safe", when it only means "nothing detected yet".
|
||||
*/
|
||||
private fun AlertLevel?.toMarkerColor(): Int = when (this) {
|
||||
AlertLevel.WARNING -> 0xFFE53935.toInt()
|
||||
AlertLevel.AWARENESS -> 0xFFFFC107.toInt()
|
||||
AlertLevel.INFO -> 0xFF64B5F6.toInt()
|
||||
null -> 0xFF78909C.toInt()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NoFixPlaceholder(modifier: Modifier) {
|
||||
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
|
||||
@@ -10,12 +10,13 @@ import com.hawhamburg.micr0bu.data.mqtt.MqttPrefs
|
||||
import com.hawhamburg.micr0bu.data.mqtt.MqttRepository
|
||||
import com.hawhamburg.micr0bu.data.mqtt.ObuHardwarePreferences
|
||||
import com.hawhamburg.micr0bu.data.transport.EspLinkStatus
|
||||
import com.hawhamburg.micr0bu.data.transport.EspRxMode
|
||||
import com.hawhamburg.micr0bu.data.transport.ObuHardware
|
||||
import com.hawhamburg.micr0bu.data.transport.TransportType
|
||||
import com.hawhamburg.micr0bu.data.transport.UsbNetworkDetector
|
||||
import com.hawhamburg.micr0bu.data.transport.UsbSerialState
|
||||
import com.hawhamburg.micr0bu.data.transport.UsbSerialTransport
|
||||
import com.hawhamburg.micr0bu.domain.denm.DenmEvent
|
||||
import com.hawhamburg.micr0bu.domain.denm.DenmParser
|
||||
import com.hawhamburg.micr0bu.domain.denm.DenmUseCase
|
||||
import com.hawhamburg.micr0bu.domain.usecase.UseCaseAlert
|
||||
import com.hawhamburg.micr0bu.domain.usecase.UseCaseType
|
||||
@@ -65,19 +66,6 @@ class MqttViewModel @Inject constructor(
|
||||
viewModelScope.launch { obuHardwarePrefs.setObuHardware(hardware) }
|
||||
}
|
||||
|
||||
/**
|
||||
* ESP32-C5-only: whether received CAM traffic is processed or discarded — see [EspRxMode]'s
|
||||
* KDoc for the important caveat that this doesn't actually disable the ESP32's receiver
|
||||
* (it can't, without also breaking TX).
|
||||
*/
|
||||
val espRxMode: StateFlow<EspRxMode> = obuHardwarePrefs.espRxModeFlow.stateIn(
|
||||
viewModelScope, SharingStarted.Eagerly, EspRxMode.SEND_AND_RECEIVE,
|
||||
)
|
||||
|
||||
fun setEspRxMode(mode: EspRxMode) {
|
||||
viewModelScope.launch { obuHardwarePrefs.setEspRxMode(mode) }
|
||||
}
|
||||
|
||||
/** True when a 192.168.42.x USB-C tethering network is detected. */
|
||||
val usbConnected: StateFlow<Boolean> = usbDetector.usbNetwork
|
||||
.map { it != null }
|
||||
@@ -104,6 +92,9 @@ class MqttViewModel @Inject constructor(
|
||||
val camPingerActive: StateFlow<Boolean> = camPinger.isActive
|
||||
val camPingerSentCount: StateFlow<Int> = camPinger.sentCount
|
||||
|
||||
/** False while the pinger runs without a GNSS fix — it has no position to build a CAM from. */
|
||||
val camPingerHasFix: StateFlow<Boolean> = camPinger.hasFix
|
||||
|
||||
fun startCamPinger() = camPinger.start()
|
||||
fun stopCamPinger() = camPinger.stop()
|
||||
|
||||
@@ -138,6 +129,34 @@ class MqttViewModel @Inject constructor(
|
||||
.map { it != null && it != 2 }
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
|
||||
|
||||
// ── DENM reception (live map hazard pins) ─────────────────────────────────
|
||||
|
||||
/**
|
||||
* Hazards received from other stations, newest first, deduped by [DenmEvent.dedupKey] so a
|
||||
* repeating DENM about the same hazard stays one pin instead of stacking up.
|
||||
*
|
||||
* Derived from the raw `v2x-uca/output/json/denm` messages the repository already buffers,
|
||||
* rather than a second subscription — the repository caps each topic's history, so this is
|
||||
* bounded by construction.
|
||||
*
|
||||
* Always empty on the ESP32-C5 path: that firmware forwards BTP-B port 2001 (CAM) only and
|
||||
* drops DENM before it reaches the phone. See [DenmEvent]'s KDoc.
|
||||
*/
|
||||
val denmEvents: StateFlow<List<DenmEvent>> = repo.topicMessages
|
||||
.map { byTopic ->
|
||||
(byTopic[DENM_RX_TOPIC] ?: emptyList())
|
||||
.mapNotNull { DenmParser.parse(it.payload, it.timestamp) }
|
||||
.associateBy { it.dedupKey } // last write wins = most recent per hazard
|
||||
.values
|
||||
.sortedByDescending { it.timestamp }
|
||||
}
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
private companion object {
|
||||
/** Use Case API topic carrying received DENMs (CiT One path only). */
|
||||
const val DENM_RX_TOPIC = "v2x-uca/output/json/denm"
|
||||
}
|
||||
|
||||
// ── DENM transmission ─────────────────────────────────────────────────────
|
||||
|
||||
/** True while a DENM use case is actively broadcasting on the OBU. */
|
||||
|
||||
@@ -93,6 +93,13 @@ class SensorViewModel(application: Application) : AndroidViewModel(application)
|
||||
|
||||
private var recordingTimerJob: Job? = null
|
||||
private var recordingSessionId: String = ""
|
||||
|
||||
/**
|
||||
* ID of the CSV session currently being written, or null when not recording. Read by
|
||||
* MainActivity so the trip started at the same moment can store it - see
|
||||
* [com.hawhamburg.micr0bu.data.db.RecordedTripEntity.sessionId] for why that link matters.
|
||||
*/
|
||||
val activeSessionId: String? get() = recordingSessionId.takeIf { _state.value.isRecording }
|
||||
private var csvWriter: BufferedWriter? = null
|
||||
|
||||
private val isoFmt = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US)
|
||||
|
||||
@@ -7,6 +7,7 @@ import android.os.PowerManager
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.hawhamburg.micr0bu.data.TripRepository
|
||||
import com.hawhamburg.micr0bu.data.shareTripCsv
|
||||
import com.hawhamburg.micr0bu.data.db.AppDatabase
|
||||
import com.hawhamburg.micr0bu.data.db.DetectedEventEntity
|
||||
import com.hawhamburg.micr0bu.data.db.RecordedTripEntity
|
||||
@@ -27,7 +28,7 @@ import kotlinx.coroutines.launch
|
||||
*/
|
||||
class TripRecordingViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
private val repository = TripRepository(AppDatabase.getInstance(application))
|
||||
private val repository = TripRepository(AppDatabase.getInstance(application), application)
|
||||
|
||||
// ── Battery optimisation prompt ───────────────────────────────────────────
|
||||
// Shown once on the Recording screen to ask the user to exempt the app so
|
||||
@@ -85,13 +86,21 @@ class TripRecordingViewModel(application: Application) : AndroidViewModel(applic
|
||||
* Starts the foreground recording service if not already running,
|
||||
* or stops it if a trip is already active.
|
||||
*/
|
||||
fun toggleRecording() {
|
||||
if (serviceState.value.isRecording) stopRecording() else startRecording()
|
||||
fun toggleRecording(sessionId: String? = null) {
|
||||
if (serviceState.value.isRecording) stopRecording() else startRecording(sessionId)
|
||||
}
|
||||
|
||||
fun startRecording() {
|
||||
/**
|
||||
* @param sessionId ID of the CSV session recorded alongside this trip, so the trip row can
|
||||
* reference it and deleting the trip can delete the CSV too. Null if CSV recording isn't
|
||||
* running - the trip is still recorded, it just has no CSV to clean up later.
|
||||
*/
|
||||
fun startRecording(sessionId: String? = null) {
|
||||
val intent = Intent(getApplication(), TripRecordingService::class.java)
|
||||
.apply { action = TripRecordingService.ACTION_START }
|
||||
.apply {
|
||||
action = TripRecordingService.ACTION_START
|
||||
putExtra(TripRecordingService.EXTRA_SESSION_ID, sessionId)
|
||||
}
|
||||
getApplication<Application>().startForegroundService(intent)
|
||||
}
|
||||
|
||||
@@ -106,4 +115,26 @@ class TripRecordingViewModel(application: Application) : AndroidViewModel(applic
|
||||
fun deleteTrip(tripId: Long) {
|
||||
viewModelScope.launch { repository.deleteTrip(tripId) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds and shares the combined CSV for [tripId] - GPS track, detected events, V2X messages
|
||||
* and the raw sensor samples recorded alongside it. See [buildTripCsv] for the format and why
|
||||
* it's one file rather than several.
|
||||
*
|
||||
* [context] must be the Activity context (`LocalContext.current` from the composable), not
|
||||
* this ViewModel's Application context: the share sheet is launched with `startActivity`, and
|
||||
* doing that from a non-Activity context throws unless FLAG_ACTIVITY_NEW_TASK is set. Same
|
||||
* pattern as SessionLogScreen's share.
|
||||
*/
|
||||
fun exportTrip(context: Context, tripId: Long) {
|
||||
viewModelScope.launch {
|
||||
val trip = repository.getTrip(tripId) ?: return@launch
|
||||
shareTripCsv(
|
||||
context = context,
|
||||
trip = trip,
|
||||
events = repository.getEventsForTripOnce(tripId),
|
||||
v2xMessages = repository.getV2xMessagesForTripOnce(tripId),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<!--
|
||||
DENM map pin: the standard hazard warning triangle (! in a triangle).
|
||||
|
||||
Drawn rather than reused from Material's Icons.Filled.Warning because osmdroid Markers take a
|
||||
Drawable, not a Compose ImageVector, and a filled triangle with an opaque outline reads far
|
||||
better against arbitrary map tiles than a single-colour glyph does.
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="36dp"
|
||||
android:height="36dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
|
||||
<!-- White outline first, so the pin stays legible over dark map features. -->
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M12,1.2L0.6,21.4h22.8L12,1.2z" />
|
||||
|
||||
<!-- Amber triangle body. -->
|
||||
<path
|
||||
android:fillColor="#FFFFC107"
|
||||
android:pathData="M12,3.6L2.9,20.0h18.2L12,3.6z" />
|
||||
|
||||
<!-- Exclamation mark. -->
|
||||
<path
|
||||
android:fillColor="#FF1A1A1A"
|
||||
android:pathData="M11.1,8.4h1.8v5.4h-1.8z" />
|
||||
<path
|
||||
android:fillColor="#FF1A1A1A"
|
||||
android:pathData="M11.1,15.2h1.8v1.8h-1.8z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,24 @@
|
||||
<!--
|
||||
Live-map marker for the ego bike's own position.
|
||||
|
||||
Deliberately a "you are here" dot rather than a pin: own position is a fact about the viewer,
|
||||
not one of the objects being tracked, and giving it the same pin shape as remote stations made
|
||||
the two indistinguishable at a glance on a crowded map. White ring keeps it readable over dark
|
||||
map features.
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M12,12m-10,0a10,10 0 1,0 20,0a10,10 0 1,0 -20,0" />
|
||||
<path
|
||||
android:fillColor="#FF1E88E5"
|
||||
android:pathData="M12,12m-7.5,0a7.5,7.5 0 1,0 15,0a7.5,7.5 0 1,0 -15,0" />
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M12,12m-3,0a3,3 0 1,0 6,0a3,3 0 1,0 -6,0" />
|
||||
</vector>
|
||||
@@ -0,0 +1,30 @@
|
||||
<!--
|
||||
Live-map marker for a remote station heard over CAM.
|
||||
|
||||
A teardrop pin, anchored at its tip, so it clearly points at a location rather than covering it
|
||||
the way the ego dot does. The body is drawn in plain white and tinted at runtime by the
|
||||
station's most severe active alert level (see V2xLiveMapView) - which is why the fill here is
|
||||
white rather than a fixed colour: setTint on a coloured fill would muddy it.
|
||||
The dark outline is drawn first so the tinted body always sits inside a readable border.
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="30dp"
|
||||
android:height="30dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
|
||||
<!-- Outline -->
|
||||
<path
|
||||
android:fillColor="#FF1A1A1A"
|
||||
android:pathData="M12,0.8C7.4,0.8 3.7,4.5 3.7,9.1c0,6.2 8.3,14.1 8.3,14.1s8.3,-7.9 8.3,-14.1C20.3,4.5 16.6,0.8 12,0.8z" />
|
||||
|
||||
<!-- Body: tinted at runtime -->
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M12,2.4C8.3,2.4 5.3,5.4 5.3,9.1c0,5.2 6.7,11.7 6.7,11.7s6.7,-6.5 6.7,-11.7C18.7,5.4 15.7,2.4 12,2.4z" />
|
||||
|
||||
<!-- Inner dot, so the pin still reads as a marker once tinted -->
|
||||
<path
|
||||
android:fillColor="#FF1A1A1A"
|
||||
android:pathData="M12,9.1m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
|
||||
</vector>
|
||||
@@ -110,6 +110,7 @@
|
||||
<string name="log_session_number">Sitzung #%1$d</string>
|
||||
<string name="log_share_cd">CSV teilen</string>
|
||||
<string name="log_save_cd">Auf Gerät speichern</string>
|
||||
<string name="trip_export_cd">Fahrt als CSV exportieren</string>
|
||||
<string name="log_delete_cd">Sitzung löschen</string>
|
||||
<string name="log_empty_title">Noch keine Sitzungen</string>
|
||||
<string name="log_empty_desc">Gehe zu Aufnahme und starte eine Sitzung</string>
|
||||
@@ -162,7 +163,7 @@
|
||||
<string name="settings_wifi_val">Nur Entwicklermodus — noch nicht implementiert</string>
|
||||
<string name="settings_about">Über</string>
|
||||
<string name="settings_app_version">App-Version</string>
|
||||
<string name="settings_app_version_val">0.4.0 (Phase 02 — USB-C + DENM TX)</string>
|
||||
<string name="settings_app_version_val">0.5.0 (Phase 03 — ESP32-C5-Seriellverbindung + CAM vom Smartphone)</string>
|
||||
<string name="settings_connection">Verbindung</string>
|
||||
<string name="settings_usb_auto_detect">OBU per USB-C automatisch erkennen</string>
|
||||
<string name="settings_usb_manual_ip">OBU-IP (manuell)</string>
|
||||
@@ -170,10 +171,6 @@
|
||||
<string name="settings_obu_hardware_cit_one">CiT One</string>
|
||||
<string name="settings_obu_hardware_esp32">ESP32-C5</string>
|
||||
<string name="settings_obu_hardware_esp32_note">Der ESP32-C5 arbeitet als „dummer" Transceiver: CAM wird auf dem Smartphone erstellt und kodiert, über USB-Seriell an den ESP32 gesendet und über ITS-G5 gesendet. Auf diesem Pfad gibt es keinen MQTT-Broker und keine DENM-Use-Case-Engine — siehe den CAM-Pinger im V2X-Monitor für ein manuelles Testwerkzeug.</string>
|
||||
<string name="settings_esp32_rx_mode">CAM-Empfang</string>
|
||||
<string name="settings_esp32_rx_mode_send_only">Nur senden</string>
|
||||
<string name="settings_esp32_rx_mode_send_and_receive">Senden & Empfangen</string>
|
||||
<string name="settings_esp32_rx_mode_desc">„Nur senden" ignoriert von nahen Stationen empfangene CAM (der ESP32 empfängt sie physisch weiterhin — der Empfänger kann nicht abgeschaltet werden, ohne auch das Senden zu unterbrechen — sie werden nur nicht von der App verarbeitet). Nützlich, um den Sendepfad isoliert zu testen.</string>
|
||||
<string name="settings_usb_transport">Aktiver Transport</string>
|
||||
<string name="settings_transport_usbc">USB-C</string>
|
||||
<string name="settings_transport_wifi">WLAN</string>
|
||||
@@ -204,7 +201,31 @@
|
||||
|
||||
<!-- CAM-Pinger — nur ESP32-C5, manueller Bank-Test, Gegenstück zur DENM-TX-Karte oben -->
|
||||
<string name="mqtt_cam_pinger_title">CAM-Pinger (manueller Test)</string>
|
||||
<string name="mqtt_cam_pinger_desc">Fester Standort, 1-Hz-CAM-Ping — prüft die serielle Verbindung und den ESP32-Funkpfad ohne GNSS-Bewegung oder Fahrtaufzeichnung.</string>
|
||||
<string name="mqtt_cam_pinger_desc">1-Hz-CAM-Ping aus Live-GNSS- und IMU-Daten — prüft die serielle Verbindung und den ESP32-Funkpfad ohne Fahrtaufzeichnung.</string>
|
||||
<string name="mqtt_cam_pinger_no_fix">Warte auf GNSS-Fix — noch nichts gesendet</string>
|
||||
|
||||
<!-- Empfangene CAMs (ESP32-C5-Pfad) -->
|
||||
<string name="v2x_cam_rx_count">%1$d Station(en) in Reichweite — jeweils neueste CAM</string>
|
||||
<string name="v2x_cam_rx_none">Keine CAMs empfangen</string>
|
||||
<string name="v2x_cam_rx_none_hint">Dekodierte CAMs benachbarter Stationen erscheinen hier, sobald sie über die serielle Verbindung eintreffen.</string>
|
||||
<string name="v2x_cam_rx_station">Station %1$d · %2$s</string>
|
||||
<string name="v2x_cam_rx_kinematics">%1$.1f km/h · Kurs %2$.0f°</string>
|
||||
<string name="v2x_cam_rx_distance">%1$.0f m</string>
|
||||
<string name="v2x_cam_rx_distance_unknown">— m</string>
|
||||
<string name="v2x_cam_rx_rssi">%1$d dBm</string>
|
||||
|
||||
<!-- DENM-Kartenmarker -->
|
||||
<string name="v2x_map_denm_labeled">Gefahr: Ursache %1$d/%2$d (Station %3$d)</string>
|
||||
<string name="v2x_map_denm_plain">Gefahr von Station %1$d</string>
|
||||
|
||||
<!-- ETSI-Stationstypen -->
|
||||
<string name="station_type_pedestrian">Fußgänger</string>
|
||||
<string name="station_type_cyclist">Radfahrer</string>
|
||||
<string name="station_type_car">Pkw</string>
|
||||
<string name="station_type_bus">Bus</string>
|
||||
<string name="station_type_truck">Lkw</string>
|
||||
<string name="station_type_rsu">Straßenseiteneinheit</string>
|
||||
<string name="station_type_other">Typ %1$d</string>
|
||||
<string name="mqtt_cam_pinger_not_connected">ESP32-C5 verbinden, um den CAM-Pinger zu aktivieren</string>
|
||||
<string name="mqtt_cam_pinger_active">Sendet — 1 CAM/s über die serielle Verbindung</string>
|
||||
<string name="mqtt_cam_pinger_sent_count">Gesendet: %1$d</string>
|
||||
@@ -247,7 +268,7 @@
|
||||
<string name="settings_platform">Plattform</string>
|
||||
<string name="settings_platform_val">Android / Kotlin / Jetpack Compose</string>
|
||||
<string name="settings_project">Projekt</string>
|
||||
<string name="settings_project_val">MicrOBU — HAW Hamburg</string>
|
||||
<string name="settings_project_val">MicrOBU — HAW Hamburg & consider it GmbH</string>
|
||||
|
||||
<!-- Phase A: Trips (bottom nav) -->
|
||||
<string name="nav_trips">Fahrten</string>
|
||||
|
||||
@@ -111,6 +111,7 @@
|
||||
<string name="log_session_number">Session #%1$d</string>
|
||||
<string name="log_share_cd">Share CSV</string>
|
||||
<string name="log_save_cd">Save to device</string>
|
||||
<string name="trip_export_cd">Export trip CSV</string>
|
||||
<string name="log_delete_cd">Delete session</string>
|
||||
<string name="log_empty_title">No sessions yet</string>
|
||||
<string name="log_empty_desc">Go to Record and start a session</string>
|
||||
@@ -163,7 +164,7 @@
|
||||
<string name="settings_wifi_val">Dev mode only — not implemented</string>
|
||||
<string name="settings_about">About</string>
|
||||
<string name="settings_app_version">App version</string>
|
||||
<string name="settings_app_version_val">0.4.0 (Phase 02 — USB-C + DENM TX)</string>
|
||||
<string name="settings_app_version_val">0.5.0 (Phase 03 — ESP32-C5 serial link + phone-built CAM)</string>
|
||||
<string name="settings_connection">Connection</string>
|
||||
<string name="settings_usb_auto_detect">Auto-detect OBU via USB-C</string>
|
||||
<string name="settings_usb_manual_ip">Manual OBU IP</string>
|
||||
@@ -171,10 +172,6 @@
|
||||
<string name="settings_obu_hardware_cit_one">CiT One</string>
|
||||
<string name="settings_obu_hardware_esp32">ESP32-C5</string>
|
||||
<string name="settings_obu_hardware_esp32_note">ESP32-C5 acts as a "dumb" transceiver: CAM is built and encoded on the phone, sent to the ESP32 over USB serial, and broadcast over ITS-G5. No MQTT broker or DENM use-case engine on this path — see the V2X Monitor screen\'s CAM Pinger for a manual test tool.</string>
|
||||
<string name="settings_esp32_rx_mode">CAM Reception</string>
|
||||
<string name="settings_esp32_rx_mode_send_only">Send Only</string>
|
||||
<string name="settings_esp32_rx_mode_send_and_receive">Send & Receive</string>
|
||||
<string name="settings_esp32_rx_mode_desc">"Send Only" ignores CAM received from nearby stations (still physically received by the ESP32 — its receiver can\'t be turned off without also breaking transmit — just not processed by the app). Useful for isolating TX-path testing.</string>
|
||||
<string name="settings_usb_transport">Active transport</string>
|
||||
<string name="settings_transport_usbc">USB-C</string>
|
||||
<string name="settings_transport_wifi">Wi-Fi</string>
|
||||
@@ -205,7 +202,31 @@
|
||||
|
||||
<!-- CAM Pinger — ESP32-C5-only manual bench test, equivalent to the DENM TX card above -->
|
||||
<string name="mqtt_cam_pinger_title">CAM Pinger (Manual Test)</string>
|
||||
<string name="mqtt_cam_pinger_desc">Fixed-location 1 Hz CAM ping — verifies the serial link and ESP32 radio path without needing GNSS movement or a trip recording.</string>
|
||||
<string name="mqtt_cam_pinger_desc">1 Hz CAM ping built from live GNSS and IMU data — verifies the serial link and ESP32 radio path without needing a trip recording.</string>
|
||||
<string name="mqtt_cam_pinger_no_fix">Waiting for GNSS fix — nothing transmitted yet</string>
|
||||
|
||||
<!-- Received-CAM list (ESP32-C5 path) -->
|
||||
<string name="v2x_cam_rx_count">%1$d station(s) in range — latest CAM per station</string>
|
||||
<string name="v2x_cam_rx_none">No CAMs received</string>
|
||||
<string name="v2x_cam_rx_none_hint">Decoded CAMs from nearby stations appear here as they arrive over the serial link.</string>
|
||||
<string name="v2x_cam_rx_station">Station %1$d · %2$s</string>
|
||||
<string name="v2x_cam_rx_kinematics">%1$.1f km/h · heading %2$.0f°</string>
|
||||
<string name="v2x_cam_rx_distance">%1$.0f m</string>
|
||||
<string name="v2x_cam_rx_distance_unknown">— m</string>
|
||||
<string name="v2x_cam_rx_rssi">%1$d dBm</string>
|
||||
|
||||
<!-- DENM map pins -->
|
||||
<string name="v2x_map_denm_labeled">Hazard: cause %1$d/%2$d (station %3$d)</string>
|
||||
<string name="v2x_map_denm_plain">Hazard from station %1$d</string>
|
||||
|
||||
<!-- ETSI station types -->
|
||||
<string name="station_type_pedestrian">Pedestrian</string>
|
||||
<string name="station_type_cyclist">Cyclist</string>
|
||||
<string name="station_type_car">Car</string>
|
||||
<string name="station_type_bus">Bus</string>
|
||||
<string name="station_type_truck">Truck</string>
|
||||
<string name="station_type_rsu">Roadside unit</string>
|
||||
<string name="station_type_other">Type %1$d</string>
|
||||
<string name="mqtt_cam_pinger_not_connected">Connect the ESP32-C5 to enable the CAM pinger</string>
|
||||
<string name="mqtt_cam_pinger_active">Pinging — 1 CAM/s over the serial link</string>
|
||||
<string name="mqtt_cam_pinger_sent_count">Sent: %1$d</string>
|
||||
@@ -248,7 +269,7 @@
|
||||
<string name="settings_platform">Platform</string>
|
||||
<string name="settings_platform_val">Android / Kotlin / Jetpack Compose</string>
|
||||
<string name="settings_project">Project</string>
|
||||
<string name="settings_project_val">MicrOBU — HAW Hamburg</string>
|
||||
<string name="settings_project_val">MicrOBU — HAW Hamburg & consider it GmbH</string>
|
||||
|
||||
<!-- Phase A: Trip Recording (bottom nav) -->
|
||||
<string name="nav_trips">Trips</string>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
|
||||
|
||||
# No longer need -Wl,-zmuldefs here - that was only for main/wifi_patches.c's
|
||||
# symbol-override attempt (which didn't work anyway; see docs/04-transmit-setup.md),
|
||||
# and that file is no longer part of the build. Superseded by main/tx_custom.c,
|
||||
# which bypasses the gate at a different layer instead of trying to override it.
|
||||
|
||||
project(obu_firmware)
|
||||
@@ -0,0 +1,53 @@
|
||||
# OBU transmit firmware - Phase 2 (in progress: HLN-SV DENM beacon)
|
||||
|
||||
Started. See `docs/04-transmit-setup.md` in the project root for build/flash
|
||||
steps and how to validate this against your own sniffer.
|
||||
|
||||
## Toolchain: use a dedicated terminal (ESP-IDF 5.5.4)
|
||||
|
||||
This project builds against the **global** ESP-IDF 5.5.4, NOT the 6.1 checkout
|
||||
that `obu-firmware` uses. Keep one terminal per toolchain and never export both
|
||||
in the same window - the second export inherits the first's
|
||||
`IDF_PYTHON_ENV_PATH` and then fails every dependency check (`click`,
|
||||
`esptool`, `cryptography`, ... "not met"). That is env-var bleed, not a broken
|
||||
install: do **not** run `install.bat` to "fix" it, that damages one of the two
|
||||
environments.
|
||||
|
||||
| Terminal | Export | Project |
|
||||
|---|---|---|
|
||||
| Transmitter | `C:\Espressif\frameworks\esp-idf-v5.5.4\export.ps1` | this one |
|
||||
| OBU | `...\micrOBU_workspace\its-g5-receiver-firmware\esp-idf\export.ps1` | `obu-firmware` |
|
||||
|
||||
If a terminal has already been used for the other IDF, clear the state first:
|
||||
|
||||
```powershell
|
||||
$env:IDF_PYTHON_ENV_PATH = $null; $env:IDF_PATH = $null
|
||||
```
|
||||
|
||||
Also note `build/` here was regenerated from scratch (its CMake cache still
|
||||
referenced an older source path under `micrOBU_workspace/v2x-obu-esp32c5/`,
|
||||
which makes `idf.py fullclean` refuse to run). If that error reappears, delete
|
||||
`build/` manually rather than fighting it.
|
||||
|
||||
## CAM encoding
|
||||
|
||||
`main/cam.c` IS compiled here (unlike `obu-firmware`'s copy, which is a
|
||||
reference only). It must stay bit-identical to `obu-firmware/main/cam.c` and
|
||||
the app's `CamUperCodec.kt` - all three encode the same wire format, and a
|
||||
one-bit divergence in any of them is invisible on the bench but wrong against
|
||||
real equipment. See the `CurvatureCalculationMode` comment in that file.
|
||||
|
||||
Implements one profile so far: **HLN-SV** (aftermarket stationary recovery
|
||||
vehicle), causeCode 94 (stationaryVehicle), subCauseCode 0, active while the
|
||||
hazard-light GPIO is grounded. No location/alacarte containers.
|
||||
|
||||
- `main/main.c` - entry point, the `phy_11p_set`/`phy_change_channel(5900,...)`
|
||||
register hack, GPIO polling, TX loop
|
||||
- `main/denm.c` / `.h` - ASN.1 UPER encoding of a minimal DENM
|
||||
- `main/geonet.c` / `.h` - GeoNetworking Basic/Common/SHB headers + BTP-B
|
||||
- `main/dot11p.c` / `.h` - 802.11 OCB (QoS Data, broadcast) frame + LLC/SNAP
|
||||
|
||||
Known gaps, tracked as TODOs in the source: no real GNSS (lat/long hardcoded
|
||||
0), no real time source (detectionTime/referenceTime hardcoded 0, decodes as
|
||||
2004-01-01), fixed (non-rotating) pseudonym MAC, SHB instead of GeoBroadcast
|
||||
(no multi-hop forwarding), unsecured (no IEEE 1609.2 signing).
|
||||
@@ -0,0 +1,8 @@
|
||||
# wifi_patches.c is intentionally NOT in this list anymore - superseded by
|
||||
# tx_custom.c (see that file for why). Left on disk, unused, for history.
|
||||
idf_component_register(
|
||||
SRCS "main.c" "denm.c" "cam.c" "geonet.c" "dot11p.c" "tx_custom.c"
|
||||
INCLUDE_DIRS "."
|
||||
REQUIRES esp_event esp_netif nvs_flash driver esp_phy
|
||||
PRIV_REQUIRES esp_wifi
|
||||
)
|
||||
@@ -0,0 +1,137 @@
|
||||
#include "cam.h"
|
||||
#include <string.h>
|
||||
|
||||
// MSB-first bit packer - identical approach to denm.c (ASN.1 UPER is a
|
||||
// bitstream, not a byte stream).
|
||||
typedef struct {
|
||||
uint8_t *buf;
|
||||
size_t buf_len;
|
||||
size_t bit_pos;
|
||||
} bitwriter_t;
|
||||
|
||||
static void bw_init(bitwriter_t *bw, uint8_t *buf, size_t len)
|
||||
{
|
||||
bw->buf = buf;
|
||||
bw->buf_len = len;
|
||||
bw->bit_pos = 0;
|
||||
memset(buf, 0, len);
|
||||
}
|
||||
|
||||
static void bw_put_bits(bitwriter_t *bw, uint64_t value, int nbits)
|
||||
{
|
||||
for (int i = nbits - 1; i >= 0; i--) {
|
||||
size_t byte_idx = bw->bit_pos / 8;
|
||||
int bit_idx = 7 - (int)(bw->bit_pos % 8);
|
||||
if (byte_idx >= bw->buf_len) {
|
||||
return; // overflow guard - check return value of cam_encode
|
||||
}
|
||||
uint8_t bit = (value >> i) & 1;
|
||||
bw->buf[byte_idx] = (uint8_t)(bw->buf[byte_idx] | (bit << bit_idx));
|
||||
bw->bit_pos++;
|
||||
}
|
||||
}
|
||||
|
||||
static size_t bw_byte_len(const bitwriter_t *bw)
|
||||
{
|
||||
return (bw->bit_pos + 7) / 8;
|
||||
}
|
||||
|
||||
int cam_encode(const cam_fields_t *f, uint8_t *buf, size_t buf_len)
|
||||
{
|
||||
bitwriter_t bw;
|
||||
bw_init(&bw, buf, buf_len);
|
||||
|
||||
// ---- ItsPduHeader ---- (SEQUENCE, no OPTIONALs, no "..." -> no preamble)
|
||||
bw_put_bits(&bw, 2, 8); // protocolVersion INTEGER(0..255) = 2
|
||||
bw_put_bits(&bw, 2, 8); // messageID INTEGER(0..255) = cam(2)
|
||||
bw_put_bits(&bw, f->station_id, 32); // stationID StationID INTEGER(0..4294967295)
|
||||
|
||||
// ---- CoopAwareness ---- (SEQUENCE, no OPTIONALs, no "...")
|
||||
// generationDeltaTime GenerationDeltaTime INTEGER(0..65535) -> 16 bits
|
||||
bw_put_bits(&bw, f->generation_delta_time, 16);
|
||||
|
||||
// ---- CamParameters ---- (SEQUENCE, EXTENSIBLE "...", 2 OPTIONALs:
|
||||
// lowFrequencyContainer, specialVehicleContainer)
|
||||
bw_put_bits(&bw, 0, 1); // extension bit: no extension additions
|
||||
bw_put_bits(&bw, 1, 1); // lowFrequencyContainer present
|
||||
bw_put_bits(&bw, 0, 1); // specialVehicleContainer absent
|
||||
|
||||
// ---- BasicContainer ---- (SEQUENCE, EXTENSIBLE "...", no OPTIONALs)
|
||||
bw_put_bits(&bw, 0, 1); // extension bit: none
|
||||
bw_put_bits(&bw, f->station_type, 8); // stationType StationType INTEGER(0..255)
|
||||
|
||||
// ReferencePosition (SEQUENCE, no OPTIONALs/"..."), identical widths to
|
||||
// DENM eventPosition (see denm.c for the constraint derivations):
|
||||
// Latitude INTEGER(-900000000..900000001) -> 31 bits, offset from -900000000
|
||||
uint32_t lat_offset = (uint32_t)((int64_t)f->latitude_tenmicrodeg - (-900000000));
|
||||
bw_put_bits(&bw, lat_offset, 31);
|
||||
// Longitude INTEGER(-1800000000..1800000001) -> 32 bits, offset from -1800000000
|
||||
uint32_t lon_offset = (uint32_t)((int64_t)f->longitude_tenmicrodeg - (-1800000000));
|
||||
bw_put_bits(&bw, lon_offset, 32);
|
||||
// PosConfidenceEllipse: SemiAxisLength(0..4095)->12, HeadingValue(0..3601)->12
|
||||
bw_put_bits(&bw, 4095, 12); // semiMajorConfidence: unavailable
|
||||
bw_put_bits(&bw, 4095, 12); // semiMinorConfidence: unavailable
|
||||
bw_put_bits(&bw, 3601, 12); // semiMajorOrientation: unavailable
|
||||
// Altitude: AltitudeValue(-100000..800001)->20 (offset from -100000),
|
||||
// AltitudeConfidence ENUM 16 values -> 4 bits
|
||||
bw_put_bits(&bw, 900001, 20); // 800001 ("unavailable") - (-100000) = 900001
|
||||
bw_put_bits(&bw, 15, 4); // altitudeConfidence: unavailable(15)
|
||||
|
||||
// ---- HighFrequencyContainer ---- CHOICE { basicVehicleContainerHighFrequency,
|
||||
// rsuContainerHighFrequency, ... } - EXTENSIBLE, 2 root alternatives.
|
||||
bw_put_bits(&bw, 0, 1); // CHOICE extension bit: value is in root
|
||||
bw_put_bits(&bw, 0, 1); // index: 0 = basicVehicleContainerHighFrequency (1 bit for 2 alts)
|
||||
|
||||
// BasicVehicleContainerHighFrequency (SEQUENCE, NOT extensible, 7 OPTIONALs
|
||||
// accelerationControl..cenDsrcTollingZone - all absent).
|
||||
bw_put_bits(&bw, 0, 7); // 7 optional-presence bits, all absent
|
||||
|
||||
// Heading: HeadingValue(0..3601)->12, HeadingConfidence(1..127)->7 (offset from 1)
|
||||
bw_put_bits(&bw, f->heading_ddeg, 12);
|
||||
bw_put_bits(&bw, 127 - 1, 7); // headingConfidence: unavailable(127)
|
||||
// Speed: SpeedValue(0..16383)->14, SpeedConfidence(1..127)->7 (offset from 1)
|
||||
bw_put_bits(&bw, f->speed_cm_s, 14);
|
||||
bw_put_bits(&bw, 127 - 1, 7); // speedConfidence: unavailable(127)
|
||||
// DriveDirection ENUM {forward,backward,unavailable} -> 2 bits
|
||||
bw_put_bits(&bw, 2, 2); // unavailable
|
||||
// VehicleLength: VehicleLengthValue(1..1023)->10 (offset from 1),
|
||||
// VehicleLengthConfidenceIndication ENUM 5 values -> 3 bits
|
||||
bw_put_bits(&bw, (uint32_t)f->vehicle_length_dm - 1, 10);
|
||||
bw_put_bits(&bw, 4, 3); // vehicleLengthConfidenceIndication: unavailable(4)
|
||||
// VehicleWidth INTEGER(1..62) -> 6 bits (offset from 1)
|
||||
bw_put_bits(&bw, (uint32_t)f->vehicle_width_dm - 1, 6);
|
||||
// LongitudinalAcceleration: value(-160..161)->9 (offset from -160),
|
||||
// AccelerationConfidence(0..102)->7
|
||||
bw_put_bits(&bw, 161 - (uint32_t)(-160), 9); // longitudinalAccelerationValue: unavailable(161)
|
||||
bw_put_bits(&bw, 102, 7); // confidence: unavailable(102)
|
||||
// Curvature: CurvatureValue(-1023..1023)->11 (offset from -1023),
|
||||
// CurvatureConfidence ENUM 8 values -> 3 bits
|
||||
bw_put_bits(&bw, 1023 - (uint32_t)(-1023), 11); // curvatureValue: unavailable(1023)
|
||||
bw_put_bits(&bw, 7, 3); // curvatureConfidence: unavailable(7)
|
||||
// CurvatureCalculationMode ENUM {yawRateUsed,yawRateNotUsed,unavailable, ...} - note the
|
||||
// extension marker: UPER encodes an extensible ENUMERATED as an extension bit followed by
|
||||
// the root-list index, so this is 1 + 2 = 3 bits, NOT 2. Writing only the 2-bit index shifted
|
||||
// yawRate and the entire low-frequency container one bit early for any standards-compliant
|
||||
// receiver - including, after its matching fix, the phone app's own decoder.
|
||||
// Keep in lockstep with obu-firmware/main/cam.c and the app's CamUperCodec.kt.
|
||||
bw_put_bits(&bw, 0, 1); // extension bit: value is in the root list
|
||||
bw_put_bits(&bw, 2, 2); // unavailable(2)
|
||||
// YawRate: YawRateValue(-32766..32767)->16 (offset from -32766),
|
||||
// YawRateConfidence ENUM 8 values -> 3 bits
|
||||
bw_put_bits(&bw, 32767 - (uint32_t)(-32766), 16); // yawRateValue: unavailable(32767)
|
||||
bw_put_bits(&bw, 7, 3); // yawRateConfidence: unavailable(7)
|
||||
|
||||
// ---- LowFrequencyContainer ---- CHOICE { basicVehicleContainerLowFrequency,
|
||||
// ... } - EXTENSIBLE, 1 root alternative (index needs 0 bits).
|
||||
bw_put_bits(&bw, 0, 1); // CHOICE extension bit: value is in root
|
||||
|
||||
// BasicVehicleContainerLowFrequency (SEQUENCE, no OPTIONALs/"...")
|
||||
// vehicleRole VehicleRole ENUM 16 values -> 4 bits
|
||||
bw_put_bits(&bw, 0, 4); // default(0)
|
||||
// exteriorLights ExteriorLights BIT STRING(SIZE(8)) -> 8 bits, all off
|
||||
bw_put_bits(&bw, 0, 8);
|
||||
// pathHistory PathHistory ::= SEQUENCE(SIZE(0..40)) OF PathPoint -> count 0..40 = 6 bits
|
||||
bw_put_bits(&bw, 0, 6); // empty path history
|
||||
|
||||
return (int)bw_byte_len(&bw);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
#ifndef CAM_H
|
||||
#define CAM_H
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
|
||||
// Minimal CAM (Cooperative Awareness Message) per ETSI EN 302 637-2 v1.4.1
|
||||
// (CAM-PDU-Descriptions) + TS 102 894-2 v1.3.1 (CDD / ITS-Container), matching
|
||||
// the field set the working Rust reference (esp32-c_its-companion, feat/tx-cam,
|
||||
// src/applogic/cam_tx.rs) transmits:
|
||||
// - ItsPduHeader (protocolVersion 2, messageID 2 = cam)
|
||||
// - CoopAwareness { generationDeltaTime, camParameters }
|
||||
// - CamParameters {
|
||||
// basicContainer { stationType, referencePosition },
|
||||
// highFrequencyContainer = basicVehicleContainerHighFrequency { ... },
|
||||
// lowFrequencyContainer = basicVehicleContainerLowFrequency { ... }
|
||||
// }
|
||||
// All vehicle-dynamics fields we don't measure are encoded as their ASN.1
|
||||
// "unavailable" value. Speed is a real 0 (correct for a stationary station).
|
||||
|
||||
typedef struct {
|
||||
uint32_t station_id;
|
||||
uint8_t station_type; // StationType(0..255): 5 = passengerCar
|
||||
uint16_t generation_delta_time; // TimestampIts mod 65536 (ms); 0 until a real clock is wired
|
||||
int32_t latitude_tenmicrodeg; // Latitude, 1/10 microdegree
|
||||
int32_t longitude_tenmicrodeg; // Longitude, 1/10 microdegree
|
||||
uint16_t speed_cm_s; // SpeedValue, 0.01 m/s units (0 = stationary)
|
||||
uint16_t heading_ddeg; // HeadingValue, 0.1 deg units (0..3600), 3601 = unavailable
|
||||
uint16_t vehicle_length_dm; // VehicleLengthValue(1..1023), 10cm steps
|
||||
uint8_t vehicle_width_dm; // VehicleWidth(1..62), 10cm steps
|
||||
} cam_fields_t;
|
||||
|
||||
// Encodes the CAM as ASN.1 UPER. Returns bytes written, or -1 if buf too small.
|
||||
int cam_encode(const cam_fields_t *f, uint8_t *buf, size_t buf_len);
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,153 @@
|
||||
#include "denm.h"
|
||||
#include <string.h>
|
||||
|
||||
// Minimal MSB-first bit packer - ASN.1 UPER is a bitstream, not a byte
|
||||
// stream, so we can't just memcpy structs.
|
||||
typedef struct {
|
||||
uint8_t *buf;
|
||||
size_t buf_len;
|
||||
size_t bit_pos;
|
||||
} bitwriter_t;
|
||||
|
||||
static void bw_init(bitwriter_t *bw, uint8_t *buf, size_t len)
|
||||
{
|
||||
bw->buf = buf;
|
||||
bw->buf_len = len;
|
||||
bw->bit_pos = 0;
|
||||
memset(buf, 0, len);
|
||||
}
|
||||
|
||||
static void bw_put_bits(bitwriter_t *bw, uint64_t value, int nbits)
|
||||
{
|
||||
for (int i = nbits - 1; i >= 0; i--) {
|
||||
size_t byte_idx = bw->bit_pos / 8;
|
||||
int bit_idx = 7 - (int)(bw->bit_pos % 8);
|
||||
if (byte_idx >= bw->buf_len) {
|
||||
return; // overflow guard - silently truncates, check return value of denm_encode
|
||||
}
|
||||
uint8_t bit = (value >> i) & 1;
|
||||
bw->buf[byte_idx] = (uint8_t)(bw->buf[byte_idx] | (bit << bit_idx));
|
||||
bw->bit_pos++;
|
||||
}
|
||||
}
|
||||
|
||||
static size_t bw_byte_len(const bitwriter_t *bw)
|
||||
{
|
||||
return (bw->bit_pos + 7) / 8;
|
||||
}
|
||||
|
||||
int denm_encode(const denm_fields_t *f, uint8_t *buf, size_t buf_len)
|
||||
{
|
||||
bitwriter_t bw;
|
||||
bw_init(&bw, buf, buf_len);
|
||||
|
||||
// ---- ItsPduHeader ---- (SEQUENCE, no OPTIONALs, no "..." -> no preamble at all)
|
||||
bw_put_bits(&bw, 2, 8); // protocolVersion INTEGER(0..255) = 2
|
||||
bw_put_bits(&bw, 1, 8); // messageID INTEGER(0..255) = denm(1)
|
||||
bw_put_bits(&bw, f->station_id, 32); // stationID = StationID INTEGER(0..4294967295) = 32 bits
|
||||
|
||||
// ---- DenmPayload (DecentralizedEnvironmentalNotificationMessage) ----
|
||||
// No "..." on this SEQUENCE -> no extension bit, just the 3-bit
|
||||
// optional-component preamble in declared order: situation, location,
|
||||
// alacarte. "No additional parameters" means location/alacarte stay
|
||||
// absent.
|
||||
bw_put_bits(&bw, 1, 1); // situation present
|
||||
bw_put_bits(&bw, 0, 1); // location absent
|
||||
bw_put_bits(&bw, 0, 1); // alacarte absent
|
||||
|
||||
// ---- ManagementContainer ----
|
||||
// This SEQUENCE ends in "..." in the real ASN.1 module -> extensible,
|
||||
// so it needs a leading 1-bit extension flag (0 = no extension
|
||||
// additions used) BEFORE the 5-bit optional/default preamble
|
||||
// (termination, relevanceDistance, relevanceTrafficDirection,
|
||||
// validityDuration, transmissionInterval, in that declared order). An
|
||||
// earlier version of this code omitted the extension bit entirely,
|
||||
// which would shift every single bit after it and corrupt the whole
|
||||
// rest of the message for any spec-compliant decoder.
|
||||
bw_put_bits(&bw, 0, 1); // ManagementContainer extension bit: none used
|
||||
bw_put_bits(&bw, f->terminate ? 1 : 0, 1); // termination present only when cancelling
|
||||
bw_put_bits(&bw, 0, 1); // relevanceDistance absent
|
||||
bw_put_bits(&bw, 0, 1); // relevanceTrafficDirection absent
|
||||
bw_put_bits(&bw, 0, 1); // validityDuration absent -> default 600s applies
|
||||
bw_put_bits(&bw, 0, 1); // transmissionInterval absent
|
||||
|
||||
// actionID = ActionID{ originatingStationID StationID(32), sequenceNumber
|
||||
// SequenceNumber(0..65535, 16 bits) } - no OPTIONALs/"..." -> no preamble.
|
||||
// Keep sequenceNumber constant across repeats of the SAME event - it's
|
||||
// the caller's job (see main.c) to only bump it on a genuinely new event
|
||||
// and reuse it for that event's eventual termination message.
|
||||
bw_put_bits(&bw, f->station_id, 32);
|
||||
bw_put_bits(&bw, f->sequence_number, 16);
|
||||
|
||||
// detectionTime / referenceTime: TimestampIts INTEGER(0..4398046511103)
|
||||
// = exactly 42 bits (2^42), ms since 2004-01-01T00:00:00Z. NOT WIRED UP
|
||||
// YET - there's no RTC/NTP sync in this skeleton, so this is 0 (decodes
|
||||
// as 2004-01-01). Wire in SNTP or a GNSS UTC fix before this is real.
|
||||
bw_put_bits(&bw, 0, 42);
|
||||
bw_put_bits(&bw, 0, 42);
|
||||
|
||||
// termination VALUE - only emitted when present (per the preamble bit
|
||||
// above - UPER never encodes a value for an absent optional component).
|
||||
// Termination ::= ENUMERATED{isCancellation(0), isNegation(1)}, no
|
||||
// "...", 2 values -> 1 bit.
|
||||
if (f->terminate) {
|
||||
bw_put_bits(&bw, 0, 1); // isCancellation
|
||||
}
|
||||
|
||||
// eventPosition (ReferencePosition ::= SEQUENCE{latitude, longitude,
|
||||
// positionConfidenceEllipse, altitude} - no OPTIONALs/"..." -> no
|
||||
// preamble, straight concatenation). Widths below are each field's
|
||||
// exact constrained-INTEGER range size from ITS-Container.asn, encoded
|
||||
// as an unsigned offset from the type's declared minimum - NOT assumed
|
||||
// to match neighboring fields (latitude and longitude are different
|
||||
// widths, which is easy to miss).
|
||||
// Latitude ::= INTEGER(-900000000..900000001) -> range 1800000002 -> 31 bits
|
||||
uint32_t lat_offset = (uint32_t)(f->latitude_tenmicrodeg - (-900000000));
|
||||
bw_put_bits(&bw, lat_offset, 31);
|
||||
// Longitude ::= INTEGER(-1800000000..1800000001) -> range 3600000002 -> 32 bits
|
||||
uint32_t lon_offset = (uint32_t)(f->longitude_tenmicrodeg - (-1800000000));
|
||||
bw_put_bits(&bw, lon_offset, 32);
|
||||
// PosConfidenceEllipse ::= SEQUENCE{semiMajorConfidence, semiMinorConfidence,
|
||||
// semiMajorOrientation} - no preamble.
|
||||
// SemiAxisLength ::= INTEGER(0..4095) -> 12 bits (not 16 - this was wrong before)
|
||||
bw_put_bits(&bw, 4095, 12); // semiMajorConfidence: unavailable
|
||||
bw_put_bits(&bw, 4095, 12); // semiMinorConfidence: unavailable
|
||||
// HeadingValue ::= INTEGER(0..3601) -> 12 bits (not 16 - this was wrong before)
|
||||
bw_put_bits(&bw, 3601, 12); // semiMajorOrientation: unavailable
|
||||
// Altitude ::= SEQUENCE{altitudeValue, altitudeConfidence} - no preamble.
|
||||
// AltitudeValue ::= INTEGER(-100000..800001) -> range 900002 -> 20 bits
|
||||
// (not 24 - this was wrong before), offset-encoded from -100000.
|
||||
bw_put_bits(&bw, 900001, 20); // 800001 ("unavailable") - (-100000) = 900001
|
||||
// AltitudeConfidence ::= ENUMERATED, 16 named values, no "..." -> 4 bits
|
||||
bw_put_bits(&bw, 15, 4); // unavailable
|
||||
|
||||
// stationType: StationType INTEGER(0..255) -> 8 bits fixed regardless of
|
||||
// how sparse the named values are.
|
||||
bw_put_bits(&bw, f->station_type, 8);
|
||||
|
||||
// ---- SituationContainer ----
|
||||
// This SEQUENCE also ends in "..." -> its own 1-bit extension flag,
|
||||
// THEN the 2-bit preamble (linkedCause, eventHistory), THEN the
|
||||
// mandatory field values. An earlier version of this code put the
|
||||
// linkedCause/eventHistory bits at the END instead of the start, and
|
||||
// had no extension bit at all - both are structural bugs that would
|
||||
// desync any spec-compliant decoder from this point on.
|
||||
bw_put_bits(&bw, 0, 1); // SituationContainer extension bit: none used
|
||||
bw_put_bits(&bw, 0, 1); // linkedCause absent
|
||||
bw_put_bits(&bw, 0, 1); // eventHistory absent
|
||||
|
||||
// informationQuality: InformationQuality INTEGER(0..7) -> 3 bits
|
||||
bw_put_bits(&bw, 1, 3); // low quality - no real sensor input, just the hazard-light GPIO
|
||||
|
||||
// eventType: CauseCode ::= SEQUENCE{causeCode, subCauseCode, ...} - this
|
||||
// inner SEQUENCE is ALSO extensible ("..."), so it gets its own leading
|
||||
// extension bit before its two mandatory fields.
|
||||
bw_put_bits(&bw, 0, 1); // CauseCode extension bit: none used
|
||||
bw_put_bits(&bw, f->cause_code, 8); // CauseCodeType INTEGER(0..255) -> 8 bits
|
||||
bw_put_bits(&bw, f->sub_cause_code, 8); // SubCauseCodeType INTEGER(0..255) -> 8 bits
|
||||
|
||||
// linkedCause / eventHistory: both absent, already signalled in the
|
||||
// preamble above - UPER writes no value bits for them.
|
||||
|
||||
return (int)bw_byte_len(&bw);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
#ifndef DENM_H
|
||||
#define DENM_H
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
#include <stdbool.h>
|
||||
|
||||
// Full CauseCodeType enumeration, straight from the authoritative source:
|
||||
// ETSI TS 102 894-2 (CDD) ITS-Container.asn, CauseCodeType definition.
|
||||
// (Values 1/2/3/14/26/27/91/94/95/97 were already cross-checked earlier
|
||||
// against a real captured DENM; the rest are now confirmed the same way,
|
||||
// from the actual ASN.1 module rather than guessed.)
|
||||
#define DENM_CAUSE_RESERVED 0
|
||||
#define DENM_CAUSE_TRAFFIC_CONDITION 1
|
||||
#define DENM_CAUSE_ACCIDENT 2
|
||||
#define DENM_CAUSE_ROADWORKS 3
|
||||
#define DENM_CAUSE_IMPASSABILITY 5
|
||||
#define DENM_CAUSE_ADVERSE_WEATHER_ADHESION 6
|
||||
#define DENM_CAUSE_AQUAPLANNING 7
|
||||
#define DENM_CAUSE_HAZARDOUS_LOCATION_SURFACE_CONDITION 9
|
||||
#define DENM_CAUSE_HAZARDOUS_LOCATION_OBSTACLE_ON_ROAD 10
|
||||
#define DENM_CAUSE_HAZARDOUS_LOCATION_ANIMAL_ON_ROAD 11
|
||||
#define DENM_CAUSE_HUMAN_PRESENCE_ON_ROAD 12
|
||||
#define DENM_CAUSE_WRONG_WAY_DRIVING 14
|
||||
#define DENM_CAUSE_RESCUE_AND_RECOVERY_WORK_IN_PROGRESS 15
|
||||
#define DENM_CAUSE_ADVERSE_WEATHER_EXTREME 17
|
||||
#define DENM_CAUSE_ADVERSE_WEATHER_VISIBILITY 18
|
||||
#define DENM_CAUSE_ADVERSE_WEATHER_PRECIPITATION 19
|
||||
#define DENM_CAUSE_SLOW_VEHICLE 26
|
||||
#define DENM_CAUSE_DANGEROUS_END_OF_QUEUE 27
|
||||
#define DENM_CAUSE_VEHICLE_BREAKDOWN 91
|
||||
#define DENM_CAUSE_POST_CRASH 92
|
||||
#define DENM_CAUSE_HUMAN_PROBLEM 93
|
||||
#define DENM_CAUSE_STATIONARY_VEHICLE 94
|
||||
#define DENM_CAUSE_EMERGENCY_VEHICLE_APPROACHING 95
|
||||
#define DENM_CAUSE_HAZARDOUS_LOCATION_DANGEROUS_CURVE 96
|
||||
#define DENM_CAUSE_COLLISION_RISK 97
|
||||
#define DENM_CAUSE_SIGNAL_VIOLATION 98
|
||||
#define DENM_CAUSE_DANGEROUS_SITUATION 99
|
||||
|
||||
typedef struct {
|
||||
uint32_t station_id;
|
||||
uint16_t sequence_number; // keep constant across repeats of the SAME event; only bump on a genuinely new event
|
||||
uint8_t cause_code; // e.g. 94 = stationaryVehicle
|
||||
uint8_t sub_cause_code; // 0 = unspecified
|
||||
uint8_t station_type; // StationType, e.g. 5 = passengerCar - match geonet_wrap_shb's station_type param
|
||||
int32_t latitude_tenmicrodeg; // 1/10 microdegree; 0 = placeholder/unavailable
|
||||
int32_t longitude_tenmicrodeg; // 1/10 microdegree; 0 = placeholder/unavailable
|
||||
bool terminate; // true = encode this as a Termination(isCancellation) message instead of a normal update
|
||||
} denm_fields_t;
|
||||
|
||||
// Encodes a minimal DENM (ItsPduHeader + ManagementContainer +
|
||||
// SituationContainer only - no location/alacarte containers) as ASN.1 UPER,
|
||||
// per the actual ETSI EN 302 637-3 / TS 102 894-2 ASN.1 modules (fetched
|
||||
// from forge.etsi.org, not reconstructed from memory). Returns bytes
|
||||
// written, or -1 if buf too small.
|
||||
//
|
||||
// Two things worth knowing if you're reading this against the modules
|
||||
// yourself: ManagementContainer, SituationContainer, and CauseCode are all
|
||||
// declared with a trailing "..." (extensible), which means each needs its
|
||||
// own leading extension bit in the UPER encoding - easy to miss, and this
|
||||
// code got it wrong in an earlier version. Field bit-widths below (e.g.
|
||||
// latitude=31 bits, longitude=32 bits, position-confidence fields=12 bits,
|
||||
// altitudeValue=20 bits) are derived directly from each type's declared
|
||||
// INTEGER constraint range, not assumed to match neighboring fields.
|
||||
int denm_encode(const denm_fields_t *f, uint8_t *buf, size_t buf_len);
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,57 @@
|
||||
#include "dot11p.h"
|
||||
#include <string.h>
|
||||
|
||||
int dot11p_build_frame(const uint8_t *gn_payload, int gn_len,
|
||||
const uint8_t src_mac[6],
|
||||
uint8_t *out, size_t out_len, bool qos)
|
||||
{
|
||||
static const uint8_t broadcast[6] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF};
|
||||
static const uint8_t llc_snap[8] = {0xAA, 0xAA, 0x03, 0x00, 0x00, 0x00, 0x89, 0x47};
|
||||
|
||||
int hdr_len = qos ? 26 : 24; // QoS Data adds a 2-byte QoS Control field
|
||||
int total = hdr_len + 8 /* LLC/SNAP */ + gn_len;
|
||||
if ((size_t)total > out_len) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
uint8_t *p = out;
|
||||
|
||||
// Frame Control: version=0, type=Data(2), subtype=QoS Data(8) -> bytes
|
||||
// 0x88 0x00. This is what real ITS-G5 hardware actually transmits.
|
||||
//
|
||||
// Back on QoS Data again (previously downgraded to non-QoS, subtype 0,
|
||||
// as a working-but-nonstandard fallback - see git history / old comments
|
||||
// here for that whole detour). What changed: main.c no longer calls
|
||||
// esp_wifi_80211_tx() at all - it now goes through
|
||||
// esp_wifi_80211_tx_custom() (tx_custom.c, pulled from
|
||||
// opentrafficmap/its-g5-receiver-firmware_txenabled), which bypasses the
|
||||
// frame-type sanity check entirely by never calling the code path that
|
||||
// contains it. Frame subtype is no longer gated, so there's no reason
|
||||
// left to avoid matching real hardware here.
|
||||
// Frame Control byte 0: version=0, type=Data(2). Subtype: QoS Data(8)=0x88
|
||||
// for the tx_custom path, or plain Data(0)=0x08 for the standard
|
||||
// esp_wifi_80211_tx() path (which rejects QoS Data outright).
|
||||
*p++ = qos ? 0x88 : 0x08; *p++ = 0x00;
|
||||
// Duration
|
||||
*p++ = 0x00; *p++ = 0x00;
|
||||
// Addr1 = destination = broadcast
|
||||
memcpy(p, broadcast, 6); p += 6;
|
||||
// Addr2 = source (our pseudonym)
|
||||
memcpy(p, src_mac, 6); p += 6;
|
||||
// Addr3 = BSSID = broadcast (no BSS exists in OCB mode)
|
||||
memcpy(p, broadcast, 6); p += 6;
|
||||
// Sequence control - left at 0; en_sys_seq=true fills this in for us
|
||||
*p++ = 0x00; *p++ = 0x00;
|
||||
// QoS Control field - only present in QoS Data frames
|
||||
if (qos) {
|
||||
*p++ = 0x00; *p++ = 0x00; // best-effort access category
|
||||
}
|
||||
|
||||
// LLC/SNAP (Ethertype 0x8947 = GeoNetworking)
|
||||
memcpy(p, llc_snap, 8); p += 8;
|
||||
|
||||
// GeoNetworking + BTP + DENM payload
|
||||
memcpy(p, gn_payload, gn_len); p += gn_len;
|
||||
|
||||
return (int)(p - out);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
#ifndef DOT11P_H
|
||||
#define DOT11P_H
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
#include <stdbool.h>
|
||||
|
||||
// Wraps a GeoNetworking-layer payload in an 802.11 OCB frame: QoS Data
|
||||
// (subtype 8, 26-byte header), matching real ITS-G5 hardware, broadcast, no
|
||||
// BSS (Addr1=Addr3=broadcast), LLC/SNAP with Ethertype 0x8947
|
||||
// (GeoNetworking's registered Ethertype). Output is ready to hand straight
|
||||
// to esp_wifi_80211_tx_custom() (tx_custom.c) - NOT esp_wifi_80211_tx(),
|
||||
// which rejects this frame type outright. `src_mac` is used as Addr2 - pass
|
||||
// the same 6 bytes you gave geonet_wrap_shb, since GN_ADDR's MID field is
|
||||
// defined to be this same link-layer address. Returns bytes written, or -1
|
||||
// if out buffer too small.
|
||||
//
|
||||
// History: this used to be downgraded to non-QoS Data (subtype 0) because
|
||||
// esp_wifi_80211_tx() rejects QoS Data ("unsupport QoS frame type" / esp_err
|
||||
// 258) and an attempted linker-override bypass (old main/wifi_patches.c)
|
||||
// didn't work. Restored to QoS Data now that main.c transmits via
|
||||
// esp_wifi_80211_tx_custom() instead, which bypasses that gate entirely
|
||||
// (see tx_custom.c) - so there's no longer a reason to deviate from the
|
||||
// real frame format.
|
||||
// qos=true -> QoS Data (subtype 8, 26-byte header) for esp_wifi_80211_tx_custom()
|
||||
// qos=false -> plain Data (subtype 0, 24-byte header) which the STANDARD
|
||||
// esp_wifi_80211_tx() accepts (used for the standard-TX isolation test)
|
||||
int dot11p_build_frame(const uint8_t *gn_payload, int gn_len,
|
||||
const uint8_t src_mac[6],
|
||||
uint8_t *out, size_t out_len, bool qos);
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,85 @@
|
||||
#include "geonet.h"
|
||||
#include <string.h>
|
||||
|
||||
int geonet_wrap_shb(const uint8_t *its_payload, int its_len,
|
||||
const uint8_t mac[6], uint8_t station_type,
|
||||
int32_t latitude_tenmicrodeg, int32_t longitude_tenmicrodeg,
|
||||
uint16_t btp_dest_port,
|
||||
uint8_t *out, size_t out_len)
|
||||
{
|
||||
// GN Basic Header (4) + GN Common Header (8) + SHB source LPV (24)
|
||||
// + BTP-B header (4) + ITS payload
|
||||
int total = 4 + 8 + 24 + 4 + its_len;
|
||||
if ((size_t)total > out_len) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
uint8_t *p = out;
|
||||
|
||||
// ---- GN Basic Header (4 bytes) ---- (EN 302 636-4-1 clause 9.6)
|
||||
*p++ = (uint8_t)((1 << 4) | 1); // version=1, NextHeader=1 (Common Header, unsecured)
|
||||
*p++ = 0x00; // reserved
|
||||
*p++ = 0x83; // lifetime (~60s in the base/multiplier encoding) - tune if needed
|
||||
*p++ = 1; // remaining hop limit = 1 (SHB single-hop; matches CAM in the Rust reference)
|
||||
|
||||
// ---- GN Common Header (8 bytes) ---- (clause 9.7)
|
||||
*p++ = (uint8_t)((2 << 4) | 0); // NextHeader=2 (BTP-B), reserved nibble
|
||||
// HeaderType=5 (TSB), HeaderSubtype=0 (SINGLE_HOP) per table 9 - this is
|
||||
// the actual encoding for single-hop broadcast. An earlier version of
|
||||
// this code used (2,0), which is GEOUNICAST - wrong header type entirely
|
||||
// for a broadcast frame; real receivers would try to match the
|
||||
// destination-address extended header GeoUnicast expects and mishandle
|
||||
// or reject the packet.
|
||||
*p++ = (uint8_t)((5 << 4) | 0);
|
||||
*p++ = 0x02; // traffic class: SCF=0, ChannelOffload=0, TC-ID=2 (clause 9.7.5)
|
||||
*p++ = 0x80; // flags: bit0 = "is mobile" station (clause 9.7.2)
|
||||
// Payload length = what follows the WHOLE GeoNetworking header
|
||||
// (Basic+Common+Extended), i.e. BTP-B header + ITS payload only - does
|
||||
// NOT include the 24-byte extended header itself. An earlier version of
|
||||
// this code wrongly added the 24 bytes in here too.
|
||||
uint16_t payload_len = (uint16_t)(4 + its_len);
|
||||
*p++ = (uint8_t)(payload_len >> 8);
|
||||
*p++ = (uint8_t)(payload_len & 0xFF);
|
||||
*p++ = 1; // max hop limit = 1, matches basic header RHL (SHB single-hop)
|
||||
*p++ = 0x00; // reserved
|
||||
|
||||
// ---- SHB extended header: Source Long Position Vector (24 bytes) ----
|
||||
// (clause 9.5.2). GN_ADDR (8 bytes) is itself structured, not a raw
|
||||
// pseudonym (clause 9.5.1): bit0 M-flag(0=auto-derived), bits1-5 ITS-S
|
||||
// type (5-bit), bits6-15 reserved(=0), then octets2-7 = MID, which is
|
||||
// defined to BE the link-layer (802.11) address - so this must match
|
||||
// the source address dot11p_build_frame uses, not just "look similar."
|
||||
uint8_t gn_addr[8];
|
||||
gn_addr[0] = (uint8_t)((0 << 7) | ((station_type & 0x1F) << 2)); // M=0, ST=station_type, top 2 reserved bits=0
|
||||
gn_addr[1] = 0x00; // remaining 8 reserved bits
|
||||
memcpy(&gn_addr[2], mac, 6); // MID = link-layer address
|
||||
memcpy(p, gn_addr, 8); p += 8;
|
||||
// Timestamp (4 bytes, ms since 2004-01-01 mod 2^32) - placeholder 0,
|
||||
// same caveat as detectionTime in denm.c.
|
||||
memset(p, 0, 4); p += 4;
|
||||
// Latitude/Longitude (4+4 bytes, signed, big-endian, 1/10 microdegree) -
|
||||
// fixed-width binary fields, not UPER bit-packed.
|
||||
uint32_t lat_u = (uint32_t)latitude_tenmicrodeg;
|
||||
*p++ = (uint8_t)(lat_u >> 24); *p++ = (uint8_t)(lat_u >> 16);
|
||||
*p++ = (uint8_t)(lat_u >> 8); *p++ = (uint8_t)(lat_u);
|
||||
uint32_t lon_u = (uint32_t)longitude_tenmicrodeg;
|
||||
*p++ = (uint8_t)(lon_u >> 24); *p++ = (uint8_t)(lon_u >> 16);
|
||||
*p++ = (uint8_t)(lon_u >> 8); *p++ = (uint8_t)(lon_u);
|
||||
// PAI(1 bit) + Speed(15 bits), packed into 2 bytes: 0 = PAI false,
|
||||
// speed 0 - which is actually correct semantics for a STATIONARY
|
||||
// vehicle beacon, not just a placeholder.
|
||||
*p++ = 0x00; *p++ = 0x00;
|
||||
// Heading (16 bits, 0.1 degree units): 0 = due north / unavailable
|
||||
*p++ = 0x00; *p++ = 0x00;
|
||||
|
||||
// ---- BTP-B header (4 bytes) ----
|
||||
*p++ = (uint8_t)(btp_dest_port >> 8);
|
||||
*p++ = (uint8_t)(btp_dest_port & 0xFF);
|
||||
*p++ = 0x00; *p++ = 0x00; // destination port info, unused for BTP-B
|
||||
|
||||
// ---- ITS payload (DENM UPER bytes) ----
|
||||
memcpy(p, its_payload, its_len);
|
||||
p += its_len;
|
||||
|
||||
return (int)(p - out);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
#ifndef GEONET_H
|
||||
#define GEONET_H
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
|
||||
// Wraps an ITS application payload (e.g. from denm_encode) with a minimal
|
||||
// GeoNetworking Basic Header + Common Header + Single-Hop-Broadcast
|
||||
// extended header (HeaderType=TSB(5), HeaderSubtype=SINGLE_HOP(0), per
|
||||
// ETSI EN 302 636-4-1 table 9), then prepends a BTP-B header addressed to
|
||||
// the DENM service port (2002).
|
||||
//
|
||||
// `mac` is the 6-byte pseudonym/link-layer address - pass the SAME address
|
||||
// you hand to dot11p_build_frame's src address, since GN_ADDR's MID field
|
||||
// (the last 6 bytes of the 8-byte GN_ADDR) is defined to BE that
|
||||
// link-layer address (EN 302 636-4-1 clause 9.5.1). `station_type` is the
|
||||
// 5-bit ITS-S type from the same clause (5 = passengerCar) and gets packed
|
||||
// into GN_ADDR alongside the address.
|
||||
//
|
||||
// `latitude_tenmicrodeg`/`longitude_tenmicrodeg` go into the Source Long
|
||||
// Position Vector (clause 9.5.2) as plain 32-bit signed big-endian fields -
|
||||
// NOT UPER bit-packed like the DENM payload's position fields, this is a
|
||||
// fixed-width binary protocol. Pass the SAME values you gave denm_encode's
|
||||
// eventPosition, so the GN-layer position and the DENM's own claimed
|
||||
// position agree.
|
||||
//
|
||||
// Deliberate simplification: real DENM dissemination normally uses
|
||||
// GeoBroadcast (GBC, HeaderType=4) so RSUs/OBUs can forward it across an
|
||||
// area - that needs a sequence number + geo-area fields this skeleton
|
||||
// doesn't build yet. Single-hop broadcast is simpler and is the
|
||||
// best-tested decode path in the receiver firmware you already have
|
||||
// working (same extended header shape as CAM). Fine for a single-vehicle
|
||||
// beacon; revisit if you need real multi-hop forwarding later.
|
||||
//
|
||||
// `btp_dest_port` is the BTP-B destination port for the service being carried
|
||||
// (ETSI TS 103 248): 2001 = CAM, 2002 = DENM, 2003 = MAPEM, 2004 = SPATEM, ...
|
||||
//
|
||||
// Returns bytes written, or -1 if out buffer too small.
|
||||
int geonet_wrap_shb(const uint8_t *its_payload, int its_len,
|
||||
const uint8_t mac[6], uint8_t station_type,
|
||||
int32_t latitude_tenmicrodeg, int32_t longitude_tenmicrodeg,
|
||||
uint16_t btp_dest_port,
|
||||
uint8_t *out, size_t out_len);
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,262 @@
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "driver/gpio.h"
|
||||
#include "esp_wifi.h"
|
||||
#include "esp_event.h"
|
||||
#include "esp_netif.h"
|
||||
#include "nvs_flash.h"
|
||||
#include "esp_log.h"
|
||||
#include "hal/modem_syscon_ll.h" // modem_syscon_ll_enable_fe_40m_clock() - see initialize_wifi
|
||||
#include "denm.h"
|
||||
#include "cam.h"
|
||||
#include "geonet.h"
|
||||
#include "dot11p.h"
|
||||
#include "tx_custom.h"
|
||||
|
||||
static const char *TAG = "obu-tx";
|
||||
|
||||
// CAM beacon: transmit a Cooperative Awareness Message every TX_INTERVAL_MS,
|
||||
// unconditionally (no hazard-light gating - CAM is a continuous beacon, unlike
|
||||
// the event-triggered DENM). Matches the working Rust reference
|
||||
// (esp32-c_its-companion, feat/tx-cam), which beacons CAM on 5900 MHz.
|
||||
|
||||
// ISOLATION TEST for whether tx_custom.c is the blocker.
|
||||
// 1 = transmit via the STANDARD, well-tested esp_wifi_80211_tx() using a
|
||||
// plain (non-QoS) Data frame, which that API accepts. This path is known
|
||||
// to actually key the PA. If the sniffer sees frames with this = 1 but
|
||||
// not with = 0, then tx_custom.c (its reverse-engineered driver-struct
|
||||
// offsets) is the problem, not the RF/channel/regulatory setup.
|
||||
// 0 = original path: QoS Data frame via esp_wifi_80211_tx_custom().
|
||||
// Non-QoS Data is non-standard for ITS-G5, but this is purely a "does any RF
|
||||
// leave the chip" test - your capture-all sniffer logs it regardless.
|
||||
//
|
||||
// A/B TEST for the bursty-SDR symptom. Console is stable and tx_custom returns
|
||||
// OK every second, but the SDR only sees sporadic bursts - the fingerprint of
|
||||
// tx_custom.c's reverse-engineered driver-struct offsets not matching THIS IDF
|
||||
// (v5.5.4) as opposed to the reference's bundled IDF. Setting this to 1 routes
|
||||
// TX through the official, well-tested esp_wifi_80211_tx() (non-QoS Data), which
|
||||
// uses NO reverse-engineered structs. If the SDR becomes a steady 1 Hz with
|
||||
// this = 1, tx_custom's struct layout is confirmed as the culprit.
|
||||
#define USE_STANDARD_TX 1
|
||||
|
||||
// Target frequency: 5900 MHz (ITS-G5 G5-CCH, channel 180). This is what the
|
||||
// working Rust reference transmits on, proving the C5 PA reaches it despite the
|
||||
// 5885 datasheet max. The reference sets band-mode 5G, then phy_11p_set +
|
||||
// phy_change_channel(5900) directly - it does NOT call esp_wifi_set_channel at
|
||||
// all, so we don't either (channel 180 isn't a normal Wi-Fi channel anyway).
|
||||
#define TX_FREQ_MHZ 5900
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// ---- CAM beacon profile ----
|
||||
#define STATION_ID 0x0BADC0DE // placeholder 32-bit station id - pick your own
|
||||
#define STATION_TYPE 5 // passengerCar (TS 102 894-2 StationType)
|
||||
#define VEHICLE_LENGTH_DM 40 // VehicleLengthValue, 10cm steps (4.0 m)
|
||||
#define VEHICLE_WIDTH_DM 18 // VehicleWidth, 10cm steps (1.8 m)
|
||||
#define BTP_PORT_CAM 2001 // BTP-B destination port for CAM (ETSI TS 103 248)
|
||||
#define TX_INTERVAL_MS 1000 // CAM beacon period (1 Hz; ITS allows 1-10 Hz)
|
||||
|
||||
// Bench location, hardcoded since there's no GNSS module wired in yet and
|
||||
// the unit is genuinely stationary here: 53°33'16.8"N 10°01'20.6"E, in
|
||||
// 1/10-microdegree units (decimal_degrees * 10,000,000). Replace with real
|
||||
// GNSS output once you have a fix source; until then this beats 0/0
|
||||
// ("Null Island"), which is an obvious placeholder-tell on any map.
|
||||
#define BENCH_LATITUDE_TENMICRODEG 535546667
|
||||
#define BENCH_LONGITUDE_TENMICRODEG 100223889
|
||||
|
||||
// Single source of truth for the pseudonym/link-layer address: used both as
|
||||
// the 802.11 source MAC (Addr2) and as GN_ADDR's MID field, since the GN
|
||||
// spec defines those as being the same address. Locally-administered bit
|
||||
// set (0x02) per normal MAC convention. Fixed/non-rotating for now - real
|
||||
// stacks rotate this every 5-15 min for privacy.
|
||||
static const uint8_t pseudonym_mac[6] = {0x02, 0x00, 0x00, 0x00, 0x00, 0x01};
|
||||
|
||||
// Undocumented libphy.a calls that push the radio into 802.11p OCB mode on
|
||||
// the 5.9 GHz ITS-G5 band. See docs/04-transmit-setup.md for source + what
|
||||
// to do if the linker can't find these symbols in your ESP-IDF version.
|
||||
extern void phy_11p_set(int enable, int unused);
|
||||
extern void phy_change_channel(int freq_mhz, int bw_mode, int sec_chan_offset, int unused);
|
||||
|
||||
static void send_cam(void)
|
||||
{
|
||||
// GenerationDeltaTime is TimestampIts mod 65536 (ms). No RTC/GNSS time here,
|
||||
// so use a free-running ms counter that advances one beacon-interval per
|
||||
// send. It wraps at 65536, which is exactly the field's defined behaviour.
|
||||
static uint16_t gen_delta = 0;
|
||||
|
||||
uint8_t frame[300];
|
||||
cam_fields_t fields = {
|
||||
.station_id = STATION_ID,
|
||||
.station_type = STATION_TYPE,
|
||||
.generation_delta_time = gen_delta,
|
||||
.latitude_tenmicrodeg = BENCH_LATITUDE_TENMICRODEG,
|
||||
.longitude_tenmicrodeg = BENCH_LONGITUDE_TENMICRODEG,
|
||||
.speed_cm_s = 0, // stationary
|
||||
.heading_ddeg = 3601, // HeadingValue unavailable (no heading source)
|
||||
.vehicle_length_dm = VEHICLE_LENGTH_DM,
|
||||
.vehicle_width_dm = VEHICLE_WIDTH_DM,
|
||||
};
|
||||
gen_delta += TX_INTERVAL_MS;
|
||||
|
||||
uint8_t cam_payload[96];
|
||||
int cam_len = cam_encode(&fields, cam_payload, sizeof(cam_payload));
|
||||
|
||||
uint8_t gn_payload[160];
|
||||
int gn_len = geonet_wrap_shb(cam_payload, cam_len, pseudonym_mac, STATION_TYPE,
|
||||
BENCH_LATITUDE_TENMICRODEG, BENCH_LONGITUDE_TENMICRODEG,
|
||||
BTP_PORT_CAM, gn_payload, sizeof(gn_payload));
|
||||
|
||||
// qos=false for the standard-TX path (esp_wifi_80211_tx accepts only non-QoS
|
||||
// Data - which is exactly what the Rust reference transmits); qos=true would
|
||||
// be a real ITS-G5 QoS Data frame for the tx_custom path.
|
||||
int frame_len = dot11p_build_frame(gn_payload, gn_len, pseudonym_mac, frame, sizeof(frame),
|
||||
USE_STANDARD_TX ? false : true);
|
||||
|
||||
// PHY/OCB/channel is configured ONCE at boot in app_main and left alone,
|
||||
// matching the working Rust reference (band-mode 5G + phy_11p_set +
|
||||
// phy_change_channel(5900), set once).
|
||||
|
||||
if (frame_len > 0) {
|
||||
#if USE_STANDARD_TX
|
||||
// Standard, well-tested raw-TX API with a non-QoS Data frame - the same
|
||||
// transmit path the Rust reference uses (esp-radio send_raw_frame wraps
|
||||
// esp_wifi_80211_tx). err 258 ("unsupport QoS frame type") would mean the
|
||||
// frame wasn't built as non-QoS.
|
||||
esp_err_t err = esp_wifi_80211_tx(WIFI_IF_STA, frame, frame_len, true);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "esp_wifi_80211_tx (standard) failed: %d", err);
|
||||
} else {
|
||||
ESP_LOGI(TAG, "CAM sent via STANDARD tx (%d bytes) @ %d MHz genDeltaT=%u", frame_len, TX_FREQ_MHZ, gen_delta);
|
||||
}
|
||||
#else
|
||||
// tx_custom path: submits to the driver's internal HMAC TX path,
|
||||
// bypassing the QoS-frame gate. 11A legacy OFDM, 12M rate.
|
||||
wifi_tx_rate_config_t tx_rate_cfg = {
|
||||
.phymode = WIFI_PHY_MODE_11A,
|
||||
.rate = WIFI_PHY_RATE_12M,
|
||||
.ersu = false,
|
||||
.dcm = false,
|
||||
};
|
||||
esp_err_t err = esp_wifi_80211_tx_custom(WIFI_IF_STA, frame, frame_len, true,
|
||||
&tx_rate_cfg, WIFI_BAND_5G, WIFI_BW20);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "esp_wifi_80211_tx_custom failed: %d", err);
|
||||
} else {
|
||||
ESP_LOGI(TAG, "CAM sent (%d bytes) @ %d MHz genDeltaT=%u", frame_len, TX_FREQ_MHZ, gen_delta);
|
||||
}
|
||||
#endif
|
||||
} else {
|
||||
ESP_LOGE(TAG, "CAM frame build failed (cam_len=%d gn_len=%d)", cam_len, gn_len);
|
||||
}
|
||||
}
|
||||
|
||||
static void tx_task(void *arg)
|
||||
{
|
||||
while (1) {
|
||||
// CAM is a continuous beacon - send every interval, unconditionally.
|
||||
send_cam();
|
||||
vTaskDelay(pdMS_TO_TICKS(TX_INTERVAL_MS));
|
||||
}
|
||||
}
|
||||
|
||||
void app_main(void)
|
||||
{
|
||||
ESP_ERROR_CHECK(nvs_flash_init());
|
||||
ESP_ERROR_CHECK(esp_netif_init());
|
||||
ESP_ERROR_CHECK(esp_event_loop_create_default());
|
||||
|
||||
// Enable the modem FRONT-END 40 MHz clock BEFORE esp_wifi_init(). This is
|
||||
// the one step the proven-working receiver firmware
|
||||
// (its-g5-receiver-firmware_txenabled, main/main.c -> initialize_wifi())
|
||||
// performs that this OBU was missing. Without the FE clock enabled the
|
||||
// 5 GHz front-end / transmit chain is not fully clocked - which matches the
|
||||
// exact symptom here: the radio calibrates (boot RF ping) and receives
|
||||
// fine, but data frames are accepted by the API and never actually key the
|
||||
// PA. This is a low-level modem_syscon register write via the HAL LL layer,
|
||||
// copied verbatim from the reference firmware.
|
||||
modem_syscon_ll_enable_fe_40m_clock(&MODEM_SYSCON, 1);
|
||||
|
||||
wifi_init_config_t wifi_cfg = WIFI_INIT_CONFIG_DEFAULT();
|
||||
ESP_ERROR_CHECK(esp_wifi_init(&wifi_cfg));
|
||||
ESP_ERROR_CHECK(esp_wifi_set_storage(WIFI_STORAGE_RAM)); // match reference initialize_wifi()
|
||||
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));
|
||||
ESP_ERROR_CHECK(esp_wifi_start());
|
||||
|
||||
// ---- Regulatory / TX-authorization override -----------------------------
|
||||
// THE fix for "RX works but TX is silent". By default the driver uses
|
||||
// WIFI_COUNTRY_POLICY_AUTO, whose 5 GHz regulatory table does NOT authorize
|
||||
// transmit on the 5.9 GHz ITS band (and treats DFS channels as no-IR /
|
||||
// radar-gated). Receiving is never gated - which is exactly why the sniffer
|
||||
// hears traffic but our own frames never key the PA, and why the only RF
|
||||
// seen from this board is the uninhibited PHY-calibration burst at boot.
|
||||
//
|
||||
// Switching to WIFI_COUNTRY_POLICY_MANUAL with an explicit 5 GHz channel
|
||||
// mask (wifi_5g_channel_mask, which only takes effect under manual policy)
|
||||
// tells the driver these channels are permitted and lifts the transmit
|
||||
// gate. WIFI_CHANNEL_177 (BIT(28)) = 5885 MHz; we enable the full 5 GHz set
|
||||
// (bits 1..28) so both the primer channel and the target are authorized.
|
||||
// Manual policy = the operator asserts regulatory responsibility, which is
|
||||
// appropriate for licensed/university research on the ITS band.
|
||||
wifi_country_t ctry = {
|
||||
.cc = "US", // nominal under manual policy
|
||||
.schan = 1,
|
||||
.nchan = 11,
|
||||
.policy = WIFI_COUNTRY_POLICY_MANUAL,
|
||||
.wifi_5g_channel_mask = 0x1FFFFFFE, // all 5 GHz channels, bits 1..28 (incl. 140 and 177)
|
||||
};
|
||||
esp_err_t ctry_err = esp_wifi_set_country(&ctry);
|
||||
if (ctry_err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "esp_wifi_set_country(MANUAL) failed: %d (continuing)", ctry_err);
|
||||
}
|
||||
// Ensure the PA runs at full configured power (not a reduced regulatory
|
||||
// default). Units are 0.25 dBm; 80 = 20 dBm.
|
||||
esp_wifi_set_max_tx_power(80);
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
// Force the dual-band C5 onto its 5 GHz PHY. This MUST be called after
|
||||
// esp_wifi_start() - calling it before returns ESP_ERR_WIFI_NOT_STARTED
|
||||
// (0x3002 / 12290). Locking the band to 5G explicitly keeps the driver
|
||||
// from ever falling back to 2.4 GHz ch1 (the old "stuck at primary=1"
|
||||
// symptom), which would key the wrong PHY and make us inaudible to a
|
||||
// 5.9 GHz sniffer. Valid 5 GHz channels on the C5 are 36..177. Not
|
||||
// ESP_ERROR_CHECK'd: log and continue if a given IDF build differs.
|
||||
esp_err_t band_err = esp_wifi_set_band_mode(WIFI_BAND_MODE_5G_ONLY);
|
||||
if (band_err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "esp_wifi_set_band_mode(5G_ONLY) failed: %d (continuing)", band_err);
|
||||
}
|
||||
|
||||
// Disable Wi-Fi power save. An unassociated STA with the default
|
||||
// WIFI_PS_MIN_MODEM power save sleeps its radio between beacons it will
|
||||
// never receive (we're not joined to any AP), and drops outbound raw
|
||||
// frames while asleep - the classic "esp_wifi_80211_tx returns OK but
|
||||
// nothing goes on air". Must be called after esp_wifi_start().
|
||||
ESP_ERROR_CHECK(esp_wifi_set_ps(WIFI_PS_NONE));
|
||||
|
||||
// Enable promiscuous mode. This is the single most important change: our
|
||||
// *receiver* firmware (V2X2MAP) - which demonstrably works at 5.9 GHz,
|
||||
// 13k+ frames captured - runs promiscuous, and ESP-IDF documents that the
|
||||
// raw-frame TX path only actually emits when the MAC is in promiscuous
|
||||
// mode or associated to an AP. Plain STA (what this firmware used before)
|
||||
// is neither, so frames were being accepted by the API and then dropped
|
||||
// by the driver. Putting the OBU in the same radio state as the working
|
||||
// sniffer, then injecting, is the whole fix. Must be after start.
|
||||
ESP_ERROR_CHECK(esp_wifi_set_promiscuous(true));
|
||||
|
||||
// Force 802.11p OCB mode on the ITS-G5 channel, exactly like the working
|
||||
// Rust reference (esp32-c_its-companion, src/radio.rs setup_wifi_sniffer):
|
||||
// enable 802.11p, then jump straight to the target frequency. With band-mode
|
||||
// already locked to 5 GHz above, NO esp_wifi_set_channel priming is needed -
|
||||
// the reference doesn't call it, and channel 180 (5900 MHz) isn't a normal
|
||||
// Wi-Fi channel anyway. phy_change_channel takes the frequency in MHz.
|
||||
ESP_LOGI(TAG, "about to call phy_11p_set...");
|
||||
phy_11p_set(1, 0);
|
||||
ESP_LOGI(TAG, "phy_11p_set returned, about to call phy_change_channel(%d)...", TX_FREQ_MHZ);
|
||||
phy_change_channel(TX_FREQ_MHZ, 1, 0, 0);
|
||||
ESP_LOGI(TAG, "phy_change_channel returned");
|
||||
|
||||
ESP_LOGW(TAG, "OCB @ %d MHz - CAM beacon armed, transmitting every %d ms",
|
||||
TX_FREQ_MHZ, TX_INTERVAL_MS);
|
||||
|
||||
xTaskCreate(tx_task, "tx_task", 4096, NULL, 5, NULL);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
// Copied verbatim (no logic changes) from opentrafficmap/its-g5-receiver-firmware_txenabled,
|
||||
// main/tx_custom.c (https://codeberg.org/opentrafficmap/its-g5-receiver-firmware_txenabled),
|
||||
// same authors as the receiver firmware (V2X2MAP) already used on the RX side of this
|
||||
// project. Same chip (ESP32-C5), same class of problem (getting a raw 802.11 frame past
|
||||
// esp_wifi_80211_tx()'s built-in frame-type gate), and a proven-different approach from our
|
||||
// own abandoned main/wifi_patches.c attempt - see docs/04-transmit-setup.md for why that one
|
||||
// didn't work and why this one is expected to.
|
||||
//
|
||||
// WHAT THIS DOES DIFFERENTLY FROM esp_wifi_80211_tx(): it doesn't call the public API at all.
|
||||
// It reaches one layer deeper into the closed WiFi driver - ic_ebuf_alloc() (allocates an
|
||||
// internal driver buffer), ieee80211_post_hmac_tx() (submits that buffer straight to the MAC
|
||||
// for transmission) - and never goes through the code path that contains the QoS-frame-type
|
||||
// sanity check that was rejecting us. Notice line "esp_err_t result = 0;//ieee80211_raw_frame_
|
||||
// sanity_check(...)" below: the upstream authors don't override that check (like our old
|
||||
// wifi_patches.c tried to), they just never call the function that calls it.
|
||||
//
|
||||
// REAL RISK, carried over from upstream, not introduced by us: this skips ALL frame-type and
|
||||
// sanity validation, same caveat as our old override attempt. A malformed frame from a bug
|
||||
// elsewhere in our own code could behave worse (silent corruption, crash) than a clean
|
||||
// rejection.
|
||||
//
|
||||
// UNVERIFIED FOR OUR EXACT TOOLCHAIN - things worth checking before trusting this blindly:
|
||||
// 1. The symbols this depends on (ieee80211_post_hmac_tx, ic_ebuf_alloc, ic_get_default_sched,
|
||||
// g_osi_funcs_p, g_wifi_global_lock) are undocumented/internal. We confirmed via `nm`
|
||||
// earlier that ieee80211_raw_frame_sanity_check exists in OUR esp32c5/IDF libnet80211.a -
|
||||
// we have NOT yet independently confirmed these other four/five symbols exist in our
|
||||
// exact ESP-IDF version (as opposed to whatever version the upstream repo's pinned
|
||||
// esp-idf submodule uses). If the linker can't find one of these, that's the first thing
|
||||
// to check - see docs/04-transmit-setup.md for the nm command.
|
||||
// 2. x_eb_txdesc_t / x_middle_data_t / x_ebuf_t below are REVERSE-ENGINEERED struct layouts
|
||||
// of closed-source internal WiFi driver types, pinned only by a sizeof() static_assert -
|
||||
// that assert catches a total-size mismatch but NOT a field-order/semantic mismatch if a
|
||||
// different IDF version shuffled internal fields while keeping the same total size. If our
|
||||
// ESP-IDF version differs meaningfully from upstream's, this could compile and link fine
|
||||
// but write to the wrong offsets internally. Worth checking `idf.py --version` against
|
||||
// whatever esp-idf commit opentrafficmap's repo has pinned as a submodule, as a rough
|
||||
// compatibility signal (not a guarantee either way).
|
||||
#include "esp_private/wifi_os_adapter.h"
|
||||
#include "esp_wifi.h"
|
||||
|
||||
#include "tx_custom.h"
|
||||
|
||||
esp_err_t ieee80211_raw_frame_sanity_check(wifi_interface_t ifx, const void *buffer, int32_t len, bool en_sys_seq);
|
||||
esp_err_t ieee80211_post_hmac_tx(void *ebuf);
|
||||
void *ic_ebuf_alloc(const void *packet, uint32_t unknown, uint32_t len);
|
||||
void *ic_get_default_sched(void);
|
||||
|
||||
extern wifi_osi_funcs_t *g_osi_funcs_p;
|
||||
extern void *g_wifi_global_lock;
|
||||
|
||||
typedef struct x_eb_txdesc
|
||||
{
|
||||
uint32_t flags;
|
||||
uint32_t field_4;
|
||||
uint32_t field_8;
|
||||
uint8_t rate;
|
||||
uint8_t field_d;
|
||||
uint8_t field_e;
|
||||
uint8_t field_f;
|
||||
uint32_t field_10;
|
||||
uint32_t field_14;
|
||||
uint32_t timestamp;
|
||||
void* sched;
|
||||
uint32_t field_20;
|
||||
uint32_t field_24;
|
||||
uint32_t field_28;
|
||||
union {
|
||||
uint32_t field_2c_32;
|
||||
struct {
|
||||
uint8_t field_2c;
|
||||
uint8_t field_2d;
|
||||
uint8_t field_2e;
|
||||
uint8_t field_2f;
|
||||
};
|
||||
};
|
||||
union {
|
||||
uint32_t field_30_32;
|
||||
struct {
|
||||
uint8_t field_30;
|
||||
uint8_t field_31;
|
||||
uint8_t field_32;
|
||||
uint8_t field_33;
|
||||
};
|
||||
};
|
||||
uint32_t field_34;
|
||||
uint32_t field_38;
|
||||
uint32_t field_3c;
|
||||
uint32_t field_40;
|
||||
uint32_t field_44;
|
||||
} x_eb_txdesc_t;
|
||||
static_assert(sizeof(x_eb_txdesc_t) == 0x48);
|
||||
|
||||
typedef struct x_middle_data
|
||||
{
|
||||
uint32_t field_40;
|
||||
uint8_t* buf;
|
||||
uint32_t field_48;
|
||||
uint32_t field_4c;
|
||||
} x_middle_data_t;
|
||||
static_assert(sizeof(x_middle_data_t) == 0x10);
|
||||
|
||||
typedef struct x_ebuf
|
||||
{
|
||||
uint32_t field_0;
|
||||
x_middle_data_t* ds_head;
|
||||
x_middle_data_t* ds_tail;
|
||||
uint16_t field_c;
|
||||
uint16_t field_e;
|
||||
uint32_t extra_data_start;
|
||||
uint16_t header_length;
|
||||
uint32_t data_length;
|
||||
uint16_t field_1c;
|
||||
uint8_t alloc_type;
|
||||
uint8_t field_1f;
|
||||
uint32_t field_20;
|
||||
uint8_t field_24;
|
||||
uint8_t field_25;
|
||||
uint8_t field_26;
|
||||
uint8_t field_27;
|
||||
uint32_t field_28;
|
||||
uint8_t field_2c;
|
||||
uint32_t field_30;
|
||||
uint32_t next_free;
|
||||
x_eb_txdesc_t* txdesc;
|
||||
uint16_t field_3c;
|
||||
uint8_t field_3e;
|
||||
uint8_t field_3f;
|
||||
} x_ebuf_t;
|
||||
static_assert(sizeof(x_ebuf_t) == 0x40);
|
||||
|
||||
esp_err_t esp_wifi_80211_tx_custom(wifi_interface_t ifx, const void *buffer, int32_t len, bool en_sys_seq, wifi_tx_rate_config_t *tx_rate_config, wifi_band_t band, wifi_bandwidth_t bw)
|
||||
{
|
||||
esp_err_t result = 0;//ieee80211_raw_frame_sanity_check(ifx, buffer, len, en_sys_seq);
|
||||
|
||||
if (!result)
|
||||
{
|
||||
g_osi_funcs_p->_mutex_lock(g_wifi_global_lock);
|
||||
x_ebuf_t* eb = ic_ebuf_alloc(buffer, 1, len);
|
||||
|
||||
if (eb)
|
||||
{
|
||||
//eb->data_length = len - 0x1a;
|
||||
eb->data_length = 0;
|
||||
x_eb_txdesc_t *txdesc_1 = eb->txdesc;
|
||||
//eb->header_length = 0x1a;
|
||||
eb->header_length = len;
|
||||
txdesc_1->flags |= 0x4000;
|
||||
txdesc_1->sched = ic_get_default_sched();
|
||||
wifi_phy_rate_t rate = tx_rate_config->rate;
|
||||
x_eb_txdesc_t *txdesc = eb->txdesc;
|
||||
|
||||
if (rate)
|
||||
txdesc->rate = (char)rate;
|
||||
else if (band != WIFI_BAND_5G)
|
||||
txdesc->rate = 0;
|
||||
else
|
||||
txdesc->rate = (char)WIFI_PHY_RATE_6M;
|
||||
|
||||
wifi_phy_mode_t phymode = tx_rate_config->phymode;
|
||||
|
||||
if (phymode == WIFI_PHY_MODE_HE20)
|
||||
{
|
||||
txdesc->flags |= 0x80000000;
|
||||
txdesc->field_2f =
|
||||
(char)((((uint32_t)tx_rate_config->ersu + 6) & 0xf) << 3)
|
||||
| (txdesc->field_2f & 0x87);
|
||||
|
||||
if ((uint32_t)tx_rate_config->dcm)
|
||||
txdesc->field_31 |= 0x80;
|
||||
}
|
||||
else if (phymode == WIFI_PHY_MODE_VHT20)
|
||||
txdesc->flags |= 0x1000000;
|
||||
|
||||
// No idea if this is correct, but this is what the original code does...
|
||||
uint32_t bw_is_bw40 = bw == WIFI_BW40;
|
||||
txdesc->field_8 = (bw_is_bw40 << 0xf) | (txdesc->field_8 & 0xffff7fff);
|
||||
|
||||
if (en_sys_seq)
|
||||
txdesc->flags |= 1;
|
||||
|
||||
txdesc->field_10 =
|
||||
(txdesc->field_10 & 0xfff3ffff) | ((ifx & WIFI_IF_MAX) << 0x12);
|
||||
txdesc->field_14 = 0x100;
|
||||
|
||||
ieee80211_post_hmac_tx(eb);
|
||||
g_osi_funcs_p->_mutex_unlock(g_wifi_global_lock);
|
||||
}
|
||||
else
|
||||
{
|
||||
result = ESP_ERR_NO_MEM;
|
||||
g_osi_funcs_p->_mutex_unlock(g_wifi_global_lock);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// Copied from opentrafficmap/its-g5-receiver-firmware_txenabled, main/tx_custom.h.
|
||||
// See tx_custom.c for what this does and why we pulled it in.
|
||||
#pragma once
|
||||
|
||||
#include "esp_wifi.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
esp_err_t esp_wifi_80211_tx_custom(wifi_interface_t ifx, const void *buffer, int32_t len, bool en_sys_seq, wifi_tx_rate_config_t *tx_rate_config, wifi_band_t band, wifi_bandwidth_t bw);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,49 @@
|
||||
#include <stdint.h>
|
||||
|
||||
// RETIRED - no longer built (removed from main/CMakeLists.txt SRCS), kept only
|
||||
// for history. Confirmed not to work: linked cleanly with -Wl,-zmuldefs but
|
||||
// the QoS-frame rejection persisted identically. Also turned out to be based
|
||||
// on the wrong function signature - the real ieee80211_raw_frame_sanity_check
|
||||
// takes (wifi_interface_t ifx, const void *buffer, int32_t len, bool
|
||||
// en_sys_seq), confirmed from opentrafficmap/its-g5-receiver-firmware_txenabled's
|
||||
// main/tx_custom.c, not the 3x int32_t guessed below. Superseded by
|
||||
// tx_custom.c, which bypasses esp_wifi_80211_tx() (and the function that
|
||||
// calls this check) entirely instead of trying to neutralize the check.
|
||||
// See docs/04-transmit-setup.md.
|
||||
|
||||
// Overrides a function inside the closed-source WiFi library that gates
|
||||
// which raw 802.11 frame types esp_wifi_80211_tx() will accept. By default
|
||||
// it only allows beacon/probe-request/probe-response/action and non-QoS
|
||||
// data frames - it explicitly rejects QoS Data (subtype 8), which is what
|
||||
// real ITS-G5/802.11p hardware actually transmits and expects.
|
||||
//
|
||||
// This is the same technique used by ESP32 WiFi-security tools (deauther/
|
||||
// injection projects) to unlock raw frame injection: define a function with
|
||||
// the exact same name as the library's gate, and link with -Wl,-zmuldefs
|
||||
// (see CMakeLists.txt) so the linker accepts having two definitions of the
|
||||
// same symbol instead of erroring with "multiple definition of
|
||||
// `ieee80211_raw_frame_sanity_check'" - and takes this one instead of the
|
||||
// library's.
|
||||
//
|
||||
// Confirmed present for THIS target/IDF version: `nm` on
|
||||
// components/esp_wifi/lib/esp32c5/libnet80211.a (IDF v5.5.4) shows
|
||||
// `ieee80211_raw_frame_sanity_check` as a normal (non-weak) global text
|
||||
// symbol in ieee80211_node.o. The exact argument count/meaning is
|
||||
// reverse-engineered from community ESP32 (Xtensa) deauther tools, not
|
||||
// confirmed byte-for-byte against esp32c5's actual implementation - if
|
||||
// frames still get rejected, or this crashes, the real signature may take
|
||||
// different arguments than assumed here.
|
||||
//
|
||||
// Real risk, not just an inconvenience: this disables ALL sanity checking
|
||||
// on raw frames going through esp_wifi_80211_tx(), not just the QoS-type
|
||||
// gate. Whatever else that check validates (frame length bounds, etc.) is
|
||||
// now unchecked. Malformed frames from a bug elsewhere in this codebase
|
||||
// could behave worse (silent corruption, crash) than they would have with
|
||||
// the check in place, where they'd have just been rejected cleanly.
|
||||
int ieee80211_raw_frame_sanity_check(int32_t arg1, int32_t arg2, int32_t arg3)
|
||||
{
|
||||
(void)arg1;
|
||||
(void)arg2;
|
||||
(void)arg3;
|
||||
return 0; // 0 = "frame is sane" - always pass
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
CONFIG_IDF_TARGET="esp32c5"
|
||||
@@ -0,0 +1,106 @@
|
||||
# obu-firmware — setup & flashing notes
|
||||
|
||||
## Two toolchains - use a dedicated terminal for each
|
||||
|
||||
This project builds against the receiver-firmware's pinned ESP-IDF **6.1**.
|
||||
The separate `obu-cam-transmistter` project builds against the global ESP-IDF
|
||||
**5.5.4**. Exporting both in one PowerShell window fails: the second export
|
||||
inherits the first's `IDF_PYTHON_ENV_PATH` and reports every Python dependency
|
||||
as unmet. Don't run `install.bat` to "fix" that - open a fresh terminal, or
|
||||
clear the state with `$env:IDF_PYTHON_ENV_PATH = $null; $env:IDF_PATH = $null`.
|
||||
|
||||
## Every new PowerShell session
|
||||
|
||||
Activate the toolchain (obu-firmware has no esp-idf of its own — reuse the
|
||||
receiver firmware's already-installed checkout):
|
||||
|
||||
```powershell
|
||||
Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass
|
||||
C:\Users\Ashin\Documents\micrOBU_workspace\its-g5-receiver-firmware\esp-idf\export.ps1
|
||||
idf.py --version
|
||||
```
|
||||
|
||||
## Build & flash
|
||||
|
||||
```powershell
|
||||
cd C:\Users\Ashin\AndroidStudioProjects\MicrOBU\obu-firmware
|
||||
idf.py set-target esp32c5 # only needed once per clean build folder
|
||||
idf.py build
|
||||
idf.py -p COM5 -b 921600 flash monitor
|
||||
```
|
||||
|
||||
Swap `COM5` for whatever port the ESP32-C5 enumerates as (Device Manager →
|
||||
Ports). `monitor` opens the serial console after flashing — `Ctrl+]` to exit.
|
||||
|
||||
## If the build fails
|
||||
|
||||
- **"includes X.h, provided by Y component(s)... not in the requirements
|
||||
list"** — IDF 5.x split the old monolithic `driver` component apart
|
||||
(`esp_driver_gpio`, `esp_driver_uart`, etc.). Add the named component to
|
||||
`REQUIRES` in `main/CMakeLists.txt` and rebuild. Already fixed once for
|
||||
`esp_driver_gpio` + `esp_driver_uart` — if a new header comes up, same fix.
|
||||
- Otherwise, start clean before re-building:
|
||||
```powershell
|
||||
idf.py fullclean
|
||||
idf.py build
|
||||
```
|
||||
|
||||
## Connecting the phone (ESP32-C5-WIFI6-KIT)
|
||||
|
||||
The board has two USB-C ports — use the right one:
|
||||
|
||||
- **Native USB-C port** (labeled for JTAG/native USB, up to 12 Mbps) — this
|
||||
is where the phone plugs in via USB-OTG. The CAM serial link
|
||||
(`serial_link.c`) runs over the ESP32-C5's native USB Serial/JTAG
|
||||
peripheral on this port, enumerating as a CDC-ACM device under Espressif's
|
||||
VID/PID (0x303A/0x1001).
|
||||
- **UART-bridge port** (labeled for flashing) — this is what you use for
|
||||
`idf.py flash monitor` from your PC. Leave the phone unplugged from this
|
||||
one; it only carries `idf.py`'s flashing protocol and the ESP_LOG console.
|
||||
|
||||
The app recognizes the ESP32-C5's VID/PID via a custom probe table in
|
||||
`UsbSerialTransport.kt` (the default `usb-serial-for-android` prober doesn't
|
||||
know Espressif's device IDs). If the phone doesn't detect anything when
|
||||
plugged into the native port, first confirm with a tool like "USB Device
|
||||
Info" (or `adb shell dumpsys usb` from a PC) that Android sees a USB device
|
||||
at all — that isolates a bad/charge-only OTG cable from an app-side issue.
|
||||
|
||||
## Bring-up checklist (phone <-> ESP32-C5 link)
|
||||
|
||||
Work down this list — each step isolates the layer below it.
|
||||
|
||||
1. **Flash and install together.** `SERIAL_LINK_MAX_PAYLOAD` is 512 on both sides.
|
||||
A phone at 512 talking to firmware still at 160 (or vice versa) silently
|
||||
rejects every large frame at the `length exceeds max, resync` branch. Never
|
||||
update one side alone.
|
||||
2. **Does Android see the device at all?** Plug the phone into the **native**
|
||||
USB-C port, hit Connect, and read logcat for `UsbSerialTransport`. It logs
|
||||
every attached device *and* each device's interfaces. Empty list = cable /
|
||||
OTG / wrong port, below the app entirely.
|
||||
3. **Did the right interface get claimed?** The C5's USB Serial/JTAG is a
|
||||
composite device — expect CDC control (class 2) + CDC data (class 10) +
|
||||
vendor-specific JTAG (class 255) in that dump. Compare against the `ports=`
|
||||
count on the `matched device` line.
|
||||
4. **Is the link alive?** The firmware sends a STATUS heartbeat at 1 Hz
|
||||
regardless of radio traffic, and the app marks the link ERROR after ~3.5 s of
|
||||
silence. Connected-and-staying-connected means device→host actually works.
|
||||
5. **If it connects but no CAM_RX ever arrives** — suspect DTR. The app now
|
||||
asserts DTR/RTS on open (`openDevice()` in `UsbSerialTransport.kt`), because
|
||||
`CdcAcmSerialDriver` doesn't do it by default and the ESP32's USB Serial/JTAG
|
||||
endpoint may gate TX on the host opening the CDC line. **This is still
|
||||
unverified on real hardware** — test it both ways (with the `setDTR(true)`
|
||||
call and with it commented out) and record the answer in `serial_link.h`
|
||||
next to the VID/PID note, so nobody has to guess again.
|
||||
6. **Watch the counters, not just "Sent: N".** The CAM Pinger card shows
|
||||
consecutive write failures (phone side) and the firmware's tx-failure /
|
||||
oversize-drop / CRC-error totals from the heartbeat. A rising `tx fail` means
|
||||
CAMs reach the ESP32 but `esp_wifi_80211_tx` rejects them — a radio problem,
|
||||
not a link problem.
|
||||
|
||||
## Notes
|
||||
|
||||
- No `git submodule update` needed here — obu-firmware has no pinned
|
||||
submodule of its own, unlike its-g5-receiver-firmware.
|
||||
- Don't use the global "ESP-IDF 5.5 PowerShell" shortcut — always export from
|
||||
the receiver-firmware's pinned checkout, since this firmware's undocumented
|
||||
PHY/driver internals were verified against that specific build.
|
||||
+13
-2
@@ -1,3 +1,8 @@
|
||||
// NOT COMPILED - deliberately absent from main/CMakeLists.txt's SRCS. This firmware no longer
|
||||
// encodes CAM at all: the phone builds and UPER-encodes it and sends the bytes down serial_link,
|
||||
// and this side only GeoNetworking-wraps opaque payloads. The file is kept as the byte-exact
|
||||
// reference the Kotlin encoder (app CamUperCodec.kt) was ported from, so fixes must be applied
|
||||
// here too or the next person porting from it reintroduces the bug.
|
||||
#include "cam.h"
|
||||
#include <string.h>
|
||||
|
||||
@@ -108,8 +113,14 @@ int cam_encode(const cam_fields_t *f, uint8_t *buf, size_t buf_len)
|
||||
// CurvatureConfidence ENUM 8 values -> 3 bits
|
||||
bw_put_bits(&bw, 1023 - (uint32_t)(-1023), 11); // curvatureValue: unavailable(1023)
|
||||
bw_put_bits(&bw, 7, 3); // curvatureConfidence: unavailable(7)
|
||||
// CurvatureCalculationMode ENUM {yawRateUsed,yawRateNotUsed,unavailable} -> 2 bits
|
||||
bw_put_bits(&bw, 2, 2); // unavailable
|
||||
// CurvatureCalculationMode ENUM {yawRateUsed,yawRateNotUsed,unavailable, ...} - note the
|
||||
// extension marker: UPER encodes an extensible ENUMERATED as an extension bit followed by
|
||||
// the root-list index, so this is 1 + 2 = 3 bits, NOT 2. This file previously wrote only the
|
||||
// 2-bit index, which shifted yawRate and the entire low-frequency container one bit early for
|
||||
// any standards-compliant receiver. Harmless between this project's own encoder and decoder
|
||||
// (both had the same error); wrong against every third-party station.
|
||||
bw_put_bits(&bw, 0, 1); // extension bit: value is in the root list
|
||||
bw_put_bits(&bw, 2, 2); // unavailable(2)
|
||||
// YawRate: YawRateValue(-32766..32767)->16 (offset from -32766),
|
||||
// YawRateConfidence ENUM 8 values -> 3 bits
|
||||
bw_put_bits(&bw, 32767 - (uint32_t)(-32766), 16); // yawRateValue: unavailable(32767)
|
||||
|
||||
@@ -72,7 +72,18 @@ static bool send_frame(uint8_t type, const uint8_t *payload, int len)
|
||||
// so the shared buffer (and the four-part write) can't interleave between callers.
|
||||
static uint8_t s_crc_buf[3 + SERIAL_LINK_MAX_PAYLOAD];
|
||||
|
||||
if (s_tx_mutex && xSemaphoreTake(s_tx_mutex, pdMS_TO_TICKS(200)) != pdTRUE) {
|
||||
// No host on the other end: the TX buffer never drains, so every write below would block its
|
||||
// full timeout and this frame is going nowhere regardless. Bail before taking the mutex -
|
||||
// otherwise a burst of promiscuously-captured CAMs holds the lock for hundreds of ms each and
|
||||
// starves the heartbeat, which is exactly what "tx mutex timeout, dropping frame" was.
|
||||
if (!usb_serial_jtag_is_connected()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Timeout must exceed the worst-case hold below (4 writes x SERIAL_LINK_WRITE_TIMEOUT_MS),
|
||||
// or a legitimately slow-but-working host makes contending senders drop frames instead of
|
||||
// waiting their turn.
|
||||
if (s_tx_mutex && xSemaphoreTake(s_tx_mutex, pdMS_TO_TICKS(SERIAL_LINK_TX_LOCK_TIMEOUT_MS)) != pdTRUE) {
|
||||
ESP_LOGW(TAG, "send_frame: tx mutex timeout, dropping frame");
|
||||
return false;
|
||||
}
|
||||
@@ -87,9 +98,9 @@ static bool send_frame(uint8_t type, const uint8_t *payload, int len)
|
||||
// Four separate writes rather than one assembled buffer - simplest given payload is
|
||||
// already wherever the caller has it (avoids a second copy of up to 160 bytes).
|
||||
// usb_serial_jtag_write_bytes() blocks up to the given tick timeout if the host isn't
|
||||
// reading fast enough; 100ms is generous for a ~160-byte frame at USB full-speed and keeps
|
||||
// a wedged/disconnected host from hanging the radio TX/RX tasks indefinitely.
|
||||
const TickType_t write_timeout = pdMS_TO_TICKS(100);
|
||||
// reading fast enough; generous for a single frame at USB full-speed, and keeps a wedged
|
||||
// host from hanging the radio TX/RX tasks indefinitely.
|
||||
const TickType_t write_timeout = pdMS_TO_TICKS(SERIAL_LINK_WRITE_TIMEOUT_MS);
|
||||
int wrote = 0;
|
||||
wrote += usb_serial_jtag_write_bytes(sync, sizeof(sync), write_timeout);
|
||||
wrote += usb_serial_jtag_write_bytes(head, sizeof(head), write_timeout);
|
||||
|
||||
@@ -51,6 +51,13 @@
|
||||
// to SERIAL_LINK_MAX_PAYLOAD below.
|
||||
#define SERIAL_LINK_USB_BUF_SIZE 1024
|
||||
|
||||
// Per-write block ceiling, and the mutex acquire timeout that must comfortably exceed the
|
||||
// worst case of one frame (4 writes: sync, head, payload, crc). Keep that relationship if you
|
||||
// change either number - a lock timeout below the max hold turns normal contention into
|
||||
// dropped frames, which is how the heartbeat was being starved by forwarded CAM_RX traffic.
|
||||
#define SERIAL_LINK_WRITE_TIMEOUT_MS 100
|
||||
#define SERIAL_LINK_TX_LOCK_TIMEOUT_MS 600
|
||||
|
||||
// Max CAM payload this link will carry. MUST match SERIAL_LINK_MAX_PAYLOAD in the app's
|
||||
// SerialFrame.kt - a mismatch means every frame above the smaller of the two is rejected by that
|
||||
// side's "length exceeds max, resync" branch, silently.
|
||||
|
||||
Reference in New Issue
Block a user