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.
This commit is contained in:
Ashin Walpola
2026-08-11 14:50:35 +02:00
parent b91eb460dc
commit f507a8a9fd
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>