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>
This commit is contained in:
Ashin Walpola
2026-08-11 14:50:35 +02:00
co-authored by Claude Opus 5
parent f3ae81a8fe
commit 528637dab6
32 changed files with 3852 additions and 133 deletions
@@ -84,7 +84,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
@@ -253,8 +252,6 @@ class MainActivity : AppCompatActivity() {
onMqttPrefsChange = mqttViewModel::updatePrefs,
obuHardware = obuHardware,
onObuHardwareChange = mqttViewModel::setObuHardware,
espRxMode = espRxMode,
onEspRxModeChange = mqttViewModel::setEspRxMode,
onBack = { navController.popBackStack() },
)
}
@@ -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,26 @@ 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()
}
}
}
scope.launch {
obuHardwarePrefs.obuHardwareFlow.collect { currentHardware = it }
}
}
@@ -253,9 +270,25 @@ class CamUseCaseRepository @Inject constructor(
private fun handleCamFromSerial(payload: ByteArray) {
if (payload.isEmpty()) return
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())
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=${payload[0].toInt()}) - first bytes: ${camBytes.toHexPreview()}",
)
return
}
Log.d(TAG, "handleCamFromSerial: decoded station=${cam.stationId} " +
"lat=${cam.latitude} lon=${cam.longitude} speed=${cam.speedMps} rssi=${payload[0].toInt()}")
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 ""
}
@@ -5,7 +5,6 @@ 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
@@ -27,7 +26,6 @@ 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")
}
@@ -39,19 +37,6 @@ 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
}
suspend fun setEspRxMode(mode: EspRxMode) {
context.obuHardwareDataStore.edit { prefs -> prefs[Keys.ESP_RX_MODE] = mode.id }
}
/** 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]
@@ -1,24 +0,0 @@
package com.hawhamburg.micr0bu.data.transport
/**
* Whether the ESP32-C5 path (Phase 03) processes remote CAM traffic it receives, or only ever
* transmits the phone's own CAM.
*
* **Important nuance:** this does NOT physically disable the ESP32's radio receiver. The
* firmware's own promiscuous-mode setup (`main.c`, see comments there) is required for its raw
* 802.11p TX path to work at all — ESP-IDF only allows `esp_wifi_80211_tx()` to emit frames when
* the MAC is promiscuous or associated to an AP. So the ESP32 always physically receives and
* forwards CAM_RX frames over the serial link regardless of this setting; what this setting
* actually controls is purely on the phone side — whether [SEND_ONLY] mode ignores those
* incoming frames (see [com.hawhamburg.micr0bu.data.cam.CamUseCaseRepository]) instead of
* feeding them into the detection engine / UI. Useful for isolating the TX path during bench
* testing (e.g. with the [com.hawhamburg.micr0bu.service.CamPinger]) without nearby test traffic
* cluttering the use-case alerts or live map.
*/
enum class EspRxMode(val id: String) {
/** Transmit the phone's own CAM only; incoming CAM_RX frames from the ESP32 are discarded. */
SEND_ONLY("send_only"),
/** Normal full-duplex operation: transmit own CAM and process received CAM traffic. */
SEND_AND_RECEIVE("send_and_receive"),
}
@@ -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) }
@@ -247,7 +257,13 @@ 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
@@ -138,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()) {
@@ -175,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,
),
@@ -206,8 +221,8 @@ fun MqttTopicViewerScreen(
activeDenmUseCase = activeDenmUseCase,
useCaseAlerts = useCaseAlerts,
showDenmTrigger = obuHardware == ObuHardware.CIT_ONE,
showCamPinger = obuHardware == ObuHardware.ESP32_C5,
isEsp32 = obuHardware == ObuHardware.ESP32_C5,
showCamPinger = isEsp32,
isEsp32 = isEsp32,
denmEvents = denmEvents,
usbSerialState = usbSerialState,
camPingerActive = camPingerActive,
@@ -1209,3 +1224,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
}
@@ -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),
)
}
}
@@ -10,7 +10,6 @@ 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
@@ -67,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 }
+2 -6
View File
@@ -162,7 +162,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 +170,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>
@@ -270,7 +266,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>
+2 -6
View File
@@ -163,7 +163,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 +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">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>
@@ -271,7 +267,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"
+9
View File
@@ -1,5 +1,14 @@
# 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
+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.