Phase 03: real CAM UPER codec + ESP32-C5 TX/RX serial link

- Firmware: rewrite obu-firmware TX loop to be serial-driven (no on-chip timer), add promiscuous RX + GeoNetworking/BTP unwrap (gn_unwrap.c), add binary UART framing to the phone (serial_link.c/.h). Drop local cam_encode() - CAM is now built on the phone.
- Kotlin: byte-exact UPER CAM encoder/decoder ported from cam.c (BitWriter/BitReader/CamUperCodec), matching SerialFrame codec, real UsbSerialTransport (usb-serial-for-android), CamTransmitLoop (1Hz base rate, event/geofence boost, ESP32-C5-only), wired into CamUseCaseRepository for RX and TripRecordingService for TX.
- Add V2X message retention: persist all CAM (own+remote) to Room while recording, drop otherwise (DB v2 -> v3 migration).
- Add jitpack repo + usb-serial-for-android dependency.

Fixes: UsbSerialTransport now uses SerialInputOutputManager.start()/stop() (this lib version manages its own thread internally) instead of manual Runnable/Thread submission, which didn't compile.
This commit is contained in:
Ashin Walpola
2026-08-04 17:58:07 +02:00
parent 842d362c8f
commit 33c4ec5998
58 changed files with 8438 additions and 65 deletions
Generated
+27
View File
@@ -2,5 +2,32 @@
<project version="4"> <project version="4">
<component name="VcsDirectoryMappings"> <component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" /> <mapping directory="$PROJECT_DIR$" vcs="Git" />
<mapping directory="$PROJECT_DIR$/its-g5-receiver-firmware" vcs="Git" />
<mapping directory="$PROJECT_DIR$/its-g5-receiver-firmware/esp-idf" vcs="Git" />
<mapping directory="$PROJECT_DIR$/its-g5-receiver-firmware/esp-idf/components/bootloader/subproject/components/micro-ecc/micro-ecc" vcs="Git" />
<mapping directory="$PROJECT_DIR$/its-g5-receiver-firmware/esp-idf/components/bt/controller/lib_esp32" vcs="Git" />
<mapping directory="$PROJECT_DIR$/its-g5-receiver-firmware/esp-idf/components/bt/controller/lib_esp32c2/esp32c2-bt-lib" vcs="Git" />
<mapping directory="$PROJECT_DIR$/its-g5-receiver-firmware/esp-idf/components/bt/controller/lib_esp32c3_family" vcs="Git" />
<mapping directory="$PROJECT_DIR$/its-g5-receiver-firmware/esp-idf/components/bt/controller/lib_esp32c5/esp32c5-bt-lib" vcs="Git" />
<mapping directory="$PROJECT_DIR$/its-g5-receiver-firmware/esp-idf/components/bt/controller/lib_esp32c6/esp32c6-bt-lib" vcs="Git" />
<mapping directory="$PROJECT_DIR$/its-g5-receiver-firmware/esp-idf/components/bt/controller/lib_esp32h2/esp32h2-bt-lib" vcs="Git" />
<mapping directory="$PROJECT_DIR$/its-g5-receiver-firmware/esp-idf/components/bt/esp_ble_mesh/lib/lib" vcs="Git" />
<mapping directory="$PROJECT_DIR$/its-g5-receiver-firmware/esp-idf/components/bt/host/nimble/nimble" vcs="Git" />
<mapping directory="$PROJECT_DIR$/its-g5-receiver-firmware/esp-idf/components/cmock/CMock" vcs="Git" />
<mapping directory="$PROJECT_DIR$/its-g5-receiver-firmware/esp-idf/components/cmock/CMock/vendor/c_exception" vcs="Git" />
<mapping directory="$PROJECT_DIR$/its-g5-receiver-firmware/esp-idf/components/cmock/CMock/vendor/unity" vcs="Git" />
<mapping directory="$PROJECT_DIR$/its-g5-receiver-firmware/esp-idf/components/esp_coex/lib" vcs="Git" />
<mapping directory="$PROJECT_DIR$/its-g5-receiver-firmware/esp-idf/components/esp_phy/lib" vcs="Git" />
<mapping directory="$PROJECT_DIR$/its-g5-receiver-firmware/esp-idf/components/esp_wifi/lib" vcs="Git" />
<mapping directory="$PROJECT_DIR$/its-g5-receiver-firmware/esp-idf/components/heap/tlsf" vcs="Git" />
<mapping directory="$PROJECT_DIR$/its-g5-receiver-firmware/esp-idf/components/lwip/lwip" vcs="Git" />
<mapping directory="$PROJECT_DIR$/its-g5-receiver-firmware/esp-idf/components/mbedtls/mbedtls" vcs="Git" />
<mapping directory="$PROJECT_DIR$/its-g5-receiver-firmware/esp-idf/components/openthread/lib" vcs="Git" />
<mapping directory="$PROJECT_DIR$/its-g5-receiver-firmware/esp-idf/components/openthread/openthread" vcs="Git" />
<mapping directory="$PROJECT_DIR$/its-g5-receiver-firmware/esp-idf/components/openthread/openthread/third_party/mbedtls/repo" vcs="Git" />
<mapping directory="$PROJECT_DIR$/its-g5-receiver-firmware/esp-idf/components/openthread/openthread/third_party/mbedtls/repo/framework" vcs="Git" />
<mapping directory="$PROJECT_DIR$/its-g5-receiver-firmware/esp-idf/components/protobuf-c/protobuf-c" vcs="Git" />
<mapping directory="$PROJECT_DIR$/its-g5-receiver-firmware/esp-idf/components/spiffs/spiffs" vcs="Git" />
<mapping directory="$PROJECT_DIR$/its-g5-receiver-firmware/esp-idf/components/unity/unity" vcs="Git" />
</component> </component>
</project> </project>
Binary file not shown.
Binary file not shown.
+2
View File
@@ -73,6 +73,8 @@ dependencies {
implementation(libs.appcompat) implementation(libs.appcompat)
implementation(libs.osmdroid) implementation(libs.osmdroid)
implementation(libs.androidx.core.splashscreen) implementation(libs.androidx.core.splashscreen)
// USB-serial link to the ESP32-C5 (Phase 03) - CDC-ACM/UART driver over Android's USB Host API.
implementation(libs.usb.serial.android)
debugImplementation(libs.compose.ui.tooling) debugImplementation(libs.compose.ui.tooling)
@@ -82,6 +82,7 @@ class MainActivity : AppCompatActivity() {
val tripServiceState by tripViewModel.serviceState.collectAsState() val tripServiceState by tripViewModel.serviceState.collectAsState()
val showBatteryOptPrompt by tripViewModel.showBatteryOptPrompt.collectAsState() val showBatteryOptPrompt by tripViewModel.showBatteryOptPrompt.collectAsState()
val useCaseEnabledMap by mqttViewModel.useCaseEnabledMap.collectAsState() val useCaseEnabledMap by mqttViewModel.useCaseEnabledMap.collectAsState()
val obuHardware by mqttViewModel.obuHardware.collectAsState()
MicrOBUTheme(darkTheme = state.darkTheme) { MicrOBUTheme(darkTheme = state.darkTheme) {
val view = LocalView.current val view = LocalView.current
@@ -133,6 +134,7 @@ class MainActivity : AppCompatActivity() {
state = state, state = state,
mqttConnectionState = mqttConnectionState, mqttConnectionState = mqttConnectionState,
activeTransport = activeTransport, activeTransport = activeTransport,
obuHardware = obuHardware,
usbCableConnected = usbConnected, usbCableConnected = usbConnected,
obuStationTypeWarning = obuStationTypeWarning, obuStationTypeWarning = obuStationTypeWarning,
obuStationType = obuStationType, obuStationType = obuStationType,
@@ -145,6 +147,13 @@ class MainActivity : AppCompatActivity() {
} }
}, },
onNavigateToMap = { navController.navigate(Screen.Map.route) }, onNavigateToMap = { navController.navigate(Screen.Map.route) },
onNavigateToRecord = {
navController.navigate(Screen.Record.route) {
popUpTo(Screen.Dashboard.route) { saveState = true }
launchSingleTop = true
restoreState = true
}
},
) )
} }
composable(Screen.Sensors.route) { composable(Screen.Sensors.route) {
@@ -239,6 +248,8 @@ class MainActivity : AppCompatActivity() {
ConnectionSettingsScreen( ConnectionSettingsScreen(
mqttPrefs = mqttPrefs, mqttPrefs = mqttPrefs,
onMqttPrefsChange = mqttViewModel::updatePrefs, onMqttPrefsChange = mqttViewModel::updatePrefs,
obuHardware = obuHardware,
onObuHardwareChange = mqttViewModel::setObuHardware,
onBack = { navController.popBackStack() }, onBack = { navController.popBackStack() },
) )
} }
@@ -3,6 +3,8 @@ package com.hawhamburg.micr0bu.data
import com.hawhamburg.micr0bu.data.db.AppDatabase import com.hawhamburg.micr0bu.data.db.AppDatabase
import com.hawhamburg.micr0bu.data.db.DetectedEventEntity import com.hawhamburg.micr0bu.data.db.DetectedEventEntity
import com.hawhamburg.micr0bu.data.db.RecordedTripEntity import com.hawhamburg.micr0bu.data.db.RecordedTripEntity
import com.hawhamburg.micr0bu.data.db.V2xMessageEntity
import com.hawhamburg.micr0bu.domain.cam.Cam
import com.hawhamburg.micr0bu.domain.detection.DetectedEvent import com.hawhamburg.micr0bu.domain.detection.DetectedEvent
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -90,4 +92,30 @@ class TripRepository(db: AppDatabase) {
/** Emits events for [tripId] ordered by timestamp, updating whenever the DB changes. */ /** Emits events for [tripId] ordered by timestamp, updating whenever the DB changes. */
fun getEventsForTrip(tripId: Long): Flow<List<DetectedEventEntity>> = fun getEventsForTrip(tripId: Long): Flow<List<DetectedEventEntity>> =
dao.getEventsForTrip(tripId) dao.getEventsForTrip(tripId)
// ── V2X messages (Phase 03) ──────────────────────────────────────────────────
// Retention policy: only ever called while a trip is actively recording — see
// V2xMessageEntity's KDoc and CamUseCaseRepository.processedCam's collector in
// TripRecordingService, which is the only caller.
/** Persists a domain [Cam] (own or remote) for the given [tripId]. */
suspend fun insertV2xMessage(tripId: Long, cam: Cam) =
dao.insertV2xMessage(
V2xMessageEntity(
tripId = tripId,
timestamp = cam.timestamp,
isOwn = cam.isOwn,
stationId = cam.stationId,
stationType = cam.stationType,
latitude = cam.latitude,
longitude = cam.longitude,
speedMps = cam.speedMps,
headingDeg = cam.headingDeg,
yawRateDps = cam.yawRateDps,
)
)
/** Emits V2X messages for [tripId] ordered by timestamp, updating whenever the DB changes. */
fun getV2xMessagesForTrip(tripId: Long): Flow<List<V2xMessageEntity>> =
dao.getV2xMessagesForTrip(tripId)
} }
@@ -6,6 +6,9 @@ import com.hawhamburg.micr0bu.data.SensorRepository
import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
import com.hawhamburg.micr0bu.data.mqtt.MqttRepository import com.hawhamburg.micr0bu.data.mqtt.MqttRepository
import com.hawhamburg.micr0bu.data.mqtt.UseCaseAlertPreferences import com.hawhamburg.micr0bu.data.mqtt.UseCaseAlertPreferences
import com.hawhamburg.micr0bu.data.transport.SerialFrameType
import com.hawhamburg.micr0bu.data.transport.UsbSerialTransport
import com.hawhamburg.micr0bu.domain.asn1.RealAsn1UperCodec
import com.hawhamburg.micr0bu.domain.cam.Cam import com.hawhamburg.micr0bu.domain.cam.Cam
import com.hawhamburg.micr0bu.domain.cam.CamParser import com.hawhamburg.micr0bu.domain.cam.CamParser
import com.hawhamburg.micr0bu.domain.cam.ObuGnssParser import com.hawhamburg.micr0bu.domain.cam.ObuGnssParser
@@ -18,9 +21,12 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
@@ -61,6 +67,8 @@ private const val OBU_GNSS_STALE_MS = 2_500L
class CamUseCaseRepository @Inject constructor( class CamUseCaseRepository @Inject constructor(
private val mqttRepository: MqttRepository, private val mqttRepository: MqttRepository,
private val prefs: UseCaseAlertPreferences, private val prefs: UseCaseAlertPreferences,
private val usbSerialTransport: UsbSerialTransport,
private val camCodec: RealAsn1UperCodec,
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
) { ) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@@ -87,6 +95,23 @@ class CamUseCaseRepository @Inject constructor(
alerts.filter { enabled[it.useCase] != false } alerts.filter { enabled[it.useCase] != false }
}.stateIn(scope, SharingStarted.Eagerly, emptyList()) }.stateIn(scope, SharingStarted.Eagerly, emptyList())
/** Ego bike's latest known position/state, for the V2X Monitor live map view (Section 13). */
val ownPosition: StateFlow<Cam?> = engine.ownPosition
/** Each tracked remote road user's latest known CAM, for the live map view (Section 13). */
val remotePositions: StateFlow<Map<Long, Cam>> = engine.remotePositions
private val _processedCam = MutableSharedFlow<Cam>(extraBufferCapacity = 256)
/**
* Every CAM (own or remote) this repository processes, own outgoing included — for
* [com.hawhamburg.micr0bu.service.TripRecordingService] to persist for the duration of a
* recording session (see `V2xMessageEntity`'s retention-policy KDoc). Deliberately separate
* from [ownPosition]/[remotePositions] (which only track the *latest* state per station,
* for the live map) — this is every message, unbounded, since a recording session needs the
* full history, not just current position.
*/
val processedCam: SharedFlow<Cam> = _processedCam.asSharedFlow()
init { init {
scope.launch { scope.launch {
mqttRepository.messages.collect { msg -> mqttRepository.messages.collect { msg ->
@@ -130,6 +155,17 @@ class CamUseCaseRepository @Inject constructor(
engine.pruneStale(System.currentTimeMillis()) engine.pruneStale(System.currentTimeMillis())
} }
} }
// ESP32-C5 path (Phase 03): remote CAMs arrive over the serial link instead of MQTT,
// already stripped of 802.11/GeoNetworking/BTP framing by the firmware's gn_unwrap.c -
// this only ever sees CAM UPER bytes. No-op stream on the CiT One path (the transport
// just never emits CAM_RX frames if nothing's plugged in over serial).
scope.launch {
usbSerialTransport.incomingFrames.collect { frame ->
if (frame.type != SerialFrameType.CAM_RX) return@collect
handleCamFromSerial(frame.payload)
}
}
} }
fun setUseCaseEnabled(type: UseCaseType, enabled: Boolean) { fun setUseCaseEnabled(type: UseCaseType, enabled: Boolean) {
@@ -151,6 +187,7 @@ class CamUseCaseRepository @Inject constructor(
if (ego.stationId != 0L) _ownStationId.value = ego.stationId if (ego.stationId != 0L) _ownStationId.value = ego.stationId
lastOwnStationType = ego.stationType lastOwnStationType = ego.stationType
engine.onOwnCam(ego) engine.onOwnCam(ego)
_processedCam.tryEmit(ego)
} }
/** /**
@@ -176,6 +213,7 @@ class CamUseCaseRepository @Inject constructor(
isOwn = true, isOwn = true,
) )
engine.onOwnCam(ego) engine.onOwnCam(ego)
_processedCam.tryEmit(ego)
} }
private fun handleCam(payload: String, timestamp: Long) { private fun handleCam(payload: String, timestamp: Long) {
@@ -188,5 +226,26 @@ class CamUseCaseRepository @Inject constructor(
} else { } else {
engine.onRemoteCam(cam) engine.onRemoteCam(cam)
} }
_processedCam.tryEmit(cam)
}
/**
* ESP32-C5 path: [payload] is a [com.hawhamburg.micr0bu.data.transport.SerialFrameType.CAM_RX]
* frame's body — `[rssi: 1 signed byte][CAM UPER bytes...]` (see that type's KDoc). RSSI
* itself isn't consumed yet (no UI surface for it on this path currently); only the CAM
* bytes are decoded.
*
* Every CAM received over the air here is inherently remote — this project's own outgoing
* CAM never loops back through this path — except for the edge case of the radio hearing
* its own just-transmitted frame (promiscuous capture of a local TX). Guarded the same way
* the MQTT path guards against reprocessing "own" CAM: compare against [_ownStationId].
*/
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
if (_ownStationId.value != null && cam.stationId == _ownStationId.value) return // self-heard TX
engine.onRemoteCam(cam)
_processedCam.tryEmit(cam)
} }
} }
@@ -12,8 +12,9 @@ import androidx.sqlite.db.SupportSQLiteDatabase
SessionEntity::class, SessionEntity::class,
RecordedTripEntity::class, RecordedTripEntity::class,
DetectedEventEntity::class, DetectedEventEntity::class,
V2xMessageEntity::class,
], ],
version = 2, version = 3,
exportSchema = false, exportSchema = false,
) )
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {
@@ -33,7 +34,7 @@ abstract class AppDatabase : RoomDatabase() {
AppDatabase::class.java, AppDatabase::class.java,
"micr0bu.db", "micr0bu.db",
) )
.addMigrations(MIGRATION_1_2) .addMigrations(MIGRATION_1_2, MIGRATION_2_3)
.build() .build()
.also { INSTANCE = it } .also { INSTANCE = it }
} }
@@ -83,5 +84,37 @@ abstract class AppDatabase : RoomDatabase() {
) )
} }
} }
/**
* Adds the `v2x_messages` table (Phase 03) — CAM retention for the duration of a
* recording session, see [V2xMessageEntity]'s KDoc for the retention policy.
*/
private val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS `v2x_messages` (
`id` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
`tripId` INTEGER NOT NULL,
`timestamp` INTEGER NOT NULL,
`isOwn` INTEGER NOT NULL,
`stationId` INTEGER NOT NULL,
`stationType` INTEGER NOT NULL,
`latitude` REAL NOT NULL,
`longitude` REAL NOT NULL,
`speedMps` REAL NOT NULL,
`headingDeg` REAL NOT NULL,
`yawRateDps` REAL,
FOREIGN KEY(`tripId`) REFERENCES `trips`(`id`)
ON UPDATE NO ACTION ON DELETE CASCADE
)
""".trimIndent()
)
database.execSQL(
"CREATE INDEX IF NOT EXISTS `index_v2x_messages_tripId` " +
"ON `v2x_messages` (`tripId`)"
)
}
}
} }
} }
@@ -37,4 +37,15 @@ interface TripDao {
@Query("SELECT COUNT(*) FROM detected_events WHERE tripId = :tripId") @Query("SELECT COUNT(*) FROM detected_events WHERE tripId = :tripId")
suspend fun getEventCountForTrip(tripId: Long): Int suspend fun getEventCountForTrip(tripId: Long): Int
// ── V2X messages (Phase 03) ──────────────────────────────────────────────────
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertV2xMessage(message: V2xMessageEntity)
@Query("SELECT * FROM v2x_messages WHERE tripId = :tripId ORDER BY timestamp ASC")
fun getV2xMessagesForTrip(tripId: Long): Flow<List<V2xMessageEntity>>
@Query("SELECT COUNT(*) FROM v2x_messages WHERE tripId = :tripId")
suspend fun getV2xMessageCountForTrip(tripId: Long): Int
} }
@@ -0,0 +1,51 @@
package com.hawhamburg.micr0bu.data.db
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.PrimaryKey
/**
* One CAM processed by [com.hawhamburg.micr0bu.data.cam.CamUseCaseRepository] (own or remote),
* persisted for the duration of an active recording session only.
*
* Retention policy (explicit user requirement, not derived from the requirements doc): while a
* trip is recording, every V2X message the detection engine sees is kept — the ride is the
* source of truth for later analysis, so nothing here should be silently dropped for storage
* reasons. Outside of a recording session, nothing is written to this table at all; the
* detection engine's own bounded in-memory history (see `UseCaseDetectionEngine.remoteHistory`)
* is the only thing tracking recent CAMs, and it can (and does) safely drop old samples once
* memory/relevance bounds are hit — there's no trip to correlate that data with anyway.
*/
@Entity(
tableName = "v2x_messages",
foreignKeys = [
ForeignKey(
entity = RecordedTripEntity::class,
parentColumns = ["id"],
childColumns = ["tripId"],
onDelete = ForeignKey.CASCADE,
)
],
)
data class V2xMessageEntity(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
@ColumnInfo(index = true)
val tripId: Long,
/** Wall-clock epoch ms this CAM was processed. */
val timestamp: Long,
/** True if this was the ego micrOBU's own outgoing CAM, false if a remote road user's. */
val isOwn: Boolean,
val stationId: Long,
val stationType: Int,
val latitude: Double,
val longitude: Double,
val speedMps: Double,
val headingDeg: Double,
val yawRateDps: Double?,
)
@@ -1,5 +1,6 @@
package com.hawhamburg.micr0bu.data.mqtt package com.hawhamburg.micr0bu.data.mqtt
import com.hawhamburg.micr0bu.data.transport.ObuHardware
import com.hawhamburg.micr0bu.data.transport.TransportType import com.hawhamburg.micr0bu.data.transport.TransportType
import com.hawhamburg.micr0bu.data.transport.UsbNetworkDetector import com.hawhamburg.micr0bu.data.transport.UsbNetworkDetector
import com.hawhamburg.micr0bu.domain.denm.DENM_CTRL_TOPIC import com.hawhamburg.micr0bu.domain.denm.DENM_CTRL_TOPIC
@@ -16,6 +17,7 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
@@ -52,6 +54,7 @@ private val SUBSCRIBED_TOPICS = listOf(
class MqttRepository @Inject constructor( class MqttRepository @Inject constructor(
private val prefs: MqttPreferences, private val prefs: MqttPreferences,
private val usbDetector: UsbNetworkDetector, private val usbDetector: UsbNetworkDetector,
private val obuHardwarePrefs: ObuHardwarePreferences,
) { ) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val clientId = "micr0bu-android-${UUID.randomUUID()}" private val clientId = "micr0bu-android-${UUID.randomUUID()}"
@@ -72,13 +75,25 @@ class MqttRepository @Inject constructor(
private val _topicMessages = MutableStateFlow<Map<String, List<MqttMessage>>>(emptyMap()) private val _topicMessages = MutableStateFlow<Map<String, List<MqttMessage>>>(emptyMap())
val topicMessages: StateFlow<Map<String, List<MqttMessage>>> = _topicMessages.asStateFlow() val topicMessages: StateFlow<Map<String, List<MqttMessage>>> = _topicMessages.asStateFlow()
/** Active transport derived from persisted prefs. */ /** Which physical OBU (Section 13) is currently selected. */
val activeTransport: StateFlow<TransportType> = prefs.prefsFlow val obuHardware: StateFlow<ObuHardware> = obuHardwarePrefs.obuHardwareFlow
.map { p -> .stateIn(scope, SharingStarted.Eagerly, ObuHardware.CIT_ONE)
if (p.activeTransport == MqttPrefs.TRANSPORT_USB_C) TransportType.USB_C
else TransportType.WIFI /**
* Active transport derived from persisted prefs, overridden by the selected OBU hardware:
* ESP32-C5 always resolves to [TransportType.USB_SERIAL] (a UART link, no MQTT-over-tethering
* broker exists on that hardware) regardless of the CiT-One-specific USB-C/Wi-Fi toggle.
*/
val activeTransport: StateFlow<TransportType> = combine(
prefs.prefsFlow,
obuHardwarePrefs.obuHardwareFlow,
) { p, hardware ->
when {
hardware == ObuHardware.ESP32_C5 -> TransportType.USB_SERIAL
p.activeTransport == MqttPrefs.TRANSPORT_USB_C -> TransportType.USB_C
else -> TransportType.WIFI
} }
.stateIn(scope, SharingStarted.Eagerly, TransportType.USB_C) }.stateIn(scope, SharingStarted.Eagerly, TransportType.USB_C)
@Volatile private var activeClient: MqttAsyncClient? = null @Volatile private var activeClient: MqttAsyncClient? = null
private var connectJob: Job? = null private var connectJob: Job? = null
@@ -0,0 +1,36 @@
package com.hawhamburg.micr0bu.data.mqtt
import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.hawhamburg.micr0bu.data.transport.ObuHardware
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
private val Context.obuHardwareDataStore by preferencesDataStore(name = "obu_hardware_prefs")
/**
* Persists which physical OBU (Section 13 / [ObuHardware]) the app is paired with. Defaults to
* [ObuHardware.CIT_ONE] — the hardware every existing feature (MQTT, DENM test trigger, CAM
* consumption) was built against.
*/
@Singleton
class ObuHardwarePreferences @Inject constructor(
@ApplicationContext private val context: Context,
) {
private object Keys {
val OBU_HARDWARE = stringPreferencesKey("obu_hardware")
}
val obuHardwareFlow: Flow<ObuHardware> = context.obuHardwareDataStore.data.map { prefs ->
ObuHardware.entries.firstOrNull { it.id == prefs[Keys.OBU_HARDWARE] } ?: ObuHardware.CIT_ONE
}
suspend fun setObuHardware(hardware: ObuHardware) {
context.obuHardwareDataStore.edit { prefs -> prefs[Keys.OBU_HARDWARE] = hardware.id }
}
}
@@ -0,0 +1,32 @@
package com.hawhamburg.micr0bu.data.transport
/**
* Which physical OBU the phone is paired with (Phase 03, requirements doc Section 13).
*
* The two hardware options differ in almost everything downstream: transport, CAM origin,
* and whether DENM triggering is available at all. See [com.hawhamburg.micr0bu.data.mqtt.ObuHardwarePreferences]
* for persistence and the Settings > Connection screen for the picker.
*/
enum class ObuHardware(val id: String) {
/**
* consider it CiT One — the primary Phase 01/02 hardware. Full V2X stack onboard: generates
* its own CAM autonomously, exposes an MQTT broker over USB-C tethering, supports DENM
* triggering via the Use Case API.
*/
CIT_ONE("cit_one"),
/**
* ESP32-C5 — a "dumb" V2X transceiver (Phase 03 second OBU option). Sends/receives raw
* ITS-G5 frames only on the phone's instruction; no onboard CAM generation, no MQTT broker,
* no DENM use-case engine. The phone does the work: builds CAM from its own GNSS/IMU, UPER-
* encodes it, and pushes it down a USB serial (UART) link — see
* [com.hawhamburg.micr0bu.data.transport.UsbSerialTransport] and
* [com.hawhamburg.micr0bu.domain.cam.PhoneCamBuilder].
*
* **Placeholder hardware option as of this writing** — the actual frame protocol between
* phone and ESP32-C5 firmware is not yet defined (pending translation of the existing
* ESP32 C firmware's logic to the Kotlin side). UI/settings exist so the option is visible
* and selectable, but connecting will not yet do anything real.
*/
ESP32_C5("esp32_c5"),
}
@@ -0,0 +1,138 @@
package com.hawhamburg.micr0bu.data.transport
/**
* Binary framing for the phone <-> ESP32-C5 link (Phase 03). Kotlin counterpart of the
* firmware's `obu-firmware/main/serial_link.c`/`.h` — frame shape and CRC algorithm MUST stay
* bit-for-bit identical between the two, since neither side validates the other's version.
*
* Frame format (both directions, symmetric):
* `[0xAA][0x55][type:1][length:2 LE][payload: length bytes][crc16:2 LE]`
* CRC16 is CRC-16/CCITT-FALSE (poly 0x1021, init 0xFFFF, no reflect, no xorout), computed over
* type + length + payload only (not the two sync bytes) — see [Crc16CcittFalse].
*/
object SerialFrameType {
/** Phone -> ESP32: raw CAM UPER bytes to GeoNetworking-wrap and transmit immediately. */
const val CAM_TX: Int = 0x01
/** ESP32 -> phone: payload is `[rssi: 1 signed][CAM UPER bytes...]`, already stripped of
* 802.11/LLC-SNAP/GeoNetworking/BTP-B framing by the firmware's `gn_unwrap.c`. */
const val CAM_RX: Int = 0x02
/** ESP32 -> phone: 1-byte heartbeat (0 = ok), independent of CAM traffic. */
const val STATUS: Int = 0x03
}
/** Max payload this link carries — matches `SERIAL_LINK_MAX_PAYLOAD` in the firmware. */
const val SERIAL_LINK_MAX_PAYLOAD = 160
private const val SYNC0: Byte = 0xAA.toByte()
private const val SYNC1: Byte = 0x55.toByte()
object Crc16CcittFalse {
/** MUST match the firmware's `crc16_ccitt_false` in `serial_link.c` byte-for-byte. */
fun compute(data: ByteArray, offset: Int = 0, length: Int = data.size - offset): Int {
var crc = 0xFFFF
for (i in offset until offset + length) {
crc = crc xor ((data[i].toInt() and 0xFF) shl 8)
repeat(8) {
crc = if (crc and 0x8000 != 0) ((crc shl 1) xor 0x1021) else (crc shl 1)
crc = crc and 0xFFFF
}
}
return crc
}
}
data class DecodedFrame(val type: Int, val payload: ByteArray)
object SerialFrameEncoder {
/** Builds a complete frame ready to write to the serial port. */
fun encode(type: Int, payload: ByteArray): ByteArray {
require(payload.size <= SERIAL_LINK_MAX_PAYLOAD) {
"payload too large for serial link (${payload.size} > $SERIAL_LINK_MAX_PAYLOAD)"
}
val head = byteArrayOf(type.toByte(), (payload.size and 0xFF).toByte(), ((payload.size shr 8) and 0xFF).toByte())
val crcInput = head + payload
val crc = Crc16CcittFalse.compute(crcInput)
val out = ByteArray(2 + crcInput.size + 2)
out[0] = SYNC0
out[1] = SYNC1
crcInput.copyInto(out, destinationOffset = 2)
out[out.size - 2] = (crc and 0xFF).toByte()
out[out.size - 1] = ((crc shr 8) and 0xFF).toByte()
return out
}
}
/**
* Stateful streaming decoder — feed it bytes as they arrive from the serial port (which may
* split or coalesce frames arbitrarily), and it emits [DecodedFrame]s as complete, checksummed
* frames are found. Mirrors the firmware's byte-at-a-time state machine in `serial_link.c`'s
* `rx_task` exactly (same states, same resync-on-mismatch behavior), just processing a whole
* chunk of newly-arrived bytes per call instead of one byte per loop iteration.
*
* Not thread-safe — feed bytes from a single reader coroutine/thread.
*/
class SerialFrameDecoder {
private enum class State { WAIT_SYNC0, WAIT_SYNC1, WAIT_TYPE, WAIT_LEN_LO, WAIT_LEN_HI, WAIT_PAYLOAD, WAIT_CRC_LO, WAIT_CRC_HI }
private var state = State.WAIT_SYNC0
private var type = 0
private var len = 0
private var payloadIdx = 0
private val payload = ByteArray(SERIAL_LINK_MAX_PAYLOAD)
private var crcRecv = 0
/** Feeds [count] new bytes from [data] (starting at [offset]) and returns any complete, valid frames found. */
fun onBytes(data: ByteArray, offset: Int = 0, count: Int = data.size - offset): List<DecodedFrame> {
val out = mutableListOf<DecodedFrame>()
for (i in offset until offset + count) {
val b = data[i].toInt() and 0xFF
when (state) {
State.WAIT_SYNC0 -> state = if (b == (SYNC0.toInt() and 0xFF)) State.WAIT_SYNC1 else State.WAIT_SYNC0
State.WAIT_SYNC1 -> state = when (b) {
SYNC1.toInt() and 0xFF -> State.WAIT_TYPE
SYNC0.toInt() and 0xFF -> State.WAIT_SYNC1
else -> State.WAIT_SYNC0
}
State.WAIT_TYPE -> {
type = b
state = State.WAIT_LEN_LO
}
State.WAIT_LEN_LO -> {
len = b
state = State.WAIT_LEN_HI
}
State.WAIT_LEN_HI -> {
len = len or (b shl 8)
state = when {
len > SERIAL_LINK_MAX_PAYLOAD -> State.WAIT_SYNC0 // can't trust the frame boundary - resync
len == 0 -> { payloadIdx = 0; State.WAIT_CRC_LO }
else -> { payloadIdx = 0; State.WAIT_PAYLOAD }
}
}
State.WAIT_PAYLOAD -> {
payload[payloadIdx++] = b.toByte()
if (payloadIdx >= len) state = State.WAIT_CRC_LO
}
State.WAIT_CRC_LO -> {
crcRecv = b
state = State.WAIT_CRC_HI
}
State.WAIT_CRC_HI -> {
crcRecv = crcRecv or (b shl 8)
val head = byteArrayOf(type.toByte(), (len and 0xFF).toByte(), ((len shr 8) and 0xFF).toByte())
val crcInput = head + payload.copyOf(len)
val crcCalc = Crc16CcittFalse.compute(crcInput)
if (crcCalc == crcRecv) {
out.add(DecodedFrame(type, payload.copyOf(len)))
}
// CRC mismatch: silently drop, matching firmware behavior - a corrupt frame
// on a link this fast recovers on its own within one beacon interval.
state = State.WAIT_SYNC0
}
}
}
return out
}
}
@@ -1,3 +1,10 @@
package com.hawhamburg.micr0bu.data.transport package com.hawhamburg.micr0bu.data.transport
enum class TransportType { USB_C, WIFI, BLUETOOTH } /**
* [USB_C] — Android USB tethering (virtual Ethernet) to the CiT One's onboard MQTT broker.
* [USB_SERIAL] — direct UART link over USB-C to an ESP32-C5's serial port (Phase 03, no
* network/MQTT layer involved — see [com.hawhamburg.micr0bu.data.transport.UsbSerialTransport]).
* [WIFI] — developer/legacy transport to a Wi-Fi-reachable MQTT broker.
* [BLUETOOTH] — planned production transport, not yet implemented (either OBU).
*/
enum class TransportType { USB_C, USB_SERIAL, WIFI, BLUETOOTH }
@@ -0,0 +1,206 @@
package com.hawhamburg.micr0bu.data.transport
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbManager
import android.os.Build
import com.hoho.android.usbserial.driver.UsbSerialDriver
import com.hoho.android.usbserial.driver.UsbSerialPort
import com.hoho.android.usbserial.driver.UsbSerialProber
import com.hoho.android.usbserial.util.SerialInputOutputManager
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject
import javax.inject.Singleton
/** Connection lifecycle for the ESP32-C5 USB-serial link. */
enum class UsbSerialState { DISCONNECTED, DEVICE_ATTACHED, PERMISSION_REQUESTED, CONNECTED, ERROR }
private const val ACTION_USB_PERMISSION = "com.hawhamburg.micr0bu.USB_SERIAL_PERMISSION"
/**
* UART connection handler for the ESP32-C5 (Phase 03 second OBU option, requirements doc
* Section 13). Unlike [UsbNetworkDetector] (CiT One, USB-C tethering → virtual Ethernet → MQTT
* broker), the ESP32-C5 has no MQTT broker or IP network at all — it's reachable only as a USB
* serial (UART) device, framed per [SerialFrameType] (see that file's KDoc — must stay
* bit-for-bit compatible with the firmware's `obu-firmware/main/serial_link.c`).
*
* Built on `com.github.mik3y:usb-serial-for-android`, which auto-detects CDC-ACM (what the
* ESP32-C5's native USB is expected to enumerate as) plus common USB-UART bridge chips as a
* fallback, so this doesn't need to hardcode a specific driver class.
*
* Baud rate is fixed at 115200 to match `SERIAL_LINK_BAUD` in the firmware — if that ever
* changes on the firmware side, [BAUD_RATE] here must change with it.
*/
@Singleton
class UsbSerialTransport @Inject constructor(
@ApplicationContext private val context: Context,
) {
companion object {
private const val BAUD_RATE = 115_200
}
private val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager
private val _state = MutableStateFlow(UsbSerialState.DISCONNECTED)
val state: StateFlow<UsbSerialState> = _state.asStateFlow()
private val _incomingFrames = MutableSharedFlow<DecodedFrame>(extraBufferCapacity = 256)
/** Every valid frame the ESP32 sends (CAM_RX and STATUS) — callers filter by [DecodedFrame.type]. */
val incomingFrames: SharedFlow<DecodedFrame> = _incomingFrames.asSharedFlow()
private val decoder = SerialFrameDecoder()
@Volatile private var port: UsbSerialPort? = null
@Volatile private var ioManager: SerialInputOutputManager? = null
@Volatile private var pendingDevice: UsbDevice? = null
private val permissionReceiver = object : BroadcastReceiver() {
override fun onReceive(ctx: Context, intent: Intent) {
if (intent.action != ACTION_USB_PERMISSION) return
synchronized(this) {
val device: UsbDevice? = intent.getUsbDeviceExtra()
val granted = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)
if (granted && device != null) {
openDevice(device)
} else {
_state.value = UsbSerialState.ERROR
}
}
}
}
private var receiverRegistered = false
/**
* Finds the first attached USB-serial-capable device, requests permission if needed, and
* opens it. Safe to call repeatedly (e.g. from a "retry" UI action) — no-ops if already
* connected.
*/
fun connect() {
if (_state.value == UsbSerialState.CONNECTED) return
ensureReceiverRegistered()
val availableDrivers: List<UsbSerialDriver> =
UsbSerialProber.getDefaultProber().findAllDrivers(usbManager)
val driver = availableDrivers.firstOrNull()
if (driver == null) {
_state.value = UsbSerialState.DISCONNECTED
return
}
val device = driver.device
_state.value = UsbSerialState.DEVICE_ATTACHED
if (usbManager.hasPermission(device)) {
openDevice(device)
} else {
pendingDevice = device
_state.value = UsbSerialState.PERMISSION_REQUESTED
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0
val permissionIntent = PendingIntent.getBroadcast(
context, 0, Intent(ACTION_USB_PERMISSION).setPackage(context.packageName), flags,
)
usbManager.requestPermission(device, permissionIntent)
}
}
private fun openDevice(device: UsbDevice) {
val driver = UsbSerialProber.getDefaultProber().probeDevice(device)
if (driver == null || driver.ports.isEmpty()) {
_state.value = UsbSerialState.ERROR
return
}
val connection = usbManager.openDevice(device)
if (connection == null) {
_state.value = UsbSerialState.ERROR
return
}
val newPort = driver.ports[0]
try {
newPort.open(connection)
newPort.setParameters(BAUD_RATE, UsbSerialPort.DATABITS_8, UsbSerialPort.STOPBITS_1, UsbSerialPort.PARITY_NONE)
} catch (e: Exception) {
runCatching { newPort.close() }
_state.value = UsbSerialState.ERROR
return
}
port = newPort
val manager = SerialInputOutputManager(newPort, object : SerialInputOutputManager.Listener {
override fun onNewData(data: ByteArray) {
val frames = decoder.onBytes(data)
frames.forEach { _incomingFrames.tryEmit(it) }
}
override fun onRunError(e: Exception) {
_state.value = UsbSerialState.ERROR
}
})
ioManager = manager
// This version of the library manages its own background thread internally -
// SerialInputOutputManager.start()/stop() rather than the older pattern of the caller
// submitting it to an Executor/Thread as a Runnable (which this version's class no
// longer exposes for external use - see the two earlier compile errors this replaced).
manager.start()
_state.value = UsbSerialState.CONNECTED
}
/**
* Encodes [camUperBytes] as a [SerialFrameType.CAM_TX] frame and writes it to the port.
* No-op (returns false) if not currently connected — callers (the CAM transmit loop) should
* treat that as "this beacon interval's CAM didn't go out," not a fatal error; the next one
* is only ~1s away and will retry naturally.
*/
fun sendCamTx(camUperBytes: ByteArray): Boolean {
val p = port ?: return false
return try {
val frame = SerialFrameEncoder.encode(SerialFrameType.CAM_TX, camUperBytes)
p.write(frame, /* timeout ms */ 200)
true
} catch (e: Exception) {
_state.value = UsbSerialState.ERROR
false
}
}
fun disconnect() {
ioManager?.stop() // stops the manager's own internal background thread
ioManager = null
runCatching { port?.close() }
port = null
_state.value = UsbSerialState.DISCONNECTED
}
private fun ensureReceiverRegistered() {
if (receiverRegistered) return
val filter = IntentFilter(ACTION_USB_PERMISSION)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.registerReceiver(permissionReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
} else {
@Suppress("UnspecifiedRegisterReceiverFlag")
context.registerReceiver(permissionReceiver, filter)
}
receiverRegistered = true
}
private fun Intent.getUsbDeviceExtra(): UsbDevice? =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
getParcelableExtra(UsbManager.EXTRA_DEVICE, UsbDevice::class.java)
} else {
@Suppress("DEPRECATION")
getParcelableExtra(UsbManager.EXTRA_DEVICE)
}
}
@@ -0,0 +1,42 @@
package com.hawhamburg.micr0bu.domain.asn1
import com.hawhamburg.micr0bu.domain.cam.Cam
import javax.inject.Inject
import javax.inject.Singleton
/**
* UPER (Unaligned Packed Encoding Rules) codec for CAM, used on the ESP32-C5 hardware path
* (Phase 03, Section 13): the phone builds outgoing CAM itself
* ([com.hawhamburg.micr0bu.domain.cam.PhoneCamBuilder]) and UPER-encodes it before handing bytes
* to [com.hawhamburg.micr0bu.data.transport.UsbSerialTransport], and UPER-decodes whatever the
* ESP32-C5 forwards back on receive (already stripped of 802.11/GeoNetworking/BTP framing by
* the firmware's `gn_unwrap.c` — this only ever sees CAM UPER bytes, never raw radio frames).
*
* On the CiT One path this doesn't exist at all — that OBU encodes/decodes its own CAM/DENM
* onboard and only ever gives the phone already-parsed JSON over MQTT.
*
* The earlier open question ("does Kotlin have a UPER ASN.1 library for V2X") resolved to: no
* library needed. The ESP32-C5's own transmit firmware (`obu-firmware/main/cam.c`) already
* hand-builds CAM's UPER bitstream field-by-field rather than using a schema compiler, and that
* turned out to be the right reference to port directly — see [CamUperCodec], a bit-for-bit
* Kotlin port of that C function (plus a new decode direction the firmware never needed, since
* it was transmit-only). A schema-driven library (OSS Nokalva, Obj-Sys, `alexvoronov/
* geonetworking`) would only be worth revisiting if this project ever needs to encode/decode
* message types beyond CAM.
*/
interface Asn1UperCodec {
/** Encode a domain CAM into a UPER-encoded ETSI EN 302637-2 CAM byte frame. */
fun encodeCam(cam: Cam): ByteArray
/** Decode a UPER-encoded ETSI EN 302637-2 CAM byte frame into a domain CAM, or null if unparseable. */
fun decodeCam(frame: ByteArray, receivedAtEpochMs: Long): Cam?
}
/** [Asn1UperCodec] backed by [CamUperCodec]. Stateless — safe as a Hilt singleton. */
@Singleton
class RealAsn1UperCodec @Inject constructor() : Asn1UperCodec {
override fun encodeCam(cam: Cam): ByteArray = CamUperCodec.encode(cam)
override fun decodeCam(frame: ByteArray, receivedAtEpochMs: Long): Cam? =
CamUperCodec.decode(frame, receivedAtEpochMs)
}
@@ -0,0 +1,37 @@
package com.hawhamburg.micr0bu.domain.asn1
/**
* MSB-first bit reader — the decode-side inverse of [BitWriter]. No equivalent existed in the
* firmware (which only ever transmitted, never decoded CAM) — this is new code, but follows the
* exact same bit-order convention [BitWriter]/`cam.c`'s `bw_put_bits` uses, since it has to
* unpack what that packer (or the real firmware using the same layout) produced.
*/
class BitReader(private val data: ByteArray) {
private var bitPos = 0
/** True if at least [nbits] more bits remain. */
fun hasBits(nbits: Int): Boolean = bitPos + nbits <= data.size * 8
/**
* Reads [nbits] bits (MSB first) as an unsigned value in a Long. Throws
* [IndexOutOfBoundsException] if the buffer is exhausted — callers decoding a fixed,
* known-length CAM structure should treat that as "truncated/corrupt frame," same as any
* other malformed-input case.
*/
fun getBits(nbits: Int): Long {
var value = 0L
repeat(nbits) {
val byteIdx = bitPos / 8
if (byteIdx >= data.size) throw IndexOutOfBoundsException("BitReader: buffer exhausted")
val bitIdx = 7 - (bitPos % 8)
val bit = (data[byteIdx].toInt() ushr bitIdx) and 1
value = (value shl 1) or bit.toLong()
bitPos++
}
return value
}
fun getBitsInt(nbits: Int): Int = getBits(nbits).toInt()
val bitPosition: Int get() = bitPos
}
@@ -0,0 +1,41 @@
package com.hawhamburg.micr0bu.domain.asn1
/**
* MSB-first bit packer — ASN.1 UPER is a bitstream, not a byte stream. Direct Kotlin port of
* `bitwriter_t` / `bw_put_bits` in the ESP32 firmware's `obu-firmware/main/cam.c`, kept
* bit-for-bit identical since [CamUperCodec] depends on matching that layout exactly for
* interop with the firmware's `gn_unwrap.c` / ESP32-side (de facto reference) understanding of
* the wire format.
*
* Not thread-safe; one instance per encode call.
*/
class BitWriter(private val maxBytes: Int) {
private val buf = ByteArray(maxBytes)
private var bitPos = 0
/**
* Writes the low [nbits] bits of [value], MSB first. Silently stops writing (rather than
* throwing) once [maxBytes] is exhausted — mirrors the firmware's overflow guard; callers
* that care should check [byteLength] against their expected size afterward, same as
* `cam_encode`'s caller checks its return value.
*/
fun putBits(value: Long, nbits: Int) {
for (i in nbits - 1 downTo 0) {
val byteIdx = bitPos / 8
if (byteIdx >= maxBytes) return // overflow guard, matches bw_put_bits
val bitIdx = 7 - (bitPos % 8)
val bit = (value ushr i) and 1L
buf[byteIdx] = (buf[byteIdx].toInt() or (bit.toInt() shl bitIdx)).toByte()
bitPos++
}
}
/** Convenience for callers passing an Int/UInt-range value. */
fun putBits(value: Int, nbits: Int) = putBits(value.toLong() and 0xFFFFFFFFL, nbits)
/** Number of whole bytes written so far, rounding up a partial final byte (like `bw_byte_len`). */
val byteLength: Int get() = (bitPos + 7) / 8
/** Returns the written bytes, trimmed to [byteLength]. */
fun toByteArray(): ByteArray = buf.copyOf(byteLength)
}
@@ -0,0 +1,260 @@
package com.hawhamburg.micr0bu.domain.asn1
import com.hawhamburg.micr0bu.domain.cam.Cam
import kotlin.math.roundToInt
import kotlin.math.roundToLong
/**
* Real ASN.1 UPER encoder/decoder for CAM (ETSI EN 302637-2 v1.4.1 CAM-PDU-Descriptions +
* TS 102894-2 v1.3.1 ITS-Container), covering exactly the field set the ESP32 firmware's
* `obu-firmware/main/cam.c` transmits — [encode] is a bit-for-bit port of that C function (same
* field order, same bit widths, same "unavailable" sentinel values), so a real ITS-G5 receiver
* that understood the firmware's old locally-built frames understands these too. [decode] is
* new (the firmware never decoded CAM — it only ever beaconed a bench-location test frame), but
* follows the identical layout in reverse.
*
* Unlike `cam.c` — which hardcoded every vehicle-dynamics field to its ASN.1 "unavailable"
* value because it had no real sensors wired in — this encodes real values wherever the phone
* actually has them ([Cam.yawRateDps], [Cam.driveDirection], [Cam.vehicleLengthM]/[vehicleWidthM],
* [Cam.accelerationMps2]), falling back to the same sentinels only when a field is null. This is
* strictly more complete than the firmware reference, not a deviation from it — the wire format
* has always supported these fields, the old firmware just never had data to put in them.
*
* Not yet covered: PosConfidenceEllipse / AltitudeConfidence / HeadingConfidence /
* SpeedConfidence / CurvatureValue / CurvatureCalculationMode are all still encoded as
* "unavailable," same as `cam.c` — none of that is derivable from what [Cam] carries today.
* `CurvatureValue` in particular *could* be derived from yaw rate ÷ speed, but that's unstable
* at low speed and deliberately left as a follow-up rather than guessed at here.
*/
object CamUperCodec {
/** Encode buffer size — matches `cam.c`'s `cam_payload[96]`, the known-sufficient size. */
private const val ENCODE_BUFFER_BYTES = 96
// TimestampIts epoch: 2004-01-01T00:00:00Z, in Unix epoch milliseconds.
private const val TS_ITS_EPOCH_MS = 1_072_915_200_000L
// ASN.1 "unavailable" sentinel values, straight from the CAM/ITS-Container modules (also
// documented inline in cam.c against each field).
private const val HEADING_UNAVAILABLE = 3601
private const val SPEED_MAX = 16382 // 16383 is the type's own unavailable value; stay under it
private const val DRIVE_DIRECTION_UNAVAILABLE = 2
private const val VEHICLE_LENGTH_UNAVAILABLE = 1023
private const val VEHICLE_WIDTH_UNAVAILABLE = 62
private const val ACCEL_UNAVAILABLE = 161
private const val YAW_RATE_UNAVAILABLE = 32767
/** Converts a wall-clock epoch-ms timestamp to a UPER GenerationDeltaTime (TimestampIts mod 65536). */
fun generationDeltaTime(epochMs: Long): Int {
val itsMs = epochMs - TS_ITS_EPOCH_MS
// floorMod so this stays well-defined even for epochMs before the ITS epoch (shouldn't
// happen with a real clock, but avoids a negative/UB result if it ever does).
return Math.floorMod(itsMs, 65536L).toInt()
}
/**
* Encodes [cam] as a UPER CAM byte string. [cam.timestamp] is used as the wall-clock source
* for GenerationDeltaTime — pass the actual moment this CAM is being transmitted, not some
* earlier sample time, since GenerationDeltaTime is defined relative to transmission.
*/
fun encode(cam: Cam): ByteArray {
val bw = BitWriter(ENCODE_BUFFER_BYTES)
// ---- ItsPduHeader ----
bw.putBits(2, 8) // protocolVersion = 2
bw.putBits(2, 8) // messageID = cam(2)
bw.putBits(cam.stationId, 32) // stationID (low 32 bits if stationId is wider)
// ---- CoopAwareness ----
bw.putBits(generationDeltaTime(cam.timestamp), 16)
// ---- CamParameters ---- extension(0), lowFrequencyContainer present(1), specialVehicleContainer absent(0)
bw.putBits(0, 1)
bw.putBits(1, 1)
bw.putBits(0, 1)
// ---- BasicContainer ---- extension(0)
bw.putBits(0, 1)
bw.putBits(cam.stationType, 8)
// ReferencePosition
val latOffset = (cam.latitude * 1e7).roundToLong() - (-900000000L)
bw.putBits(latOffset, 31)
val lonOffset = (cam.longitude * 1e7).roundToLong() - (-1800000000L)
bw.putBits(lonOffset, 32)
bw.putBits(4095, 12) // semiMajorConfidence: unavailable
bw.putBits(4095, 12) // semiMinorConfidence: unavailable
bw.putBits(3601, 12) // semiMajorOrientation: unavailable
bw.putBits(900001, 20) // altitudeValue: unavailable
bw.putBits(15, 4) // altitudeConfidence: unavailable
// ---- HighFrequencyContainer CHOICE ---- extension(0), index 0 = basicVehicleContainerHighFrequency
bw.putBits(0, 1)
bw.putBits(0, 1)
bw.putBits(0, 7) // 7 optional-presence bits, all absent
val headingDdeg = if (cam.headingDeg.isFinite()) {
(Math.floorMod((cam.headingDeg * 10.0).roundToInt(), 3600))
} else {
HEADING_UNAVAILABLE
}
bw.putBits(headingDdeg, 12)
bw.putBits(126, 7) // headingConfidence: unavailable
val speedCmS = (cam.speedMps * 100.0).roundToInt().coerceIn(0, SPEED_MAX)
bw.putBits(speedCmS, 14)
bw.putBits(126, 7) // speedConfidence: unavailable
val driveDirection = cam.driveDirection?.coerceIn(0, 1) ?: DRIVE_DIRECTION_UNAVAILABLE
bw.putBits(driveDirection, 2)
val vehicleLengthDm = cam.vehicleLengthM
?.let { (it * 10.0).roundToInt().coerceIn(1, 1023) }
?: VEHICLE_LENGTH_UNAVAILABLE
bw.putBits(vehicleLengthDm - 1, 10)
bw.putBits(4, 3) // vehicleLengthConfidenceIndication: unavailable
val vehicleWidthDm = cam.vehicleWidthM
?.let { (it * 10.0).roundToInt().coerceIn(1, 62) }
?: VEHICLE_WIDTH_UNAVAILABLE
bw.putBits(vehicleWidthDm - 1, 6)
val accelTenths = cam.accelerationMps2
?.let { (it * 10.0).roundToInt().coerceIn(-160, 160) }
?: ACCEL_UNAVAILABLE
bw.putBits(accelTenths - (-160), 9)
bw.putBits(102, 7) // longitudinalAccelerationConfidence: unavailable
bw.putBits(1023 - (-1023), 11) // curvatureValue: unavailable (not derived - see class KDoc)
bw.putBits(7, 3) // curvatureConfidence: unavailable
bw.putBits(2, 2) // curvatureCalculationMode: unavailable
val yawRateCentiDegS = cam.yawRateDps
?.let { (it * 100.0).roundToInt().coerceIn(-32766, 32766) }
?: YAW_RATE_UNAVAILABLE
bw.putBits(yawRateCentiDegS - (-32766), 16)
bw.putBits(7, 3) // yawRateConfidence: unavailable
// ---- LowFrequencyContainer CHOICE ---- extension(0) -> basicVehicleContainerLowFrequency
bw.putBits(0, 1)
bw.putBits(0, 4) // vehicleRole: default(0)
bw.putBits(0, 8) // exteriorLights: all off
bw.putBits(0, 6) // pathHistory: empty
return bw.toByteArray()
}
/**
* Decodes a UPER CAM byte string into a domain [Cam] (always `isOwn = false` — this is only
* used for CAMs received from other stations; the ego's own CAM never round-trips through
* this). Returns null if the bytes aren't a CAM this codec understands: wrong
* protocolVersion/messageID, a CamParameters/HighFrequencyContainer/LowFrequencyContainer
* shape we don't decode (extension in use, RSU container instead of vehicle, or a
* specialVehicleContainer present — none of those are things this project transmits or
* currently needs to receive), or a truncated frame.
*
* [receivedAtEpochMs] becomes [Cam.timestamp] (wall-clock receipt time) — GenerationDeltaTime
* alone (a value mod 65536 ms) isn't enough on its own to reconstruct an absolute timestamp
* without also knowing which 65.536s window it falls in, so local receipt time is used
* instead, same convention the rest of this app's Cam pipeline already relies on.
*/
fun decode(bytes: ByteArray, receivedAtEpochMs: Long): Cam? {
return try {
decodeOrThrow(bytes, receivedAtEpochMs)
} catch (e: IndexOutOfBoundsException) {
null // truncated frame
}
}
private fun decodeOrThrow(bytes: ByteArray, receivedAtEpochMs: Long): Cam? {
val br = BitReader(bytes)
val protocolVersion = br.getBitsInt(8)
val messageId = br.getBitsInt(8)
if (protocolVersion != 2 || messageId != 2) return null // not a CAM we recognize
val stationId = br.getBits(32)
br.getBits(16) // generationDeltaTime - not used, we timestamp on receipt instead
val camParamsExt = br.getBitsInt(1)
if (camParamsExt != 0) return null // extension in use - unsupported shape
val lowFreqPresent = br.getBitsInt(1) == 1
val specialVehiclePresent = br.getBitsInt(1) == 1
if (specialVehiclePresent) return null // different container shape we don't parse
val basicContainerExt = br.getBitsInt(1)
if (basicContainerExt != 0) return null
val stationType = br.getBitsInt(8)
val latOffset = br.getBits(31)
val latitude = (latOffset + (-900000000L)) / 1e7
val lonOffset = br.getBits(32)
val longitude = (lonOffset + (-1800000000L)) / 1e7
br.getBits(12) // semiMajorConfidence
br.getBits(12) // semiMinorConfidence
br.getBits(12) // semiMajorOrientation
br.getBits(20) // altitudeValue
br.getBits(4) // altitudeConfidence
val highFreqExt = br.getBitsInt(1)
val highFreqIndex = br.getBitsInt(1)
if (highFreqExt != 0 || highFreqIndex != 0) return null // extension, or rsuContainerHighFrequency
br.getBits(7) // 7 optional-presence bits
val headingRaw = br.getBitsInt(12)
br.getBits(7) // headingConfidence
val headingDeg = headingRaw / 10.0
val speedRaw = br.getBitsInt(14)
br.getBits(7) // speedConfidence
val speedMps = speedRaw / 100.0
val driveDirectionRaw = br.getBitsInt(2)
val driveDirection = if (driveDirectionRaw == DRIVE_DIRECTION_UNAVAILABLE) null else driveDirectionRaw
val vehicleLengthRaw = br.getBitsInt(10) + 1
br.getBits(3) // vehicleLengthConfidenceIndication
val vehicleLengthM = if (vehicleLengthRaw == VEHICLE_LENGTH_UNAVAILABLE) null else vehicleLengthRaw / 10.0
val vehicleWidthRaw = br.getBitsInt(6) + 1
val vehicleWidthM = if (vehicleWidthRaw == VEHICLE_WIDTH_UNAVAILABLE) null else vehicleWidthRaw / 10.0
val accelRaw = br.getBitsInt(9) + (-160)
br.getBits(7) // longitudinalAccelerationConfidence
val accelerationMps2 = if (accelRaw == ACCEL_UNAVAILABLE) null else accelRaw / 10.0
br.getBits(11) // curvatureValue
br.getBits(3) // curvatureConfidence
br.getBits(2) // curvatureCalculationMode
val yawRateRaw = br.getBitsInt(16) + (-32766)
br.getBits(3) // yawRateConfidence
val yawRateDps = if (yawRateRaw == YAW_RATE_UNAVAILABLE) null else yawRateRaw / 100.0
if (lowFreqPresent) {
val lowFreqExt = br.getBitsInt(1)
if (lowFreqExt != 0) return null
br.getBits(4) // vehicleRole
br.getBits(8) // exteriorLights
br.getBits(6) // pathHistory count (0..40) - not decoded into path points, just consumed
}
return Cam(
stationId = stationId,
stationType = stationType,
latitude = latitude,
longitude = longitude,
speedMps = speedMps,
headingDeg = headingDeg,
yawRateDps = yawRateDps,
driveDirection = driveDirection,
vehicleLengthM = vehicleLengthM,
vehicleWidthM = vehicleWidthM,
accelerationMps2 = accelerationMps2,
timestamp = receivedAtEpochMs,
isOwn = false,
)
}
}
@@ -0,0 +1,43 @@
package com.hawhamburg.micr0bu.domain.cam
/**
* A geofenced area where CAM transmit rate should increase above the base rate — e.g. a known
* signalized intersection where VRU-vehicle conflicts are more likely (Phase 03, Section 13).
*
* **Placeholder values.** Exact radius and elevated rate are explicitly "still to be tuned"
* per the user's Phase 03 spec — these are initial engineering estimates only, matching the
* disclaimer pattern already used for [com.hawhamburg.micr0bu.domain.usecase.UseCaseDetectionConfig].
*/
data class CamGeofence(
val label: String,
val latitude: Double,
val longitude: Double,
val radiusM: Double = 60.0,
)
/**
* CAM transmit-rate policy for the phone-generated CAM path (ESP32-C5 hardware only — see
* [PhoneCamBuilder]). The CiT One generates and rates its own CAM autonomously onboard; this
* config has no effect on that path.
*
* Requirements (Section 13): 1 Hz base rate, only while a recording session is active; rate
* increases inside known high-risk geofenced areas. Exact elevated rate/radius are not yet
* tuned — [elevatedRateHz] and each [CamGeofence.radiusM] are placeholders.
*/
data class CamTransmitConfig(
/** Base transmit rate, Hz, used everywhere outside a geofence. */
val baseRateHz: Double = 1.0,
/** Elevated transmit rate, Hz, used inside a [geofences] entry. Not yet tuned. */
val elevatedRateHz: Double = 4.0,
/** Known high-risk areas (e.g. signalized intersections) where [elevatedRateHz] applies. */
val geofences: List<CamGeofence> = emptyList(),
/**
* CAM transmission only runs while a recording session is active (Section 13) — this is
* not a rate knob, it's a hard on/off gate enforced by whatever wires [PhoneCamBuilder]
* into [com.hawhamburg.micr0bu.service.TripRecordingService].
*/
val activeOnlyDuringRecording: Boolean = true,
)
@@ -0,0 +1,71 @@
package com.hawhamburg.micr0bu.domain.cam
import com.hawhamburg.micr0bu.data.GnssReading
import kotlin.math.abs
/**
* Builds an outgoing [Cam] from the phone's own GNSS + gyroscope, for the ESP32-C5 hardware
* path (Phase 03, Section 13) where the OBU itself generates no CAM at all — the phone must.
*
* This class only does the sensor-fusion-into-CAM-fields part, which is independent of the
* (not yet defined) wire protocol to the ESP32-C5. It is **not yet wired into any transmit
* pipeline** — nothing calls this today. Once the ESP32 firmware protocol is translated to
* Kotlin and [com.hawhamburg.micr0bu.domain.asn1.Asn1UperCodec] has a real implementation, the
* intended flow is:
*
* `PhoneCamBuilder.build(...)` → `Asn1UperCodec.encodeCam(...)` → `UsbSerialTransport` (write).
*
* Position/speed/heading come straight from GNSS. Yaw rate is derived from the gyroscope's
* z-axis reading (rotation about the vertical axis while the phone is roughly flat/mounted
* upright) rather than from GNSS heading deltas, which are noisy at low speed — same rationale
* already used for remote-vehicle turn detection in [com.hawhamburg.micr0bu.domain.usecase.UseCaseDetectionEngine].
*/
object PhoneCamBuilder {
/** Placeholder station ID until real station-ID assignment/config exists for this path. */
private const val PLACEHOLDER_OWN_STATION_ID = 0L
/**
* @param gnss latest phone GNSS fix.
* @param gyroZRadPerSec latest gyroscope z-axis reading, rad/s (device frame). Positive per
* Android's convention is counter-clockwise around +Z; converted to the clockwise-positive
* yaw rate convention already used by [Cam.yawRateDps] to match OBU/remote CAM data.
* @param stationId this device's own station ID. Defaults to a placeholder until Phase 03
* defines how the phone learns/assigns an ID on the ESP32-C5 path (the CiT One path
* currently learns this from `v2x/rx/obu_gnss`'s own_info, which doesn't exist here).
*/
fun build(
gnss: GnssReading,
gyroZRadPerSec: Float?,
stationId: Long = PLACEHOLDER_OWN_STATION_ID,
): Cam {
val yawRateDps = gyroZRadPerSec?.let { -it * RAD_TO_DEG } // negate: CCW+ -> CW+ convention
return Cam(
stationId = stationId,
stationType = StationType.CYCLIST,
latitude = gnss.latitude,
longitude = gnss.longitude,
speedMps = gnss.speedMs.toDouble(),
headingDeg = normalizeHeading(gnss.bearingDeg.toDouble()),
yawRateDps = yawRateDps?.let { if (abs(it) < YAW_RATE_NOISE_FLOOR_DPS) 0.0 else it },
timestamp = gnss.timestamp,
isOwn = true,
)
}
private fun normalizeHeading(deg: Double): Double {
var h = deg % 360.0
if (h < 0) h += 360.0
return h
}
private const val RAD_TO_DEG = 180.0 / Math.PI
/**
* Gyro noise floor below which yaw rate is clamped to zero. Placeholder — initial
* engineering estimate pending real-world tuning, same disclaimer as
* [com.hawhamburg.micr0bu.domain.usecase.UseCaseDetectionConfig].
*/
private const val YAW_RATE_NOISE_FLOOR_DPS = 1.0
}
@@ -49,6 +49,17 @@ class UseCaseDetectionEngine(private val config: UseCaseDetectionConfig = UseCas
/** Currently active alerts across all remote road users, most severe first. */ /** Currently active alerts across all remote road users, most severe first. */
val currentAlerts: StateFlow<List<UseCaseAlert>> = _currentAlerts.asStateFlow() val currentAlerts: StateFlow<List<UseCaseAlert>> = _currentAlerts.asStateFlow()
// ── Live positions (Section 13 — V2X Monitor live map view) ──────────────────────────
// Exposed purely for the map view; detection logic above never reads these back.
private val _ownPosition = MutableStateFlow<Cam?>(null)
/** Ego bike's latest known state, for plotting on the live map. */
val ownPosition: StateFlow<Cam?> = _ownPosition.asStateFlow()
private val _remotePositions = MutableStateFlow<Map<Long, Cam>>(emptyMap())
/** Each tracked remote road user's latest known CAM, keyed by station ID, for the live map. */
val remotePositions: StateFlow<Map<Long, Cam>> = _remotePositions.asStateFlow()
/** /**
* Feed the ego bike's own most recent state (from `v2x/rx/obu_gnss`, or a fallback source * Feed the ego bike's own most recent state (from `v2x/rx/obu_gnss`, or a fallback source
* — see `CamUseCaseRepository`). Out-of-order/late updates are ignored. Re-evaluates all * — see `CamUseCaseRepository`). Out-of-order/late updates are ignored. Re-evaluates all
@@ -58,6 +69,7 @@ class UseCaseDetectionEngine(private val config: UseCaseDetectionConfig = UseCas
val current = ownCam val current = ownCam
if (current != null && cam.timestamp < current.timestamp) return // stale/out-of-order if (current != null && cam.timestamp < current.timestamp) return // stale/out-of-order
ownCam = cam ownCam = cam
_ownPosition.value = cam
remoteHistory.keys.toList().forEach { id -> remoteHistory[id]?.lastOrNull()?.let { evaluate(it) } } remoteHistory.keys.toList().forEach { id -> remoteHistory[id]?.lastOrNull()?.let { evaluate(it) } }
publish() publish()
} }
@@ -70,6 +82,7 @@ class UseCaseDetectionEngine(private val config: UseCaseDetectionConfig = UseCas
val history = remoteHistory.getOrPut(cam.stationId) { ArrayDeque() } val history = remoteHistory.getOrPut(cam.stationId) { ArrayDeque() }
history.addLast(cam) history.addLast(cam)
trimHistory(history, cam.timestamp) trimHistory(history, cam.timestamp)
_remotePositions.value = _remotePositions.value + (cam.stationId to cam)
evaluate(cam) evaluate(cam)
publish() publish()
} }
@@ -97,6 +110,7 @@ class UseCaseDetectionEngine(private val config: UseCaseDetectionConfig = UseCas
remoteHistory.remove(id) remoteHistory.remove(id)
activeAlerts.keys.filter { it.first == id }.forEach { activeAlerts.remove(it) } activeAlerts.keys.filter { it.first == id }.forEach { activeAlerts.remove(it) }
} }
_remotePositions.value = _remotePositions.value - staleIds
publish() publish()
} }
@@ -105,6 +119,8 @@ class UseCaseDetectionEngine(private val config: UseCaseDetectionConfig = UseCas
ownCam = null ownCam = null
remoteHistory.clear() remoteHistory.clear()
activeAlerts.clear() activeAlerts.clear()
_ownPosition.value = null
_remotePositions.value = emptyMap()
publish() publish()
} }
@@ -0,0 +1,124 @@
package com.hawhamburg.micr0bu.service
import android.content.Context
import com.hawhamburg.micr0bu.data.GnssReading
import com.hawhamburg.micr0bu.data.SensorRepository
import com.hawhamburg.micr0bu.data.mqtt.ObuHardwarePreferences
import com.hawhamburg.micr0bu.data.transport.ObuHardware
import com.hawhamburg.micr0bu.data.transport.UsbSerialTransport
import com.hawhamburg.micr0bu.domain.asn1.RealAsn1UperCodec
import com.hawhamburg.micr0bu.domain.cam.CamTransmitConfig
import com.hawhamburg.micr0bu.domain.cam.PhoneCamBuilder
import com.hawhamburg.micr0bu.domain.usecase.GeoMath
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
/**
* Real CAM transmit loop for the ESP32-C5 hardware path (Phase 03, Section 13) — the phone-side
* counterpart to the OBU's old autonomous beacon, now driven from here since the ESP32-C5 has
* no onboard CAM generation at all (see `obu-firmware/main/main.c`'s rewritten TX path, which
* is purely receive-and-transmit-on-serial-arrival with no timer of its own).
*
* Started/stopped by [TripRecordingService] around an active recording session — per the
* Section 13 spec, CAM transmission only runs while recording, matching the CiT One path's
* behavior of "no traffic until there's a trip to correlate it with." Internally also gated on
* [ObuHardwarePreferences] currently reporting [ObuHardware.ESP32_C5] — on the CiT One path
* this loop stays parked (via [kotlinx.coroutines.flow.collectLatest] on the hardware
* preference) and never sends anything.
*
* Rate policy: [CamTransmitConfig.baseRateHz] (1 Hz) everywhere, bumped to
* [CamTransmitConfig.elevatedRateHz] inside a [com.hawhamburg.micr0bu.domain.cam.CamGeofence] or
* for [ELEVATED_HOLD_MS] after an external event trigger (harsh braking/turning/stopping — see
* [onDetectedEvent], called by [TripRecordingService] from the same
* [com.hawhamburg.micr0bu.domain.detection.EventDetector] stream that already drives trip event
* logging). Both rate figures are placeholders pending real-world tuning, per
* [CamTransmitConfig]'s own disclaimer.
*/
@Singleton
class CamTransmitLoop @Inject constructor(
@ApplicationContext private val context: Context,
private val obuHardwarePrefs: ObuHardwarePreferences,
private val usbSerialTransport: UsbSerialTransport,
private val codec: RealAsn1UperCodec,
) {
private val config = CamTransmitConfig()
private val sensorRepository = SensorRepository(context)
private var job: Job? = null
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@Volatile private var latestGnss: GnssReading? = null
@Volatile private var latestGyroZ: Float? = null
@Volatile private var elevatedUntilMs: Long = 0L
/** Own station id for the ESP32-C5 path — see [PhoneCamBuilder]'s KDoc on why this is a placeholder. */
@Volatile var stationId: Long = 0L
/**
* Call when a braking/turning/stopping event fires during an active trip — bumps the CAM
* rate to [CamTransmitConfig.elevatedRateHz] for [ELEVATED_HOLD_MS] so nearby stations get
* denser updates through the maneuver, not just at the instant it was detected.
*/
fun onDetectedEvent() {
elevatedUntilMs = System.currentTimeMillis() + ELEVATED_HOLD_MS
}
/**
* Starts the loop for the duration of a recording session. Internally stays idle (no
* transmission) unless/until the ESP32-C5 is the selected OBU hardware, and automatically
* pauses/resumes if that selection changes mid-trip.
*/
fun start() {
if (job?.isActive == true) return
elevatedUntilMs = 0L
job = scope.launch {
obuHardwarePrefs.obuHardwareFlow.collectLatest { hardware ->
if (hardware != ObuHardware.ESP32_C5) return@collectLatest
runTransmitLoop()
}
}
}
fun stop() {
job?.cancel()
job = null
elevatedUntilMs = 0L
}
private suspend fun runTransmitLoop() = coroutineScope {
launch { sensorRepository.gnssFlow().collect { latestGnss = it } }
launch { sensorRepository.gyroscopeFlow().collect { latestGyroZ = it.z } }
while (true) {
val gnss = latestGnss
if (gnss != null) {
val cam = PhoneCamBuilder.build(gnss, latestGyroZ, stationId)
val bytes = codec.encodeCam(cam)
usbSerialTransport.sendCamTx(bytes)
}
delay((1000.0 / currentRateHz(gnss)).toLong())
}
}
private fun currentRateHz(gnss: GnssReading?): Double {
val now = System.currentTimeMillis()
val inGeofence = gnss != null && config.geofences.any { fence ->
GeoMath.haversineMeters(gnss.latitude, gnss.longitude, fence.latitude, fence.longitude) <= fence.radiusM
}
val eventBoosted = now < elevatedUntilMs
return if (inGeofence || eventBoosted) config.elevatedRateHz else config.baseRateHz
}
companion object {
private const val ELEVATED_HOLD_MS = 5_000L
}
}
@@ -24,11 +24,13 @@ import com.google.android.gms.location.Priority
import com.hawhamburg.micr0bu.MainActivity import com.hawhamburg.micr0bu.MainActivity
import com.hawhamburg.micr0bu.R import com.hawhamburg.micr0bu.R
import com.hawhamburg.micr0bu.data.TripRepository import com.hawhamburg.micr0bu.data.TripRepository
import com.hawhamburg.micr0bu.data.cam.CamUseCaseRepository
import com.hawhamburg.micr0bu.data.db.AppDatabase import com.hawhamburg.micr0bu.data.db.AppDatabase
import com.hawhamburg.micr0bu.domain.detection.DetectionConfig import com.hawhamburg.micr0bu.domain.detection.DetectionConfig
import com.hawhamburg.micr0bu.domain.detection.EventDetector import com.hawhamburg.micr0bu.domain.detection.EventDetector
import com.hawhamburg.micr0bu.domain.detection.EventType import com.hawhamburg.micr0bu.domain.detection.EventType
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -88,6 +90,17 @@ class TripRecordingService : Service() {
private lateinit var sensorManager: SensorManager private lateinit var sensorManager: SensorManager
private lateinit var fusedLocation: FusedLocationProviderClient private lateinit var fusedLocation: FusedLocationProviderClient
private lateinit var repository: TripRepository private lateinit var repository: TripRepository
// ESP32-C5 CAM transmit loop (Phase 03/Section 13) - internally a no-op unless that
// hardware is the one currently selected (see CamTransmitLoop's KDoc). Started/stopped
// alongside the trip, same lifecycle as everything else in this service.
@Inject lateinit var camTransmitLoop: CamTransmitLoop
// V2X message retention (Phase 03) - every CAM this repository processes (own + remote,
// either hardware path) gets persisted for the duration of a recording session; see
// V2xMessageEntity's KDoc for why nothing is retained outside of one.
@Inject lateinit var camUseCaseRepository: CamUseCaseRepository
private var v2xLoggingJob: Job? = null
private val detector = EventDetector( private val detector = EventDetector(
DetectionConfig( DetectionConfig(
brakingSpeedDropThreshold = 1.0, brakingSpeedDropThreshold = 1.0,
@@ -266,6 +279,9 @@ class TripRecordingService : Service() {
detector.events.collect { event -> detector.events.collect { event ->
if (currentTripId < 0) return@collect if (currentTripId < 0) return@collect
repository.insertEvent(currentTripId, event) repository.insertEvent(currentTripId, event)
// Bump the CAM transmit rate through the maneuver, not just at detection instant.
// No-op on the CiT One path (see CamTransmitLoop's KDoc).
camTransmitLoop.onDetectedEvent()
when (event.type) { when (event.type) {
EventType.BRAKING -> brakingCount++ EventType.BRAKING -> brakingCount++
EventType.TURNING -> turningCount++ EventType.TURNING -> turningCount++
@@ -296,6 +312,19 @@ class TripRecordingService : Service() {
// Register sensors // Register sensors
registerSensors() registerSensors()
requestLocationUpdates() requestLocationUpdates()
camTransmitLoop.start()
// V2X message retention — every CAM processed while this trip is recording gets
// persisted, unbounded, regardless of hardware path (MQTT/CiT One or serial/ESP32-C5).
// Explicit user requirement: outside of a recording session, this collector doesn't
// run at all, so nothing is retained beyond the detection engine's own bounded
// in-memory history.
v2xLoggingJob = serviceScope.launch {
camUseCaseRepository.processedCam.collect { cam ->
if (currentTripId < 0) return@collect
repository.insertV2xMessage(currentTripId, cam)
}
}
// Start foreground ASAP (within 5 s required by Android). // Start foreground ASAP (within 5 s required by Android).
// Specify FOREGROUND_SERVICE_TYPE_LOCATION so Android 10+ knows why we need // Specify FOREGROUND_SERVICE_TYPE_LOCATION so Android 10+ knows why we need
@@ -315,6 +344,9 @@ class TripRecordingService : Service() {
timerJob?.cancel() timerJob?.cancel()
unregisterSensors() unregisterSensors()
removeLocationUpdates() removeLocationUpdates()
camTransmitLoop.stop()
v2xLoggingJob?.cancel()
v2xLoggingJob = null
val endTime = System.currentTimeMillis() val endTime = System.currentTimeMillis()
val totalEvents = brakingCount + turningCount + stoppingCount val totalEvents = brakingCount + turningCount + stoppingCount
@@ -42,6 +42,7 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.hawhamburg.micr0bu.R import com.hawhamburg.micr0bu.R
import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
import com.hawhamburg.micr0bu.data.transport.ObuHardware
import com.hawhamburg.micr0bu.data.transport.TransportType import com.hawhamburg.micr0bu.data.transport.TransportType
import com.hawhamburg.micr0bu.viewmodel.MqttViewModel import com.hawhamburg.micr0bu.viewmodel.MqttViewModel
@@ -62,6 +63,7 @@ fun ConnectionSetupScreen(
val detectedObuIp by viewModel.detectedObuIp.collectAsState() val detectedObuIp by viewModel.detectedObuIp.collectAsState()
val activeTransport by viewModel.activeTransport.collectAsState() val activeTransport by viewModel.activeTransport.collectAsState()
val mqttPrefs by viewModel.mqttPrefs.collectAsState() val mqttPrefs by viewModel.mqttPrefs.collectAsState()
val obuHardware by viewModel.obuHardware.collectAsState()
val isConnected = connectionState == MqttConnectionState.CONNECTED val isConnected = connectionState == MqttConnectionState.CONNECTED
val isConnecting = connectionState == MqttConnectionState.CONNECTING val isConnecting = connectionState == MqttConnectionState.CONNECTING
@@ -85,12 +87,13 @@ fun ConnectionSetupScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
// ── USB-C Primary Card ───────────────────────────────────────────── // ── USB-C Primary Card (CiT One only — see ESP32-C5 placeholder card below) ────────
val usbContainerColor = when { val usbContainerColor = when {
usbConnected && isConnected -> UsbGreenBg usbConnected && isConnected -> UsbGreenBg
usbConnected && isConnecting -> UsbAmberBg usbConnected && isConnecting -> UsbAmberBg
else -> UsbGrayBg else -> UsbGrayBg
} }
if (obuHardware == ObuHardware.CIT_ONE) {
Card( Card(
colors = CardDefaults.cardColors(containerColor = usbContainerColor), colors = CardDefaults.cardColors(containerColor = usbContainerColor),
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
@@ -222,6 +225,41 @@ fun ConnectionSetupScreen(
} }
} }
} }
} else {
// ── ESP32-C5 placeholder card ───────────────────────────────────────
// Real USB-serial connection handling lives in UsbSerialTransport, which is a
// stub until the ESP32 firmware protocol is translated to Kotlin (Section 13).
Card(
colors = CardDefaults.cardColors(containerColor = UsbGrayBg),
shape = RoundedCornerShape(12.dp),
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
Icons.Default.Usb,
contentDescription = null,
tint = UsbGray,
modifier = Modifier.size(20.dp),
)
Text(
stringResource(R.string.conn_esp32_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = UsbGray,
)
}
Spacer(Modifier.height(8.dp))
Text(
stringResource(R.string.conn_esp32_phase3_desc),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
} }
} }
@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -49,6 +50,7 @@ import androidx.compose.ui.unit.dp
import androidx.core.net.toUri import androidx.core.net.toUri
import com.hawhamburg.micr0bu.R import com.hawhamburg.micr0bu.R
import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
import com.hawhamburg.micr0bu.data.transport.ObuHardware
import com.hawhamburg.micr0bu.data.transport.TransportType import com.hawhamburg.micr0bu.data.transport.TransportType
import com.hawhamburg.micr0bu.viewmodel.SensorUiState import com.hawhamburg.micr0bu.viewmodel.SensorUiState
import kotlin.math.sqrt import kotlin.math.sqrt
@@ -59,12 +61,14 @@ fun DashboardScreen(
state: SensorUiState, state: SensorUiState,
mqttConnectionState: MqttConnectionState, mqttConnectionState: MqttConnectionState,
activeTransport: TransportType = TransportType.USB_C, activeTransport: TransportType = TransportType.USB_C,
obuHardware: ObuHardware = ObuHardware.CIT_ONE,
usbCableConnected: Boolean = false, usbCableConnected: Boolean = false,
obuStationTypeWarning: Boolean = false, obuStationTypeWarning: Boolean = false,
obuStationType: Int? = null, obuStationType: Int? = null,
onNavigateToConnection: () -> Unit, onNavigateToConnection: () -> Unit,
onNavigateToSensors: () -> Unit, onNavigateToSensors: () -> Unit,
onNavigateToMap: () -> Unit, onNavigateToMap: () -> Unit,
onNavigateToRecord: () -> Unit = {},
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val context = LocalContext.current val context = LocalContext.current
@@ -207,6 +211,23 @@ fun DashboardScreen(
} }
} }
// ── "Start Driving Session" shortcut (Section 13 dashboard redesign) — straight to
// Record. Hidden while already recording since the banner above covers that state. ──
if (!state.isRecording) {
androidx.compose.material3.Button(
onClick = onNavigateToRecord,
modifier = Modifier.fillMaxWidth().height(52.dp),
) {
Icon(Icons.Default.FiberManualRecord, null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text(
stringResource(R.string.dash_start_driving_session),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
}
}
// GNSS card — tapping opens the bottom sheet // GNSS card — tapping opens the bottom sheet
StatusCard( StatusCard(
title = stringResource(R.string.dash_gnss), title = stringResource(R.string.dash_gnss),
@@ -236,11 +257,13 @@ fun DashboardScreen(
val mqttConnected = mqttConnectionState == MqttConnectionState.CONNECTED val mqttConnected = mqttConnectionState == MqttConnectionState.CONNECTED
val transportIcon = when (activeTransport) { val transportIcon = when (activeTransport) {
TransportType.USB_C -> Icons.Default.Usb TransportType.USB_C -> Icons.Default.Usb
TransportType.USB_SERIAL -> Icons.Default.Usb
TransportType.WIFI -> Icons.Default.Wifi TransportType.WIFI -> Icons.Default.Wifi
TransportType.BLUETOOTH -> Icons.Default.Bluetooth TransportType.BLUETOOTH -> Icons.Default.Bluetooth
} }
val transportInactiveIcon = when (activeTransport) { val transportInactiveIcon = when (activeTransport) {
TransportType.USB_C -> Icons.Default.Usb TransportType.USB_C -> Icons.Default.Usb
TransportType.USB_SERIAL -> Icons.Default.Usb
TransportType.WIFI -> Icons.Default.WifiOff TransportType.WIFI -> Icons.Default.WifiOff
TransportType.BLUETOOTH -> Icons.Default.BluetoothDisabled TransportType.BLUETOOTH -> Icons.Default.BluetoothDisabled
} }
@@ -285,8 +308,10 @@ fun DashboardScreen(
} }
} }
Spacer(Modifier.height(10.dp)) Spacer(Modifier.height(10.dp))
// Transport chip row — USB-C / Wi-Fi / BT with active highlighted // Transport chip row — which chips show depends on the selected OBU hardware,
// since CiT One and ESP32-C5 use non-overlapping transports (Section 13).
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
if (obuHardware == ObuHardware.CIT_ONE) {
TransportChip( TransportChip(
label = stringResource(R.string.dash_transport_usbc), label = stringResource(R.string.dash_transport_usbc),
icon = Icons.Default.Usb, icon = Icons.Default.Usb,
@@ -298,11 +323,19 @@ fun DashboardScreen(
icon = Icons.Default.Wifi, icon = Icons.Default.Wifi,
active = activeTransport == TransportType.WIFI, active = activeTransport == TransportType.WIFI,
) )
} else {
TransportChip(
label = stringResource(R.string.dash_transport_usb_serial),
icon = Icons.Default.Usb,
active = activeTransport == TransportType.USB_SERIAL,
hasCable = usbCableConnected,
)
}
TransportChip( TransportChip(
label = stringResource(R.string.dash_transport_bt), label = stringResource(R.string.dash_transport_bt),
icon = Icons.Default.Bluetooth, icon = Icons.Default.Bluetooth,
active = activeTransport == TransportType.BLUETOOTH, active = activeTransport == TransportType.BLUETOOTH,
dimmed = true, // Phase 03 — not yet available dimmed = true, // Phase 03 — production BT transport still under discussion
) )
} }
} }
@@ -65,6 +65,7 @@ import com.hawhamburg.micr0bu.R
import com.hawhamburg.micr0bu.data.mqtt.MessageDirection import com.hawhamburg.micr0bu.data.mqtt.MessageDirection
import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
import com.hawhamburg.micr0bu.data.mqtt.MqttMessage import com.hawhamburg.micr0bu.data.mqtt.MqttMessage
import com.hawhamburg.micr0bu.data.transport.ObuHardware
import com.hawhamburg.micr0bu.domain.cam.CamParser import com.hawhamburg.micr0bu.domain.cam.CamParser
import com.hawhamburg.micr0bu.domain.denm.DenmUseCase import com.hawhamburg.micr0bu.domain.denm.DenmUseCase
import com.hawhamburg.micr0bu.domain.usecase.AlertLevel import com.hawhamburg.micr0bu.domain.usecase.AlertLevel
@@ -78,6 +79,9 @@ import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
/** List (raw topics) vs Map (V2X live map, Section 13) toggle for [TopicListPane]. */
private enum class TopicViewMode { LIST, MAP }
private val timeFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.US) private val timeFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.US)
private val ConnectedGreen = Color(0xFF4CAF50) private val ConnectedGreen = Color(0xFF4CAF50)
@@ -111,6 +115,9 @@ fun MqttTopicViewerScreen(
val activeDenmUseCase by viewModel.activeDenmUseCase.collectAsState() val activeDenmUseCase by viewModel.activeDenmUseCase.collectAsState()
val useCaseAlerts by viewModel.useCaseAlerts.collectAsState() val useCaseAlerts by viewModel.useCaseAlerts.collectAsState()
val ownStationId by viewModel.ownStationId.collectAsState() val ownStationId by viewModel.ownStationId.collectAsState()
val obuHardware by viewModel.obuHardware.collectAsState()
val ownCamPosition by viewModel.ownCamPosition.collectAsState()
val remoteCamPositions by viewModel.remoteCamPositions.collectAsState()
// Sort: sys/ topics first (heartbeat/health), then alphabetical // Sort: sys/ topics first (heartbeat/health), then alphabetical
val sortedTopics = topicMessages.keys.sortedWith( val sortedTopics = topicMessages.keys.sortedWith(
@@ -188,6 +195,9 @@ fun MqttTopicViewerScreen(
lastDenmPayload = lastDenmPayload, lastDenmPayload = lastDenmPayload,
activeDenmUseCase = activeDenmUseCase, activeDenmUseCase = activeDenmUseCase,
useCaseAlerts = useCaseAlerts, useCaseAlerts = useCaseAlerts,
showDenmTrigger = obuHardware == ObuHardware.CIT_ONE,
ownCamPosition = ownCamPosition,
remoteCamPositions = remoteCamPositions,
onSelectTopic = { viewModel.selectTopic(it) }, onSelectTopic = { viewModel.selectTopic(it) },
onSendDenm = { viewModel.sendDenm() }, onSendDenm = { viewModel.sendDenm() },
onStopDenm = { viewModel.stopDenm() }, onStopDenm = { viewModel.stopDenm() },
@@ -213,11 +223,15 @@ private fun TopicListPane(
lastDenmPayload: String?, lastDenmPayload: String?,
activeDenmUseCase: String?, activeDenmUseCase: String?,
useCaseAlerts: List<UseCaseAlert>, useCaseAlerts: List<UseCaseAlert>,
showDenmTrigger: Boolean = true,
ownCamPosition: com.hawhamburg.micr0bu.domain.cam.Cam? = null,
remoteCamPositions: Map<Long, com.hawhamburg.micr0bu.domain.cam.Cam> = emptyMap(),
onSelectTopic: (String) -> Unit, onSelectTopic: (String) -> Unit,
onSendDenm: () -> Unit, onSendDenm: () -> Unit,
onStopDenm: () -> Unit, onStopDenm: () -> Unit,
) { ) {
val isConnected = connectionState == MqttConnectionState.CONNECTED val isConnected = connectionState == MqttConnectionState.CONNECTED
var viewMode by rememberSaveable { mutableStateOf(TopicViewMode.LIST) }
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
@@ -227,6 +241,9 @@ private fun TopicListPane(
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f)) HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f))
// ── DENM TX Control card — manual/test trigger only, not use-case-driven ── // ── DENM TX Control card — manual/test trigger only, not use-case-driven ──
// CiT-One-only: the OBU's Use Case API (v2x-uca/input/denmtrg) doesn't exist on the
// ESP32-C5 path, which has no onboard use-case engine (Section 13).
if (showDenmTrigger) {
DenmTxCard( DenmTxCard(
isConnected = isConnected, isConnected = isConnected,
denmActive = denmActive, denmActive = denmActive,
@@ -237,9 +254,39 @@ private fun TopicListPane(
) )
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f)) HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f))
}
// ── Topic rows ────────────────────────────────────────────────────── // ── List / Map toggle — the raw topic list stays available either way (Section 13
if (topics.isEmpty()) { // asks for the map "in addition to", not instead of, the topic list). ──────────────
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedButton(
onClick = { viewMode = TopicViewMode.LIST },
colors = ButtonDefaults.outlinedButtonColors(
containerColor = if (viewMode == TopicViewMode.LIST) MaterialTheme.colorScheme.primaryContainer else Color.Transparent,
contentColor = if (viewMode == TopicViewMode.LIST) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
),
) { Text(stringResource(R.string.mqtt_view_list)) }
OutlinedButton(
onClick = { viewMode = TopicViewMode.MAP },
colors = ButtonDefaults.outlinedButtonColors(
containerColor = if (viewMode == TopicViewMode.MAP) MaterialTheme.colorScheme.primaryContainer else Color.Transparent,
contentColor = if (viewMode == TopicViewMode.MAP) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
),
) { Text(stringResource(R.string.mqtt_view_map)) }
}
// ── Topic rows / live map ─────────────────────────────────────────────
if (viewMode == TopicViewMode.MAP) {
V2xLiveMapView(
own = ownCamPosition,
remotes = remoteCamPositions,
alerts = useCaseAlerts,
modifier = Modifier.fillMaxSize(),
)
} else if (topics.isEmpty()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text( Text(
@@ -47,6 +47,7 @@ import androidx.compose.ui.unit.dp
import androidx.core.os.LocaleListCompat import androidx.core.os.LocaleListCompat
import com.hawhamburg.micr0bu.R import com.hawhamburg.micr0bu.R
import com.hawhamburg.micr0bu.data.mqtt.MqttPrefs import com.hawhamburg.micr0bu.data.mqtt.MqttPrefs
import com.hawhamburg.micr0bu.data.transport.ObuHardware
import com.hawhamburg.micr0bu.domain.usecase.UseCaseDetectionConfig import com.hawhamburg.micr0bu.domain.usecase.UseCaseDetectionConfig
import com.hawhamburg.micr0bu.domain.usecase.UseCaseType import com.hawhamburg.micr0bu.domain.usecase.UseCaseType
import com.hawhamburg.micr0bu.viewmodel.SensorUiState import com.hawhamburg.micr0bu.viewmodel.SensorUiState
@@ -162,9 +163,56 @@ private fun SubScreen(title: String, onBack: () -> Unit, content: @Composable ()
fun ConnectionSettingsScreen( fun ConnectionSettingsScreen(
mqttPrefs: MqttPrefs, mqttPrefs: MqttPrefs,
onMqttPrefsChange: (MqttPrefs) -> Unit, onMqttPrefsChange: (MqttPrefs) -> Unit,
obuHardware: ObuHardware = ObuHardware.CIT_ONE,
onObuHardwareChange: (ObuHardware) -> Unit = {},
onBack: () -> Unit, onBack: () -> Unit,
) { ) {
SubScreen(stringResource(R.string.settings_connection), onBack) { SubScreen(stringResource(R.string.settings_connection), onBack) {
SectionCard {
// OBU Hardware selector — CiT One / ESP32-C5 (Phase 03, Section 13). Everything
// below (transport, USB-C options) only really applies to CiT One; ESP32-C5 uses
// USB Serial exclusively and has no transport choice to make here.
Text(
stringResource(R.string.settings_obu_hardware),
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 = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
val isCitOne = obuHardware == ObuHardware.CIT_ONE
OutlinedButton(
onClick = { onObuHardwareChange(ObuHardware.CIT_ONE) },
modifier = Modifier.weight(1f),
colors = ButtonDefaults.outlinedButtonColors(
containerColor = if (isCitOne) MaterialTheme.colorScheme.primaryContainer else Color.Transparent,
contentColor = if (isCitOne) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
),
) { Text(stringResource(R.string.settings_obu_hardware_cit_one), fontWeight = if (isCitOne) FontWeight.Bold else FontWeight.Normal) }
OutlinedButton(
onClick = { onObuHardwareChange(ObuHardware.ESP32_C5) },
modifier = Modifier.weight(1f),
colors = ButtonDefaults.outlinedButtonColors(
containerColor = if (!isCitOne) MaterialTheme.colorScheme.primaryContainer else Color.Transparent,
contentColor = if (!isCitOne) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
),
) { Text(stringResource(R.string.settings_obu_hardware_esp32), fontWeight = if (!isCitOne) FontWeight.Bold else FontWeight.Normal) }
}
if (obuHardware == ObuHardware.ESP32_C5) {
Text(
stringResource(R.string.settings_obu_hardware_esp32_note),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 8.dp),
)
}
}
if (obuHardware == ObuHardware.CIT_ONE) {
SectionCard { SectionCard {
// Active transport selector // Active transport selector
Text( Text(
@@ -221,6 +269,7 @@ fun ConnectionSettingsScreen(
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
} }
} }
}
} }
@Composable @Composable
@@ -0,0 +1,173 @@
package com.hawhamburg.micr0bu.ui.screens
import android.content.Context
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.GpsOff
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import com.hawhamburg.micr0bu.R
import com.hawhamburg.micr0bu.domain.cam.Cam
import com.hawhamburg.micr0bu.domain.usecase.AlertLevel
import com.hawhamburg.micr0bu.domain.usecase.UseCaseAlert
import org.osmdroid.config.Configuration
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
import org.osmdroid.util.GeoPoint
import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.Marker
/**
* V2X Monitor live map view (Phase 03, Section 13) — plots the ego bike's own position plus
* every currently-tracked remote road user's last-known CAM position, in addition to (not
* replacing) the raw topic list already on this screen. Reuses the same osmdroid pattern as
* [MapScreen]; unlike that screen, this one has no phone-GNSS-only fallback because [own] here
* always reflects whichever ego source [com.hawhamburg.micr0bu.data.cam.CamUseCaseRepository]
* currently trusts (obu_gnss / phone GNSS / CAM-topic-own — see that class's KDoc).
*
* Remote markers are colored by that station's most severe active alert level, if any, so a
* glance at the map shows not just "who's nearby" but "who's a warning right now" — the same
* severity coloring already used by [UseCaseAlertPanel].
*/
@Composable
fun V2xLiveMapView(
own: Cam?,
remotes: Map<Long, Cam>,
alerts: List<UseCaseAlert>,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
if (own == null) {
NoFixPlaceholder(modifier)
return
}
val ownGeoPoint = remember(own.latitude, own.longitude) { GeoPoint(own.latitude, own.longitude) }
val alertByStation = remember(alerts) {
alerts.groupBy { it.remoteStationId }
.mapValues { (_, a) -> a.maxByOrNull { it.alertLevel.ordinal }?.alertLevel }
}
val mapViewRef = remember { mutableStateOf<MapView?>(null) }
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_RESUME -> mapViewRef.value?.onResume()
Lifecycle.Event.ON_PAUSE -> mapViewRef.value?.onPause()
else -> {}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
mapViewRef.value?.onDetach()
}
}
Column(modifier = modifier.fillMaxSize()) {
Text(
text = stringResource(R.string.v2x_map_remote_count, remotes.size),
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(4.dp))
AndroidView(
factory = { ctx ->
initOsmForV2xMap(ctx)
MapView(ctx).apply {
setTileSource(TileSourceFactory.MAPNIK)
setMultiTouchControls(true)
controller.setZoom(17.0)
controller.setCenter(ownGeoPoint)
mapViewRef.value = this
}
},
update = { mv ->
mv.overlays.clear()
// Own marker — distinct from remotes via a dedicated title prefix; osmdroid
// doesn't tint default pins per-instance without a custom drawable, so color
// differentiation for now relies on the title label shown on tap.
mv.overlays.add(
Marker(mv).apply {
position = ownGeoPoint
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
title = context.getString(R.string.v2x_map_own_label)
}
)
remotes.forEach { (stationId, cam) ->
val level = alertByStation[stationId]
val label = when (level) {
AlertLevel.WARNING -> context.getString(R.string.v2x_map_remote_warning, stationId)
AlertLevel.AWARENESS -> context.getString(R.string.v2x_map_remote_awareness, stationId)
AlertLevel.INFO -> context.getString(R.string.v2x_map_remote_info, stationId)
null -> context.getString(R.string.v2x_map_remote_plain, stationId)
}
mv.overlays.add(
Marker(mv).apply {
position = GeoPoint(cam.latitude, cam.longitude)
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
title = label
}
)
}
mv.controller.animateTo(ownGeoPoint)
mv.invalidate()
},
modifier = Modifier.fillMaxSize(),
)
}
}
@Composable
private fun NoFixPlaceholder(modifier: Modifier) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
Icons.Default.GpsOff,
contentDescription = null,
modifier = Modifier.size(56.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(12.dp))
Text(
stringResource(R.string.gnss_no_fix),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
private fun initOsmForV2xMap(context: Context) {
Configuration.getInstance().apply {
load(context, context.getSharedPreferences("osmdroid", Context.MODE_PRIVATE))
userAgentValue = context.packageName
}
}
@@ -8,6 +8,8 @@ import com.hawhamburg.micr0bu.data.mqtt.MqttMessage
import com.hawhamburg.micr0bu.data.mqtt.MqttPreferences import com.hawhamburg.micr0bu.data.mqtt.MqttPreferences
import com.hawhamburg.micr0bu.data.mqtt.MqttPrefs import com.hawhamburg.micr0bu.data.mqtt.MqttPrefs
import com.hawhamburg.micr0bu.data.mqtt.MqttRepository import com.hawhamburg.micr0bu.data.mqtt.MqttRepository
import com.hawhamburg.micr0bu.data.mqtt.ObuHardwarePreferences
import com.hawhamburg.micr0bu.data.transport.ObuHardware
import com.hawhamburg.micr0bu.data.transport.TransportType import com.hawhamburg.micr0bu.data.transport.TransportType
import com.hawhamburg.micr0bu.data.transport.UsbNetworkDetector import com.hawhamburg.micr0bu.data.transport.UsbNetworkDetector
import com.hawhamburg.micr0bu.domain.denm.DenmUseCase import com.hawhamburg.micr0bu.domain.denm.DenmUseCase
@@ -30,6 +32,7 @@ class MqttViewModel @Inject constructor(
private val prefs: MqttPreferences, private val prefs: MqttPreferences,
private val usbDetector: UsbNetworkDetector, private val usbDetector: UsbNetworkDetector,
private val camUseCaseRepository: CamUseCaseRepository, private val camUseCaseRepository: CamUseCaseRepository,
private val obuHardwarePrefs: ObuHardwarePreferences,
) : ViewModel() { ) : ViewModel() {
// ── MQTT connection & messages ──────────────────────────────────────────── // ── MQTT connection & messages ────────────────────────────────────────────
@@ -48,6 +51,13 @@ class MqttViewModel @Inject constructor(
val activeTransport: StateFlow<TransportType> = repo.activeTransport val activeTransport: StateFlow<TransportType> = repo.activeTransport
/** Which physical OBU (Section 13) is currently selected — CiT One or ESP32-C5. */
val obuHardware: StateFlow<ObuHardware> = repo.obuHardware
fun setObuHardware(hardware: ObuHardware) {
viewModelScope.launch { obuHardwarePrefs.setObuHardware(hardware) }
}
/** True when a 192.168.42.x USB-C tethering network is detected. */ /** True when a 192.168.42.x USB-C tethering network is detected. */
val usbConnected: StateFlow<Boolean> = usbDetector.usbNetwork val usbConnected: StateFlow<Boolean> = usbDetector.usbNetwork
.map { it != null } .map { it != null }
@@ -111,6 +121,12 @@ class MqttViewModel @Inject constructor(
/** Per-use-case enable/disable state (Settings > Use Case Alerts). */ /** Per-use-case enable/disable state (Settings > Use Case Alerts). */
val useCaseEnabledMap: StateFlow<Map<UseCaseType, Boolean>> = camUseCaseRepository.enabledMap val useCaseEnabledMap: StateFlow<Map<UseCaseType, Boolean>> = camUseCaseRepository.enabledMap
/** Ego bike's latest known position, for the V2X Monitor live map view (Section 13). */
val ownCamPosition: StateFlow<com.hawhamburg.micr0bu.domain.cam.Cam?> = camUseCaseRepository.ownPosition
/** Latest known CAM per tracked remote road user, for the live map view (Section 13). */
val remoteCamPositions: StateFlow<Map<Long, com.hawhamburg.micr0bu.domain.cam.Cam>> = camUseCaseRepository.remotePositions
/** True if [stationId] is the ego OBU's own — used for OWN/REMOTE badges in the raw message list. */ /** True if [stationId] is the ego OBU's own — used for OWN/REMOTE badges in the raw message list. */
fun isOwnStationId(stationId: Long): Boolean = camUseCaseRepository.isOwnStationId(stationId) fun isOwnStationId(stationId: Long): Boolean = camUseCaseRepository.isOwnStationId(stationId)
+16
View File
@@ -29,6 +29,7 @@
<string name="dash_mqtt_error">Broker nicht erreichbar — V2X-Einstellungen prüfen</string> <string name="dash_mqtt_error">Broker nicht erreichbar — V2X-Einstellungen prüfen</string>
<string name="dash_recording">Aufnahme</string> <string name="dash_recording">Aufnahme</string>
<string name="dash_samples">Messwerte</string> <string name="dash_samples">Messwerte</string>
<string name="dash_start_driving_session">Fahrsitzung starten</string>
<string name="dash_initialising">Wird initialisiert…</string> <string name="dash_initialising">Wird initialisiert…</string>
<string name="stat_pressure">Luftdruck</string> <string name="stat_pressure">Luftdruck</string>
<string name="stat_altitude">Höhe</string> <string name="stat_altitude">Höhe</string>
@@ -61,6 +62,8 @@
<string name="conn_bluetooth">Bluetooth</string> <string name="conn_bluetooth">Bluetooth</string>
<string name="conn_bluetooth_phase3">Bluetooth</string> <string name="conn_bluetooth_phase3">Bluetooth</string>
<string name="conn_bluetooth_phase3_desc">Bluetooth-Verbindung ist für Phase 03 geplant und noch nicht implementiert.</string> <string name="conn_bluetooth_phase3_desc">Bluetooth-Verbindung ist für Phase 03 geplant und noch nicht implementiert.</string>
<string name="conn_esp32_title">ESP32-C5 (USB Seriell)</string>
<string name="conn_esp32_phase3_desc">Die USB-Seriell-Verbindung zum ESP32-C5 ist für Phase 03 vorgesehen, aber noch nicht funktionsfähig — dafür muss zuerst das ESP32-Firmware-Protokoll nach Kotlin übersetzt werden.</string>
<string name="conn_scan">Geräte suchen</string> <string name="conn_scan">Geräte suchen</string>
<string name="conn_scanning">Suche läuft…</string> <string name="conn_scanning">Suche läuft…</string>
<string name="conn_usb_title">USB-C (Primär)</string> <string name="conn_usb_title">USB-C (Primär)</string>
@@ -119,6 +122,12 @@
<string name="gnss_no_fix">Noch kein GPS-Signal — gehen Sie ins Freie</string> <string name="gnss_no_fix">Noch kein GPS-Signal — gehen Sie ins Freie</string>
<string name="map_title">Standortkarte</string> <string name="map_title">Standortkarte</string>
<string name="map_location_label">Aktueller Standort</string> <string name="map_location_label">Aktueller Standort</string>
<string name="v2x_map_remote_count">%1$d erfasste externe Verkehrsteilnehmer</string>
<string name="v2x_map_own_label">Eigen (Ego)</string>
<string name="v2x_map_remote_plain">Extern #%1$d</string>
<string name="v2x_map_remote_info">Extern #%1$d · Info</string>
<string name="v2x_map_remote_awareness">Extern #%1$d · Aufmerksamkeit</string>
<string name="v2x_map_remote_warning">Extern #%1$d · Warnung</string>
<!-- Settings --> <!-- Settings -->
<string name="settings_title">Einstellungen</string> <string name="settings_title">Einstellungen</string>
@@ -151,6 +160,10 @@
<string name="settings_connection">Verbindung</string> <string name="settings_connection">Verbindung</string>
<string name="settings_usb_auto_detect">OBU per USB-C automatisch erkennen</string> <string name="settings_usb_auto_detect">OBU per USB-C automatisch erkennen</string>
<string name="settings_usb_manual_ip">OBU-IP (manuell)</string> <string name="settings_usb_manual_ip">OBU-IP (manuell)</string>
<string name="settings_obu_hardware">OBU-Hardware</string>
<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">Die ESP32-C5-Unterstützung ist für Phase 03 vorgesehen, aber noch nicht funktionsfähig. Die CAM-Erzeugung wechselt zum Smartphone; USB-Seriell-Verbindung und DENM-Auslöser sind Platzhalter, bis das Firmware-Protokoll übersetzt ist.</string>
<string name="settings_usb_transport">Aktiver Transport</string> <string name="settings_usb_transport">Aktiver Transport</string>
<string name="settings_transport_usbc">USB-C</string> <string name="settings_transport_usbc">USB-C</string>
<string name="settings_transport_wifi">WLAN</string> <string name="settings_transport_wifi">WLAN</string>
@@ -162,6 +175,8 @@
<string name="mqtt_disconnect">Trennen</string> <string name="mqtt_disconnect">Trennen</string>
<string name="mqtt_auto_scroll">Automatisch scrollen</string> <string name="mqtt_auto_scroll">Automatisch scrollen</string>
<string name="mqtt_no_topics">Noch keine Nachrichten</string> <string name="mqtt_no_topics">Noch keine Nachrichten</string>
<string name="mqtt_view_list">Liste</string>
<string name="mqtt_view_map">Karte</string>
<string name="mqtt_no_topics_hint">Mit der OBU verbinden und auf V2X-Verkehr warten</string> <string name="mqtt_no_topics_hint">Mit der OBU verbinden und auf V2X-Verkehr warten</string>
<string name="mqtt_no_messages">Noch keine Nachrichten zu diesem Thema</string> <string name="mqtt_no_messages">Noch keine Nachrichten zu diesem Thema</string>
@@ -201,6 +216,7 @@
<!-- Dashboard transport --> <!-- Dashboard transport -->
<string name="dash_transport_usbc">USB-C</string> <string name="dash_transport_usbc">USB-C</string>
<string name="dash_transport_usb_serial">USB seriell</string>
<string name="dash_transport_wifi">WLAN</string> <string name="dash_transport_wifi">WLAN</string>
<string name="dash_transport_bt">BT</string> <string name="dash_transport_bt">BT</string>
+16
View File
@@ -30,6 +30,7 @@
<string name="dash_mqtt_error">Broker unreachable — check V2X settings</string> <string name="dash_mqtt_error">Broker unreachable — check V2X settings</string>
<string name="dash_recording">Recording</string> <string name="dash_recording">Recording</string>
<string name="dash_samples">samples</string> <string name="dash_samples">samples</string>
<string name="dash_start_driving_session">Start Driving Session</string>
<string name="dash_initialising">Initialising…</string> <string name="dash_initialising">Initialising…</string>
<string name="stat_pressure">Pressure</string> <string name="stat_pressure">Pressure</string>
<string name="stat_altitude">Altitude</string> <string name="stat_altitude">Altitude</string>
@@ -62,6 +63,8 @@
<string name="conn_bluetooth">Bluetooth</string> <string name="conn_bluetooth">Bluetooth</string>
<string name="conn_bluetooth_phase3">Bluetooth</string> <string name="conn_bluetooth_phase3">Bluetooth</string>
<string name="conn_bluetooth_phase3_desc">Bluetooth connection is planned for Phase 03 and is not yet implemented.</string> <string name="conn_bluetooth_phase3_desc">Bluetooth connection is planned for Phase 03 and is not yet implemented.</string>
<string name="conn_esp32_title">ESP32-C5 (USB Serial)</string>
<string name="conn_esp32_phase3_desc">USB-serial connection to the ESP32-C5 is scaffolded for Phase 03 but not yet functional — it needs the ESP32 firmware protocol translated to Kotlin first.</string>
<string name="conn_scan">Scan for Devices</string> <string name="conn_scan">Scan for Devices</string>
<string name="conn_scanning">Scanning…</string> <string name="conn_scanning">Scanning…</string>
<string name="conn_usb_title">USB-C (Primary)</string> <string name="conn_usb_title">USB-C (Primary)</string>
@@ -120,6 +123,12 @@
<string name="gnss_no_fix">No GPS fix yet — move to an open area</string> <string name="gnss_no_fix">No GPS fix yet — move to an open area</string>
<string name="map_title">Location Map</string> <string name="map_title">Location Map</string>
<string name="map_location_label">Current Location</string> <string name="map_location_label">Current Location</string>
<string name="v2x_map_remote_count">%1$d tracked remote road user(s)</string>
<string name="v2x_map_own_label">Own (ego)</string>
<string name="v2x_map_remote_plain">Remote #%1$d</string>
<string name="v2x_map_remote_info">Remote #%1$d · Info</string>
<string name="v2x_map_remote_awareness">Remote #%1$d · Awareness</string>
<string name="v2x_map_remote_warning">Remote #%1$d · Warning</string>
<!-- Settings --> <!-- Settings -->
<string name="settings_title">Settings</string> <string name="settings_title">Settings</string>
@@ -152,6 +161,10 @@
<string name="settings_connection">Connection</string> <string name="settings_connection">Connection</string>
<string name="settings_usb_auto_detect">Auto-detect OBU via USB-C</string> <string name="settings_usb_auto_detect">Auto-detect OBU via USB-C</string>
<string name="settings_usb_manual_ip">Manual OBU IP</string> <string name="settings_usb_manual_ip">Manual OBU IP</string>
<string name="settings_obu_hardware">OBU Hardware</string>
<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 support is scaffolded for Phase 03 but not yet functional. CAM generation moves to the phone; USB-serial connection and DENM trigger are placeholders pending firmware protocol translation.</string>
<string name="settings_usb_transport">Active transport</string> <string name="settings_usb_transport">Active transport</string>
<string name="settings_transport_usbc">USB-C</string> <string name="settings_transport_usbc">USB-C</string>
<string name="settings_transport_wifi">Wi-Fi</string> <string name="settings_transport_wifi">Wi-Fi</string>
@@ -163,6 +176,8 @@
<string name="mqtt_disconnect">Disconnect</string> <string name="mqtt_disconnect">Disconnect</string>
<string name="mqtt_auto_scroll">Auto-scroll to latest</string> <string name="mqtt_auto_scroll">Auto-scroll to latest</string>
<string name="mqtt_no_topics">No messages yet</string> <string name="mqtt_no_topics">No messages yet</string>
<string name="mqtt_view_list">List</string>
<string name="mqtt_view_map">Map</string>
<string name="mqtt_no_topics_hint">Connect to the OBU and wait for V2X traffic</string> <string name="mqtt_no_topics_hint">Connect to the OBU and wait for V2X traffic</string>
<string name="mqtt_no_messages">No messages on this topic yet</string> <string name="mqtt_no_messages">No messages on this topic yet</string>
@@ -202,6 +217,7 @@
<!-- Dashboard transport --> <!-- Dashboard transport -->
<string name="dash_transport_usbc">USB-C</string> <string name="dash_transport_usbc">USB-C</string>
<string name="dash_transport_usb_serial">USB Serial</string>
<string name="dash_transport_wifi">Wi-Fi</string> <string name="dash_transport_wifi">Wi-Fi</string>
<string name="dash_transport_bt">BT</string> <string name="dash_transport_bt">BT</string>
+2
View File
@@ -18,6 +18,7 @@ activityCompose = "1.9.3"
navigationCompose = "2.8.5" navigationCompose = "2.8.5"
playServicesLocation = "21.3.0" playServicesLocation = "21.3.0"
coroutines = "1.9.0" coroutines = "1.9.0"
usbSerial = "3.9.0"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -46,6 +47,7 @@ room-compiler = { group = "androidx.room", name = "room-compiler", version.ref =
appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
osmdroid = { group = "org.osmdroid", name = "osmdroid-android", version.ref = "osmdroid" } osmdroid = { group = "org.osmdroid", name = "osmdroid-android", version.ref = "osmdroid" }
androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "splashscreen" } androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "splashscreen" }
usb-serial-android = { group = "com.github.mik3y", name = "usb-serial-for-android", version.ref = "usbSerial" }
junit = { group = "junit", name = "junit", version.ref = "junit" } junit = { group = "junit", name = "junit", version.ref = "junit" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" } kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" }
Submodule its-g5-receiver-firmware added at 91d6511fe6
+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)
+19
View File
@@ -0,0 +1,19 @@
# 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.
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).
+16
View File
@@ -0,0 +1,16 @@
# 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.
#
# cam.c is ALSO intentionally not in this list anymore (Phase 03): CAM is now built on the
# phone and sent down over serial_link, so this firmware never encodes CAM itself. cam.c/.h are
# left on disk as the byte-exact reference the Kotlin encoder was ported from - do not delete.
#
# serial_link.c/.h - binary phone<->ESP32 UART framing (Phase 03).
# gn_unwrap.c/.h - strips 802.11/LLC-SNAP/GeoNetworking/BTP-B off received frames down to CAM
# UPER bytes, for forwarding to the phone over serial_link.
idf_component_register(
SRCS "main.c" "denm.c" "geonet.c" "dot11p.c" "tx_custom.c" "serial_link.c" "gn_unwrap.c"
INCLUDE_DIRS "."
REQUIRES esp_event esp_netif nvs_flash driver esp_phy
PRIV_REQUIRES esp_wifi
)
+131
View File
@@ -0,0 +1,131 @@
#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} -> 2 bits
bw_put_bits(&bw, 2, 2); // unavailable
// 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
+114
View File
@@ -0,0 +1,114 @@
#include "gn_unwrap.h"
#include <string.h>
// Mirrors dot11p.c / geonet.c's constants and layout, in reverse. Keep these two files in sync
// if either the TX-side frame shape or these constants change.
#define GN_ETHERTYPE (0x8947)
#define LLC_SNAP_HEADER_LEN (8)
#define IEEE80211_HEADER_LEN (24) // non-QoS Data
#define IEEE80211_QOS_CTRL_LEN (2) // extra field QoS Data frames add
#define IEEE80211_FC_TYPE_DATA (2)
#define IEEE80211_FC_QOS_SUBTYPE_BIT (0x08)
#define GN_BASIC_HEADER_LEN (4)
#define GN_COMMON_HEADER_LEN (8)
#define GN_SHB_EXT_HEADER_LEN (24) // Source Long Position Vector, geonet.c's SHB shape
#define BTP_B_HEADER_LEN (4)
#define GN_HEADER_TYPE_TSB (5) // Topologically-Scoped Broadcast
#define GN_HEADER_SUBTYPE_SINGLE_HOP (0)
#define BTP_DEST_PORT_CAM (2001) // ETSI TS 103 248
static const uint8_t s_llc_snap_prefix[6] = {0xAA, 0xAA, 0x03, 0x00, 0x00, 0x00};
bool gn_unwrap_cam(const uint8_t *frame, int frame_len,
const uint8_t **out_cam, int *out_cam_len)
{
if (!frame || frame_len < IEEE80211_HEADER_LEN) {
return false;
}
uint8_t fc0 = frame[0];
uint8_t fc1 = frame[1];
uint8_t type = (fc0 >> 2) & 0x03;
uint8_t subtype = (fc0 >> 4) & 0x0F;
bool to_ds = fc1 & 0x01;
bool from_ds = fc1 & 0x02;
// Only plain broadcast Data frames, no WDS - matches what dot11p_build_frame ever produces
// (and what real ITS-G5 hardware sends).
if (type != IEEE80211_FC_TYPE_DATA || (to_ds && from_ds)) {
return false;
}
int offset = IEEE80211_HEADER_LEN;
if (subtype & IEEE80211_FC_QOS_SUBTYPE_BIT) {
offset += IEEE80211_QOS_CTRL_LEN;
}
if (frame_len < offset + LLC_SNAP_HEADER_LEN) {
return false;
}
if (memcmp(frame + offset, s_llc_snap_prefix, sizeof(s_llc_snap_prefix)) != 0) {
return false;
}
uint16_t ethertype = ((uint16_t)frame[offset + 6] << 8) | frame[offset + 7];
if (ethertype != GN_ETHERTYPE) {
return false;
}
offset += LLC_SNAP_HEADER_LEN;
// ---- GN Basic Header (4 bytes) ---- nothing here we need to validate for our purposes;
// just skip it. (version/NextHeader in byte0, lifetime in byte2, RHL in byte3.)
if (frame_len < offset + GN_BASIC_HEADER_LEN) {
return false;
}
offset += GN_BASIC_HEADER_LEN;
// ---- GN Common Header (8 bytes) ----
if (frame_len < offset + GN_COMMON_HEADER_LEN) {
return false;
}
uint8_t next_header = (frame[offset + 0] >> 4) & 0x0F;
uint8_t header_type = (frame[offset + 1] >> 4) & 0x0F;
uint8_t header_subtype = frame[offset + 1] & 0x0F;
if (next_header != 2 /* BTP-B */) {
return false;
}
if (header_type != GN_HEADER_TYPE_TSB || header_subtype != GN_HEADER_SUBTYPE_SINGLE_HOP) {
// Not a single-hop-broadcast frame - e.g. GeoBroadcast (DENM-style dissemination) or
// something this project doesn't transmit/expect. Not an error, just not for us yet -
// see gn_unwrap.h's note on scope.
return false;
}
offset += GN_COMMON_HEADER_LEN;
// ---- SHB extended header (24 bytes) ---- skip straight past it, we don't need the
// sender's claimed position/speed/heading here (the CAM payload has its own, more precise
// versions of those same fields).
if (frame_len < offset + GN_SHB_EXT_HEADER_LEN) {
return false;
}
offset += GN_SHB_EXT_HEADER_LEN;
// ---- BTP-B header (4 bytes) ----
if (frame_len < offset + BTP_B_HEADER_LEN) {
return false;
}
uint16_t dest_port = ((uint16_t)frame[offset + 0] << 8) | frame[offset + 1];
if (dest_port != BTP_DEST_PORT_CAM) {
return false; // e.g. DENM (2002) - not decoded by this project yet
}
offset += BTP_B_HEADER_LEN;
// ---- Whatever's left is the CAM UPER payload ----
int cam_len = frame_len - offset;
if (cam_len <= 0) {
return false;
}
*out_cam = frame + offset;
*out_cam_len = cam_len;
return true;
}
+39
View File
@@ -0,0 +1,39 @@
#ifndef GN_UNWRAP_H
#define GN_UNWRAP_H
#include <stdint.h>
#include <stddef.h>
#include <stdbool.h>
// Inverse of geonet_wrap_shb() + dot11p_build_frame(): takes a raw 802.11 frame as delivered by
// the WiFi driver's promiscuous RX callback and strips 802.11 header -> LLC/SNAP -> GeoNetworking
// Basic/Common/extended header -> BTP-B header, leaving just the ITS payload (CAM UPER bytes)
// and the sender's station id (GN_ADDR MID).
//
// Deliberately narrow, matching what this project actually transmits: only handles the
// Single-Hop-Broadcast (TSB, HeaderType=5/Subtype=0) extended header shape, same as
// geonet_wrap_shb() builds - the same "best-tested decode path" rationale documented there.
// A real receiver would also need GeoBroadcast (HeaderType=4, used by DENM dissemination in
// real deployments) and possibly Beacon/GeoUnicast - out of scope for now since nothing this
// project talks to sends those. Extend header_type handling here if that changes.
//
// Only accepts BTP-B destination port 2001 (CAM, per ETSI TS 103 248) - other ports (e.g. 2002
// DENM) are silently rejected since the phone-side decoder only understands CAM right now.
//
// Returns true and fills *out_cam / *out_cam_len (pointing INTO the input frame buffer, not a
// copy - valid only as long as `frame` is) if this was a well-formed, CAM-carrying SHB frame
// this project can decode. Returns false otherwise (wrong ethertype, wrong header type, wrong
// BTP port, truncated, or FCS/promiscuous-capture garbage - all common and expected on an
// open-air capture, not logged as errors by the caller).
//
// No station id is extracted here on purpose: CAM's own ItsPduHeader.stationID (the first real
// field inside the UPER payload this function hands back, per cam.c) is already the meaningful
// application-level identifier - the Kotlin-side decoder reads it from there. The GN_ADDR MID
// this frame also carries is a separate, link-layer-only pseudonym; extracting and forwarding
// it too would just be a second, easily-confused "station id" for no benefit here.
//
// RSSI is NOT extracted here either - it comes from the promiscuous callback's own packet
// metadata (wifi_pkt_rx_ctrl_t.rssi in main.c), not from anything inside the frame bytes.
bool gn_unwrap_cam(const uint8_t *frame, int frame_len,
const uint8_t **out_cam, int *out_cam_len);
#endif
+316
View File
@@ -0,0 +1,316 @@
#include <stdio.h>
#include <string.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/queue.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 "geonet.h"
#include "dot11p.h"
#include "tx_custom.h" // not called below - kept available for the QoS-Data/tx_custom path if
// esp_wifi_80211_tx's non-QoS frame ever proves insufficient again
#include "serial_link.h"
#include "gn_unwrap.h"
static const char *TAG = "obu-tx";
// Phase 03: CAM is no longer built on this chip. The phone fuses its own GNSS+IMU, UPER-encodes
// CAM itself, and hands the finished bytes down over serial_link (SERIAL_MSG_CAM_TX) - this
// firmware's job on transmit shrinks to "GeoNetworking/BTP-wrap + 802.11-wrap + key the PA the
// instant a CAM arrives." There is no on-chip transmit timer anymore; the phone's send cadence
// (1 Hz baseline, faster near intersections/events - all decided app-side) IS the air cadence.
// See cam.c/.h - no longer built (removed from CMakeLists), kept on disk for field-layout
// reference only, since the phone's Kotlin encoder is a byte-exact port of it.
//
// On receive, this firmware now also runs a promiscuous callback (gn_unwrap.c strips
// 802.11/LLC-SNAP/GeoNetworking/BTP-B down to the raw CAM UPER payload) and forwards every CAM
// it hears over the same serial link (SERIAL_MSG_CAM_RX), for the phone's detection engine.
// Single half-duplex radio doing both jobs, same as real ITS-G5 hardware.
// 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 (used for the GeoNetworking layer only now - see below) ----
#define STATION_TYPE 5 // passengerCar (TS 102 894-2 StationType) - matches gn_addr's ST field
#define BTP_PORT_CAM 2001 // BTP-B destination port for CAM (ETSI TS 103 248)
// 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. Used ONLY for the
// GeoNetworking Source Long Position Vector now (geonet_wrap_shb's own claimed position) - the
// CAM payload's own referencePosition comes from the phone's real GNSS and can legitimately
// differ from this bench placeholder until the GN layer is also given a real position source.
// TODO: feed this from the phone too (e.g. a lightweight position update piggybacked on
// SERIAL_MSG_CAM_TX, or a new small message type) instead of a fixed bench location.
#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. Owned entirely by this firmware (not the
// phone) per the Phase 03 design decision - simplest given the phone never needs to know it.
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);
// ============================================================================
// ---- TX path: phone -> serial_link -> queue -> radio task -> air ----------
// ============================================================================
// One CAM-to-transmit item. Fixed-size (no malloc) since SERIAL_LINK_MAX_PAYLOAD bounds it -
// simplest safe option for a queue this small and this hot.
typedef struct {
uint8_t data[SERIAL_LINK_MAX_PAYLOAD];
int len;
} cam_tx_item_t;
static QueueHandle_t s_tx_queue;
// Called directly from serial_link's UART RX task the instant a checksummed SERIAL_MSG_CAM_TX
// frame arrives - MUST be fast (documented in serial_link.h), so this only copies into a queue
// item and returns; the actual GeoNetworking-wrap + 802.11-wrap + radio TX happens in
// tx_radio_task below, off the UART parsing path entirely. xQueueSend with 0 timeout: if the
// radio task is somehow behind, drop this CAM rather than stall UART frame parsing - the next
// one is only ~1s (or less, at elevated rate) away regardless.
static void on_cam_tx_from_phone(const uint8_t *cam_uper, int cam_len)
{
if (cam_len <= 0 || cam_len > SERIAL_LINK_MAX_PAYLOAD) {
ESP_LOGW(TAG, "on_cam_tx_from_phone: bad length %d", cam_len);
return;
}
cam_tx_item_t item;
item.len = cam_len;
memcpy(item.data, cam_uper, (size_t)cam_len);
if (xQueueSend(s_tx_queue, &item, 0) != pdTRUE) {
ESP_LOGW(TAG, "tx queue full, dropping CAM from phone");
}
}
static void tx_radio_task(void *arg)
{
(void)arg;
cam_tx_item_t item;
while (1) {
if (xQueueReceive(s_tx_queue, &item, portMAX_DELAY) != pdTRUE) {
continue;
}
uint8_t gn_payload[160];
int gn_len = geonet_wrap_shb(item.data, item.len, pseudonym_mac, STATION_TYPE,
BENCH_LATITUDE_TENMICRODEG, BENCH_LONGITUDE_TENMICRODEG,
BTP_PORT_CAM, gn_payload, sizeof(gn_payload));
if (gn_len <= 0) {
ESP_LOGW(TAG, "geonet_wrap_shb failed (cam_len=%d)", item.len);
continue;
}
uint8_t frame[300];
int frame_len = dot11p_build_frame(gn_payload, gn_len, pseudonym_mac, frame,
sizeof(frame), false);
if (frame_len <= 0) {
ESP_LOGW(TAG, "dot11p_build_frame failed (gn_len=%d)", gn_len);
continue;
}
// Standard, well-tested raw-TX API with a non-QoS Data frame - same path validated
// during Phase 2 bring-up (see git history for the tx_custom.c A/B test that led here).
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 failed: %d", err);
} else {
ESP_LOGI(TAG, "CAM sent (%d bytes) @ %d MHz", frame_len, TX_FREQ_MHZ);
}
}
}
// ============================================================================
// ---- RX path: air -> promiscuous cb -> queue -> forward task -> serial_link
// ============================================================================
// Promiscuous RX callbacks run in the WiFi driver's own task context and must stay short - so,
// same pattern as the TX side and as the reference sniffer firmware (cmd_sniffer.c's
// queue_packet), this just copies the frame and queues it; gn_unwrap_cam() and the serial write
// both happen in rx_forward_task instead.
typedef struct {
uint8_t data[400]; // generous vs. our own ~300-byte TX frames; longer frames are truncated
int len;
int8_t rssi;
} rx_item_t;
static QueueHandle_t s_rx_queue;
static void wifi_promisc_rx_cb(void *recv_buf, wifi_promiscuous_pkt_type_t type)
{
if (type == WIFI_PKT_MISC) {
return; // no payload of interest, mirrors cmd_sniffer.c's handling
}
wifi_promiscuous_pkt_t *packet = (wifi_promiscuous_pkt_t *)recv_buf;
if (packet->rx_ctrl.rx_state) {
return; // frame had an error (mirrors cmd_sniffer.c)
}
#if CONFIG_SOC_WIFI_HE_SUPPORT
int length = packet->rx_ctrl.dump_len;
#else
int length = packet->rx_ctrl.sig_len - 4 /* FCS */;
#endif
if (length <= 0) {
return;
}
rx_item_t item;
item.len = length > (int)sizeof(item.data) ? (int)sizeof(item.data) : length;
memcpy(item.data, packet->payload, (size_t)item.len);
item.rssi = packet->rx_ctrl.rssi;
// 0 timeout: never block the WiFi driver's own task waiting for queue space.
xQueueSend(s_rx_queue, &item, 0);
}
static void rx_forward_task(void *arg)
{
(void)arg;
rx_item_t item;
while (1) {
if (xQueueReceive(s_rx_queue, &item, portMAX_DELAY) != pdTRUE) {
continue;
}
const uint8_t *cam = NULL;
int cam_len = 0;
// Most promiscuously-captured frames are NOT CAM (management/control frames, other
// ITS-G5 traffic types, our own loopback if the driver echoes it) - gn_unwrap_cam
// returning false here is the common case, not an error.
if (gn_unwrap_cam(item.data, item.len, &cam, &cam_len)) {
serial_link_send_cam_rx(item.rssi, cam, cam_len);
}
}
}
// ============================================================================
void app_main(void)
{
ESP_ERROR_CHECK(nvs_flash_init());
ESP_ERROR_CHECK(esp_netif_init());
ESP_ERROR_CHECK(esp_event_loop_create_default());
s_tx_queue = xQueueCreate(4, sizeof(cam_tx_item_t));
s_rx_queue = xQueueCreate(8, sizeof(rx_item_t));
if (!s_tx_queue || !s_rx_queue) {
ESP_LOGE(TAG, "queue creation failed - halting");
return;
}
// 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. 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/peer. 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. Also matters for RX now: a sleeping radio misses
// incoming CAMs just as easily as it drops outbound ones. Must be called
// after esp_wifi_start().
ESP_ERROR_CHECK(esp_wifi_set_ps(WIFI_PS_NONE));
// Register the promiscuous RX callback BEFORE enabling promiscuous mode, so there's no
// window where promiscuous mode is on but nothing is registered to receive frames from it.
ESP_ERROR_CHECK(esp_wifi_set_promiscuous_rx_cb(wifi_promisc_rx_cb));
// Enable promiscuous mode. Doubles as the fix for raw-TX being silently dropped
// (ESP-IDF only actually emits raw frames when the MAC is promiscuous or associated to an
// AP - plain unassociated STA is neither) AND as what makes RX possible at all outside a
// joined BSS. One radio, one mode, both jobs - see file header comment.
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 -
// 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");
xTaskCreate(tx_radio_task, "tx_radio", 4096, NULL, 6, NULL);
xTaskCreate(rx_forward_task, "rx_forward", 4096, NULL, 5, NULL);
serial_link_init(on_cam_tx_from_phone);
ESP_LOGW(TAG, "OCB @ %d MHz - TX/RX armed, driven by serial_link (no on-chip TX timer)",
TX_FREQ_MHZ);
}
+199
View File
@@ -0,0 +1,199 @@
#include "serial_link.h"
#include <string.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "driver/uart.h"
#include "esp_log.h"
static const char *TAG = "serial_link";
#define SYNC0 0xAA
#define SYNC1 0x55
static serial_link_cam_tx_cb_t s_on_cam_tx;
// ---- CRC-16/CCITT-FALSE (poly 0x1021, init 0xFFFF, no reflect, no xorout) ----
// Bytewise (no table) - frames here are at most SERIAL_LINK_MAX_PAYLOAD + 3 bytes, so table
// lookup isn't worth the flash/RAM tradeoff. MUST match the Kotlin-side implementation exactly
// (see app SerialFrame.kt) or every frame will be silently rejected as corrupt.
static uint16_t crc16_ccitt_false(const uint8_t *data, size_t len)
{
uint16_t crc = 0xFFFF;
for (size_t i = 0; i < len; i++) {
crc ^= (uint16_t)data[i] << 8;
for (int b = 0; b < 8; b++) {
crc = (crc & 0x8000) ? (uint16_t)((crc << 1) ^ 0x1021) : (uint16_t)(crc << 1);
}
}
return crc;
}
static bool send_frame(uint8_t type, const uint8_t *payload, int len)
{
if (len < 0 || len > SERIAL_LINK_MAX_PAYLOAD) {
ESP_LOGW(TAG, "send_frame: payload too large (%d)", len);
return false;
}
// type(1) + length(2) + payload(len) is what the CRC covers.
uint8_t head[3];
head[0] = type;
head[1] = (uint8_t)(len & 0xFF);
head[2] = (uint8_t)((len >> 8) & 0xFF);
uint16_t crc;
{
// Compute CRC over head+payload without a combined buffer copy: CRC is a running
// state, so feed it in two calls worth of bytes by concatenating into a small stack
// buffer (payload is capped at SERIAL_LINK_MAX_PAYLOAD, so head+payload comfortably
// fits on the stack).
uint8_t crc_buf[3 + SERIAL_LINK_MAX_PAYLOAD];
memcpy(crc_buf, head, 3);
if (len > 0) memcpy(crc_buf + 3, payload, (size_t)len);
crc = crc16_ccitt_false(crc_buf, (size_t)(3 + len));
}
uint8_t sync[2] = {SYNC0, SYNC1};
uint8_t crc_bytes[2] = {(uint8_t)(crc & 0xFF), (uint8_t)((crc >> 8) & 0xFF)};
// 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).
int wrote = 0;
wrote += uart_write_bytes(SERIAL_LINK_UART_NUM, sync, sizeof(sync));
wrote += uart_write_bytes(SERIAL_LINK_UART_NUM, head, sizeof(head));
if (len > 0) wrote += uart_write_bytes(SERIAL_LINK_UART_NUM, payload, (size_t)len);
wrote += uart_write_bytes(SERIAL_LINK_UART_NUM, crc_bytes, sizeof(crc_bytes));
return wrote == (int)(sizeof(sync) + sizeof(head) + len + sizeof(crc_bytes));
}
bool serial_link_send_cam_rx(int8_t rssi, const uint8_t *cam_uper, int cam_len)
{
if (cam_len < 0 || cam_len > SERIAL_LINK_MAX_PAYLOAD - 1) {
ESP_LOGW(TAG, "send_cam_rx: cam_len too large (%d)", cam_len);
return false;
}
uint8_t payload[SERIAL_LINK_MAX_PAYLOAD];
payload[0] = (uint8_t)rssi;
memcpy(payload + 1, cam_uper, (size_t)cam_len);
return send_frame(SERIAL_MSG_CAM_RX, payload, 1 + cam_len);
}
bool serial_link_send_status(uint8_t status)
{
return send_frame(SERIAL_MSG_STATUS, &status, 1);
}
// ---- RX framing state machine ----
// Runs in its own task, byte-at-a-time off the UART driver's RX ring buffer (via
// uart_read_bytes with a short timeout, not raw ISR access - simplest correct option for a
// link this slow/small; revisit if CAM traffic volume ever makes this a bottleneck).
typedef enum {
WAIT_SYNC0,
WAIT_SYNC1,
WAIT_TYPE,
WAIT_LEN_LO,
WAIT_LEN_HI,
WAIT_PAYLOAD,
WAIT_CRC_LO,
WAIT_CRC_HI,
} rx_state_t;
static void rx_task(void *arg)
{
(void)arg;
rx_state_t state = WAIT_SYNC0;
uint8_t type = 0;
uint16_t len = 0;
uint16_t payload_idx = 0;
uint8_t payload[SERIAL_LINK_MAX_PAYLOAD];
uint16_t crc_recv = 0;
uint8_t byte;
while (1) {
int n = uart_read_bytes(SERIAL_LINK_UART_NUM, &byte, 1, pdMS_TO_TICKS(50));
if (n <= 0) continue;
switch (state) {
case WAIT_SYNC0:
state = (byte == SYNC0) ? WAIT_SYNC1 : WAIT_SYNC0;
break;
case WAIT_SYNC1:
state = (byte == SYNC1) ? WAIT_TYPE : (byte == SYNC0 ? WAIT_SYNC1 : WAIT_SYNC0);
break;
case WAIT_TYPE:
type = byte;
state = WAIT_LEN_LO;
break;
case WAIT_LEN_LO:
len = byte;
state = WAIT_LEN_HI;
break;
case WAIT_LEN_HI:
len |= (uint16_t)byte << 8;
if (len > SERIAL_LINK_MAX_PAYLOAD) {
ESP_LOGW(TAG, "rx: length %u exceeds max, resyncing", len);
state = WAIT_SYNC0; // can't trust this frame boundary at all - drop to resync
} else if (len == 0) {
payload_idx = 0;
state = WAIT_CRC_LO;
} else {
payload_idx = 0;
state = WAIT_PAYLOAD;
}
break;
case WAIT_PAYLOAD:
payload[payload_idx++] = byte;
if (payload_idx >= len) state = WAIT_CRC_LO;
break;
case WAIT_CRC_LO:
crc_recv = byte;
state = WAIT_CRC_HI;
break;
case WAIT_CRC_HI: {
crc_recv |= (uint16_t)byte << 8;
uint8_t crc_buf[3 + SERIAL_LINK_MAX_PAYLOAD];
crc_buf[0] = type;
crc_buf[1] = (uint8_t)(len & 0xFF);
crc_buf[2] = (uint8_t)((len >> 8) & 0xFF);
if (len > 0) memcpy(crc_buf + 3, payload, len);
uint16_t crc_calc = crc16_ccitt_false(crc_buf, (size_t)(3 + len));
if (crc_calc == crc_recv) {
if (type == SERIAL_MSG_CAM_TX && s_on_cam_tx) {
s_on_cam_tx(payload, len);
} else if (type != SERIAL_MSG_CAM_TX) {
ESP_LOGW(TAG, "rx: unexpected frame type 0x%02x from phone, ignoring", type);
}
} else {
ESP_LOGW(TAG, "rx: CRC mismatch (got %04x want %04x), dropping frame", crc_recv, crc_calc);
}
state = WAIT_SYNC0;
break;
}
}
}
}
void serial_link_init(serial_link_cam_tx_cb_t on_cam_tx)
{
s_on_cam_tx = on_cam_tx;
uart_config_t cfg = {
.baud_rate = SERIAL_LINK_BAUD,
.data_bits = UART_DATA_8_BITS,
.parity = UART_PARITY_DISABLE,
.stop_bits = UART_STOP_BITS_1,
.flow_ctrl = UART_HW_FLOWCTRL_DISABLE,
.source_clk = UART_SCLK_DEFAULT,
};
ESP_ERROR_CHECK(uart_driver_install(SERIAL_LINK_UART_NUM, 1024, 1024, 0, NULL, 0));
ESP_ERROR_CHECK(uart_param_config(SERIAL_LINK_UART_NUM, &cfg));
ESP_ERROR_CHECK(uart_set_pin(SERIAL_LINK_UART_NUM, SERIAL_LINK_TX_GPIO, SERIAL_LINK_RX_GPIO,
UART_PIN_NO_CHANGE, UART_PIN_NO_CHANGE));
xTaskCreate(rx_task, "serial_link_rx", 4096, NULL, 6, NULL);
ESP_LOGI(TAG, "serial_link up on UART%d, TX=GPIO%d RX=GPIO%d @ %d baud",
SERIAL_LINK_UART_NUM, SERIAL_LINK_TX_GPIO, SERIAL_LINK_RX_GPIO, SERIAL_LINK_BAUD);
}
+63
View File
@@ -0,0 +1,63 @@
#ifndef SERIAL_LINK_H
#define SERIAL_LINK_H
#include <stdint.h>
#include <stddef.h>
#include <stdbool.h>
// Binary framing for the phone <-> ESP32-C5 link (Phase 03). Deliberately NOT the same UART as
// the ESP-IDF console/ESP_LOG output (UART0, see sdkconfig CONFIG_ESP_CONSOLE_UART_NUM=0) -
// mixing binary frames with human-readable log text on one wire would corrupt both. This runs
// on a dedicated UART (see SERIAL_LINK_UART_NUM / TX / RX pins below - CHANGE THESE to match
// your board's actual wiring from the USB-C connector's UART bridge to ESP32-C5 GPIOs).
//
// Frame format (both directions, symmetric):
// [0xAA][0x55][type:1][length:2 LE][payload: length bytes][crc16:2 LE]
// CRC16 is CRC-16/CCITT-FALSE (poly 0x1021, init 0xFFFF, no reflect, no xorout), computed over
// type + length + payload only (not the two sync bytes). Same algorithm must be used on the
// Kotlin side (see app SerialFrame.kt) - frames that don't checksum are silently dropped.
//
// Direction / types:
// SERIAL_MSG_CAM_TX (0x01), phone -> ESP32: payload is a raw CAM UPER byte string, already
// built by the phone (position/speed/heading/yaw rate baked in). On receipt the ESP32
// immediately GeoNetworking-wraps and transmits it - this IS the transmit clock now, there
// is no independent on-chip timer. See main.c's rx-driven tx path.
// SERIAL_MSG_CAM_RX (0x02), ESP32 -> phone: payload is [rssi:1 signed][CAM UPER bytes...] - a
// CAM received over the air, already stripped of its 802.11/LLC-SNAP/GeoNetworking/BTP-B
// framing by gn_unwrap.c. The phone never sees raw 802.11 frames. No station id is carried
// separately - CAM's own ItsPduHeader.stationID (the first field inside the UPER bytes) is
// already the meaningful identifier; see gn_unwrap.h for why a second one isn't added here.
// SERIAL_MSG_STATUS (0x03), ESP32 -> phone: 1-byte heartbeat (0 = ok), sent periodically so
// the phone can distinguish "link idle" from "link dead" independent of CAM traffic.
#define SERIAL_MSG_CAM_TX 0x01
#define SERIAL_MSG_CAM_RX 0x02
#define SERIAL_MSG_STATUS 0x03
// CHANGE THESE to match your board's actual USB-C -> UART bridge wiring. UART0 is already
// claimed by the console/ESP_LOG; picking UART1 here to stay clear of it. These are common
// free GPIOs on ESP32-C5 devkits but are NOT guaranteed free on your specific board - check
// your schematic before flashing.
#define SERIAL_LINK_UART_NUM 1
#define SERIAL_LINK_TX_GPIO 4
#define SERIAL_LINK_RX_GPIO 5
#define SERIAL_LINK_BAUD 115200
// Max CAM payload this link will carry. cam.c sizes its own encode buffer at 96 bytes; 160
// gives headroom for the RX path's extra station_id+rssi prefix plus margin.
#define SERIAL_LINK_MAX_PAYLOAD 160
// Initializes the dedicated UART and its background RX-framing task. Call once from app_main,
// after nvs/event loop init. `on_cam_tx` is invoked (from the RX task's context - keep it fast,
// it blocks the next frame's parsing) whenever a complete, checksummed SERIAL_MSG_CAM_TX frame
// arrives from the phone.
typedef void (*serial_link_cam_tx_cb_t)(const uint8_t *cam_uper, int cam_len);
void serial_link_init(serial_link_cam_tx_cb_t on_cam_tx);
// Sends a SERIAL_MSG_CAM_RX frame to the phone: rssi + the CAM UPER bytes gn_unwrap.c extracted
// from an over-the-air frame. Returns true if the frame was written to the UART (not an
// end-to-end ack - the phone may still drop it, e.g. serial buffer overrun).
bool serial_link_send_cam_rx(int8_t rssi, const uint8_t *cam_uper, int cam_len);
// Sends a 1-byte SERIAL_MSG_STATUS heartbeat frame.
bool serial_link_send_status(uint8_t status);
#endif
+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"
File diff suppressed because it is too large Load Diff
+3
View File
@@ -10,6 +10,9 @@ dependencyResolutionManagement {
repositories { repositories {
google() google()
mavenCentral() mavenCentral()
// usb-serial-for-android (com.github.mik3y) is only published via JitPack, not Maven
// Central - needed for the ESP32-C5 USB-serial transport (Phase 03).
maven(url = "https://jitpack.io")
} }
} }