3 Commits
Author SHA1 Message Date
Ashin WalpolaandClaude Opus 5 611f7b69eb ESP32 link indicators, trip export with V2X + RSSI, trip/CSV cleanup
UI:
- Top bar shows a USB glyph reflecting the serial link on the ESP32-C5 path,
  instead of a Wi-Fi glyph driven by an MQTT state that is permanently
  disconnected there.
- Recording screen's OBU stream row follows whichever transport the selected
  hardware uses. It previously read as offline throughout a recording that was
  actively beaconing CAMs.
- Live map uses distinct markers: a centred dot for own position (a fact about
  the viewer, not a tracked object) and a teardrop pin for remote stations,
  tinted by alert severity. Both were osmdroid's identical default pin before,
  and severity required tapping a marker to read its label.
- Sensor Monitor moves out of the bottom nav to Settings > Developer. A live
  phone-sensor feed is a bench tool; the Dashboard already reports GNSS/IMU
  health. Screen and route are unchanged, just not in the rider's way.

Detection engine on the ESP32 path:
- Seed our own StationID from the persisted value CamTransmitLoop transmits.
  It was null here (the CiT One learns it from v2x/rx/obu_gnss, which doesn't
  exist on this path), so the self-heard-TX filter never fired: the ESP32 runs
  promiscuous for raw TX to work at all, hears our own CAMs back off the air,
  and they were tracked as a remote station - a ghost vehicle on top of the ego
  position, fed to the engine as a collision partner for itself.

RSSI:
- The firmware has always sent per-frame RSSI in byte 0 of every CAM_RX frame;
  the app discarded it. Now carried on Cam, persisted per V2X message, shown per
  station in the received-CAM list, and exported. Null for own CAMs and the
  whole CiT One path, neither of which has a measurement.

Trips, CSV and export (schema v3 -> v4):
- trips.sessionId links a trip to the CSV session recorded alongside it. The two
  are written by independent subsystems that the Recording button happens to
  start together; without the link, deleting a trip orphaned its CSV forever.
  Timestamp matching was rejected - close recordings would delete the wrong file.
- Deleting a trip now removes the CSV file AND its sessions row, so it stops
  appearing in the Session Log pointing at nothing.
- Per-trip combined CSV export: GPS track, detected events, V2X messages (with
  RSSI) and the raw sensor samples in one file, keyed by a leading type column.
  One file rather than a zip of tables because the point of the export is
  correlating those streams, and splitting them pushes the join downstream.
- Export takes the Activity context. Sharing from the ViewModel's Application
  context threw AndroidRuntimeException on startActivity - this crashed on the
  first tap of the share button.

Trips recorded before v4 have a null sessionId, so their exports omit raw sensor
rows and their CSVs still need clearing by hand once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 15:47:54 +02:00
Ashin WalpolaandClaude Opus 5 528637dab6 Fix UPER encoding of CurvatureCalculationMode; verified on hardware
CurvatureCalculationMode is the one extensible ENUMERATED in CAM:
  ENUMERATED {yawRateUsed(0), yawRateNotUsed(1), unavailable(2), ...}
UPER encodes an extensible ENUMERATED as an extension bit followed by the root
index - 1 + 2 = 3 bits. All three of our encoders wrote only the 2-bit index,
shifting yawRate and the entire low-frequency container one bit early for any
standards-compliant receiver.

It went unnoticed because every end of this project shared the mistake: the
Kotlin codec was ported bit-for-bit from cam.c, so phone and ESP32 agreed
perfectly with each other and with nothing else. Confirmed against the ETSI
ASN.1 in the C-ITS-Parser checkout, where rasn marks this type - and only this
type - #[non_exhaustive].

Fixed in all three copies of the encoder (app CamUperCodec.kt,
obu-firmware/main/cam.c, obu-cam-transmistter/main/cam.c) plus the decoder,
which now rejects rather than misreads a set extension bit. Frame size is
unchanged at 43 bytes. Transmitter reflashed and the phone decodes its CAMs.

Also in this change:

- serial_link: skip send_frame entirely when no USB host is attached, and raise
  the tx mutex timeout above the worst-case hold. With the phone unplugged every
  write blocked its full timeout while holding the lock, so forwarded CAM_RX
  traffic starved the 1 Hz heartbeat - observed as "tx mutex timeout, dropping
  frame" on the console, and it would have tripped the phone's link watchdog.
  Verified gone on hardware.
- Log decoded and failed CAMs in CamUseCaseRepository. "The app shows nothing"
  had two indistinguishable causes; a silent `?: return` made this bug much
  harder to find than it needed to be.
- Remove the ESP32 send-only/send-and-receive toggle. Reception can't be
  disabled in firmware (raw TX only works while promiscuous), so it was an
  app-side filter pretending to be a radio control.
- V2X monitor follows the serial link state on the ESP32 path instead of MQTT,
  which is permanently disconnected there; CAM intake is gated on the link being
  up, and engine state is cleared when it drops.
- About screen: 0.5.0, Phase 03.
- Track obu-cam-transmistter, the bench CAM transmitter. Its cam.c is compiled
  (unlike obu-firmware's reference copy) and must stay bit-identical to the other
  two - this commit is what that coupling costs when it's broken.
- Document the two-toolchain split: this project builds on IDF 5.5.4, obu-firmware
  on the pinned 6.1. Exporting both in one shell fails confusingly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 14:50:35 +02:00
Ashin WalpolaandClaude Opus 5 f3ae81a8fe Phase 03: CAM decode coverage, real sensor data in TX, V2X monitor for ESP32 path
CAM codec:
- Stop rejecting CAMs carrying a specialVehicleContainer. It is declared last in
  CamParameters, after everything this decoder reads, so buses / emergency
  vehicles / road-works vehicles now decode for position and kinematics instead
  of being dropped outright
- Drop the lowFrequencyContainer parse - it extracted nothing into Cam, and its
  reads were only correct when no high-frequency optionals were present
- Document why the 7 optional-presence bits are consumed but not acted on: UPER
  writes a SEQUENCE's presence bitmap up front but each field's value in
  declaration order, and all seven are declared after yawRate
- Field widths and container ordering verified against the ETSI ASN.1 sources in
  the C-ITS-Parser checkout, not from memory

Transmit path:
- Own StationID is now a persisted random 32-bit value instead of a hardcoded 0.
  Receivers key on StationID to track a station across CAMs, so every unit
  broadcasting 0 made two MicrOBUs indistinguishable - including to this app's
  own detection engine
- Populate longitudinalAcceleration from successive GNSS speed samples. Not from
  the accelerometer: CAM wants signed along-track acceleration, and the raw
  sensor is device-frame with gravity in it. Null outside a usable sample gap
  rather than a fabricated value
- CAM pinger builds from live GNSS/IMU via PhoneCamBuilder instead of beaconing a
  hardcoded bench coordinate with speed and heading pinned to zero, so it now
  exercises the sensor pipeline and not just the wire. Sends nothing without a
  fix, and reports that rather than sitting at "Sent: 0"

V2X monitor:
- Received-CAM pane for the ESP32-C5 path, replacing the MQTT topic list that is
  permanently empty there. One row per station rather than per message - CAMs
  arrive at 1-10 Hz per station, so the pane is bounded by road users nearby, not
  by traffic rate. Nearest first, tinted by active alert level
- DENM hazard pins on the live map as a warning triangle, drawn above vehicle
  markers. CiT One path only: the ESP32 firmware forwards BTP-B port 2001 (CAM)
  and drops port 2002 before it reaches the phone

DenmParser uses tolerant field-name matching - the Use Case API's DENM JSON
schema is not yet confirmed against real payloads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:09:18 +02:00
52 changed files with 5123 additions and 169 deletions
@@ -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>
+24
View File
@@ -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>
+28 -7
View File
@@ -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 &amp; 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 &amp; consider it GmbH</string>
<!-- Phase A: Trips (bottom nav) -->
<string name="nav_trips">Fahrten</string>
+28 -7
View File
@@ -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 &amp; 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 &amp; consider it GmbH</string>
<!-- Phase A: Trip Recording (bottom nav) -->
<string name="nav_trips">Trips</string>
+9
View File
@@ -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)
+53
View File
@@ -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).
+8
View File
@@ -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
)
+137
View File
@@ -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);
}
+35
View File
@@ -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
+153
View File
@@ -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);
}
+67
View File
@@ -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
+57
View File
@@ -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);
}
+31
View File
@@ -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
+85
View File
@@ -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);
}
+44
View File
@@ -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
+262
View File
@@ -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);
}
+196
View File
@@ -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;
}
+15
View File
@@ -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
+49
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
CONFIG_IDF_TARGET="esp32c5"
+106
View File
@@ -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
View File
@@ -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)
+15 -4
View File
@@ -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);
+7
View File
@@ -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.