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:
Generated
+27
@@ -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.
@@ -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(
|
||||||
@@ -222,6 +270,7 @@ fun ConnectionSettingsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun LanguageSettingsScreen(onBack: () -> Unit) {
|
fun LanguageSettingsScreen(onBack: () -> Unit) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
+1
Submodule its-g5-receiver-firmware added at 91d6511fe6
@@ -0,0 +1,9 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.16)
|
||||||
|
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
|
||||||
|
|
||||||
|
# No longer need -Wl,-zmuldefs here - that was only for main/wifi_patches.c's
|
||||||
|
# symbol-override attempt (which didn't work anyway; see docs/04-transmit-setup.md),
|
||||||
|
# and that file is no longer part of the build. Superseded by main/tx_custom.c,
|
||||||
|
# which bypasses the gate at a different layer instead of trying to override it.
|
||||||
|
|
||||||
|
project(obu_firmware)
|
||||||
@@ -0,0 +1,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).
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
#ifndef CAM_H
|
||||||
|
#define CAM_H
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <stddef.h>
|
||||||
|
|
||||||
|
// Minimal CAM (Cooperative Awareness Message) per ETSI EN 302 637-2 v1.4.1
|
||||||
|
// (CAM-PDU-Descriptions) + TS 102 894-2 v1.3.1 (CDD / ITS-Container), matching
|
||||||
|
// the field set the working Rust reference (esp32-c_its-companion, feat/tx-cam,
|
||||||
|
// src/applogic/cam_tx.rs) transmits:
|
||||||
|
// - ItsPduHeader (protocolVersion 2, messageID 2 = cam)
|
||||||
|
// - CoopAwareness { generationDeltaTime, camParameters }
|
||||||
|
// - CamParameters {
|
||||||
|
// basicContainer { stationType, referencePosition },
|
||||||
|
// highFrequencyContainer = basicVehicleContainerHighFrequency { ... },
|
||||||
|
// lowFrequencyContainer = basicVehicleContainerLowFrequency { ... }
|
||||||
|
// }
|
||||||
|
// All vehicle-dynamics fields we don't measure are encoded as their ASN.1
|
||||||
|
// "unavailable" value. Speed is a real 0 (correct for a stationary station).
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
uint32_t station_id;
|
||||||
|
uint8_t station_type; // StationType(0..255): 5 = passengerCar
|
||||||
|
uint16_t generation_delta_time; // TimestampIts mod 65536 (ms); 0 until a real clock is wired
|
||||||
|
int32_t latitude_tenmicrodeg; // Latitude, 1/10 microdegree
|
||||||
|
int32_t longitude_tenmicrodeg; // Longitude, 1/10 microdegree
|
||||||
|
uint16_t speed_cm_s; // SpeedValue, 0.01 m/s units (0 = stationary)
|
||||||
|
uint16_t heading_ddeg; // HeadingValue, 0.1 deg units (0..3600), 3601 = unavailable
|
||||||
|
uint16_t vehicle_length_dm; // VehicleLengthValue(1..1023), 10cm steps
|
||||||
|
uint8_t vehicle_width_dm; // VehicleWidth(1..62), 10cm steps
|
||||||
|
} cam_fields_t;
|
||||||
|
|
||||||
|
// Encodes the CAM as ASN.1 UPER. Returns bytes written, or -1 if buf too small.
|
||||||
|
int cam_encode(const cam_fields_t *f, uint8_t *buf, size_t buf_len);
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
#include "denm.h"
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
// Minimal MSB-first bit packer - ASN.1 UPER is a bitstream, not a byte
|
||||||
|
// stream, so we can't just memcpy structs.
|
||||||
|
typedef struct {
|
||||||
|
uint8_t *buf;
|
||||||
|
size_t buf_len;
|
||||||
|
size_t bit_pos;
|
||||||
|
} bitwriter_t;
|
||||||
|
|
||||||
|
static void bw_init(bitwriter_t *bw, uint8_t *buf, size_t len)
|
||||||
|
{
|
||||||
|
bw->buf = buf;
|
||||||
|
bw->buf_len = len;
|
||||||
|
bw->bit_pos = 0;
|
||||||
|
memset(buf, 0, len);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void bw_put_bits(bitwriter_t *bw, uint64_t value, int nbits)
|
||||||
|
{
|
||||||
|
for (int i = nbits - 1; i >= 0; i--) {
|
||||||
|
size_t byte_idx = bw->bit_pos / 8;
|
||||||
|
int bit_idx = 7 - (int)(bw->bit_pos % 8);
|
||||||
|
if (byte_idx >= bw->buf_len) {
|
||||||
|
return; // overflow guard - silently truncates, check return value of denm_encode
|
||||||
|
}
|
||||||
|
uint8_t bit = (value >> i) & 1;
|
||||||
|
bw->buf[byte_idx] = (uint8_t)(bw->buf[byte_idx] | (bit << bit_idx));
|
||||||
|
bw->bit_pos++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static size_t bw_byte_len(const bitwriter_t *bw)
|
||||||
|
{
|
||||||
|
return (bw->bit_pos + 7) / 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
int denm_encode(const denm_fields_t *f, uint8_t *buf, size_t buf_len)
|
||||||
|
{
|
||||||
|
bitwriter_t bw;
|
||||||
|
bw_init(&bw, buf, buf_len);
|
||||||
|
|
||||||
|
// ---- ItsPduHeader ---- (SEQUENCE, no OPTIONALs, no "..." -> no preamble at all)
|
||||||
|
bw_put_bits(&bw, 2, 8); // protocolVersion INTEGER(0..255) = 2
|
||||||
|
bw_put_bits(&bw, 1, 8); // messageID INTEGER(0..255) = denm(1)
|
||||||
|
bw_put_bits(&bw, f->station_id, 32); // stationID = StationID INTEGER(0..4294967295) = 32 bits
|
||||||
|
|
||||||
|
// ---- DenmPayload (DecentralizedEnvironmentalNotificationMessage) ----
|
||||||
|
// No "..." on this SEQUENCE -> no extension bit, just the 3-bit
|
||||||
|
// optional-component preamble in declared order: situation, location,
|
||||||
|
// alacarte. "No additional parameters" means location/alacarte stay
|
||||||
|
// absent.
|
||||||
|
bw_put_bits(&bw, 1, 1); // situation present
|
||||||
|
bw_put_bits(&bw, 0, 1); // location absent
|
||||||
|
bw_put_bits(&bw, 0, 1); // alacarte absent
|
||||||
|
|
||||||
|
// ---- ManagementContainer ----
|
||||||
|
// This SEQUENCE ends in "..." in the real ASN.1 module -> extensible,
|
||||||
|
// so it needs a leading 1-bit extension flag (0 = no extension
|
||||||
|
// additions used) BEFORE the 5-bit optional/default preamble
|
||||||
|
// (termination, relevanceDistance, relevanceTrafficDirection,
|
||||||
|
// validityDuration, transmissionInterval, in that declared order). An
|
||||||
|
// earlier version of this code omitted the extension bit entirely,
|
||||||
|
// which would shift every single bit after it and corrupt the whole
|
||||||
|
// rest of the message for any spec-compliant decoder.
|
||||||
|
bw_put_bits(&bw, 0, 1); // ManagementContainer extension bit: none used
|
||||||
|
bw_put_bits(&bw, f->terminate ? 1 : 0, 1); // termination present only when cancelling
|
||||||
|
bw_put_bits(&bw, 0, 1); // relevanceDistance absent
|
||||||
|
bw_put_bits(&bw, 0, 1); // relevanceTrafficDirection absent
|
||||||
|
bw_put_bits(&bw, 0, 1); // validityDuration absent -> default 600s applies
|
||||||
|
bw_put_bits(&bw, 0, 1); // transmissionInterval absent
|
||||||
|
|
||||||
|
// actionID = ActionID{ originatingStationID StationID(32), sequenceNumber
|
||||||
|
// SequenceNumber(0..65535, 16 bits) } - no OPTIONALs/"..." -> no preamble.
|
||||||
|
// Keep sequenceNumber constant across repeats of the SAME event - it's
|
||||||
|
// the caller's job (see main.c) to only bump it on a genuinely new event
|
||||||
|
// and reuse it for that event's eventual termination message.
|
||||||
|
bw_put_bits(&bw, f->station_id, 32);
|
||||||
|
bw_put_bits(&bw, f->sequence_number, 16);
|
||||||
|
|
||||||
|
// detectionTime / referenceTime: TimestampIts INTEGER(0..4398046511103)
|
||||||
|
// = exactly 42 bits (2^42), ms since 2004-01-01T00:00:00Z. NOT WIRED UP
|
||||||
|
// YET - there's no RTC/NTP sync in this skeleton, so this is 0 (decodes
|
||||||
|
// as 2004-01-01). Wire in SNTP or a GNSS UTC fix before this is real.
|
||||||
|
bw_put_bits(&bw, 0, 42);
|
||||||
|
bw_put_bits(&bw, 0, 42);
|
||||||
|
|
||||||
|
// termination VALUE - only emitted when present (per the preamble bit
|
||||||
|
// above - UPER never encodes a value for an absent optional component).
|
||||||
|
// Termination ::= ENUMERATED{isCancellation(0), isNegation(1)}, no
|
||||||
|
// "...", 2 values -> 1 bit.
|
||||||
|
if (f->terminate) {
|
||||||
|
bw_put_bits(&bw, 0, 1); // isCancellation
|
||||||
|
}
|
||||||
|
|
||||||
|
// eventPosition (ReferencePosition ::= SEQUENCE{latitude, longitude,
|
||||||
|
// positionConfidenceEllipse, altitude} - no OPTIONALs/"..." -> no
|
||||||
|
// preamble, straight concatenation). Widths below are each field's
|
||||||
|
// exact constrained-INTEGER range size from ITS-Container.asn, encoded
|
||||||
|
// as an unsigned offset from the type's declared minimum - NOT assumed
|
||||||
|
// to match neighboring fields (latitude and longitude are different
|
||||||
|
// widths, which is easy to miss).
|
||||||
|
// Latitude ::= INTEGER(-900000000..900000001) -> range 1800000002 -> 31 bits
|
||||||
|
uint32_t lat_offset = (uint32_t)(f->latitude_tenmicrodeg - (-900000000));
|
||||||
|
bw_put_bits(&bw, lat_offset, 31);
|
||||||
|
// Longitude ::= INTEGER(-1800000000..1800000001) -> range 3600000002 -> 32 bits
|
||||||
|
uint32_t lon_offset = (uint32_t)(f->longitude_tenmicrodeg - (-1800000000));
|
||||||
|
bw_put_bits(&bw, lon_offset, 32);
|
||||||
|
// PosConfidenceEllipse ::= SEQUENCE{semiMajorConfidence, semiMinorConfidence,
|
||||||
|
// semiMajorOrientation} - no preamble.
|
||||||
|
// SemiAxisLength ::= INTEGER(0..4095) -> 12 bits (not 16 - this was wrong before)
|
||||||
|
bw_put_bits(&bw, 4095, 12); // semiMajorConfidence: unavailable
|
||||||
|
bw_put_bits(&bw, 4095, 12); // semiMinorConfidence: unavailable
|
||||||
|
// HeadingValue ::= INTEGER(0..3601) -> 12 bits (not 16 - this was wrong before)
|
||||||
|
bw_put_bits(&bw, 3601, 12); // semiMajorOrientation: unavailable
|
||||||
|
// Altitude ::= SEQUENCE{altitudeValue, altitudeConfidence} - no preamble.
|
||||||
|
// AltitudeValue ::= INTEGER(-100000..800001) -> range 900002 -> 20 bits
|
||||||
|
// (not 24 - this was wrong before), offset-encoded from -100000.
|
||||||
|
bw_put_bits(&bw, 900001, 20); // 800001 ("unavailable") - (-100000) = 900001
|
||||||
|
// AltitudeConfidence ::= ENUMERATED, 16 named values, no "..." -> 4 bits
|
||||||
|
bw_put_bits(&bw, 15, 4); // unavailable
|
||||||
|
|
||||||
|
// stationType: StationType INTEGER(0..255) -> 8 bits fixed regardless of
|
||||||
|
// how sparse the named values are.
|
||||||
|
bw_put_bits(&bw, f->station_type, 8);
|
||||||
|
|
||||||
|
// ---- SituationContainer ----
|
||||||
|
// This SEQUENCE also ends in "..." -> its own 1-bit extension flag,
|
||||||
|
// THEN the 2-bit preamble (linkedCause, eventHistory), THEN the
|
||||||
|
// mandatory field values. An earlier version of this code put the
|
||||||
|
// linkedCause/eventHistory bits at the END instead of the start, and
|
||||||
|
// had no extension bit at all - both are structural bugs that would
|
||||||
|
// desync any spec-compliant decoder from this point on.
|
||||||
|
bw_put_bits(&bw, 0, 1); // SituationContainer extension bit: none used
|
||||||
|
bw_put_bits(&bw, 0, 1); // linkedCause absent
|
||||||
|
bw_put_bits(&bw, 0, 1); // eventHistory absent
|
||||||
|
|
||||||
|
// informationQuality: InformationQuality INTEGER(0..7) -> 3 bits
|
||||||
|
bw_put_bits(&bw, 1, 3); // low quality - no real sensor input, just the hazard-light GPIO
|
||||||
|
|
||||||
|
// eventType: CauseCode ::= SEQUENCE{causeCode, subCauseCode, ...} - this
|
||||||
|
// inner SEQUENCE is ALSO extensible ("..."), so it gets its own leading
|
||||||
|
// extension bit before its two mandatory fields.
|
||||||
|
bw_put_bits(&bw, 0, 1); // CauseCode extension bit: none used
|
||||||
|
bw_put_bits(&bw, f->cause_code, 8); // CauseCodeType INTEGER(0..255) -> 8 bits
|
||||||
|
bw_put_bits(&bw, f->sub_cause_code, 8); // SubCauseCodeType INTEGER(0..255) -> 8 bits
|
||||||
|
|
||||||
|
// linkedCause / eventHistory: both absent, already signalled in the
|
||||||
|
// preamble above - UPER writes no value bits for them.
|
||||||
|
|
||||||
|
return (int)bw_byte_len(&bw);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
#ifndef DENM_H
|
||||||
|
#define DENM_H
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <stdbool.h>
|
||||||
|
|
||||||
|
// Full CauseCodeType enumeration, straight from the authoritative source:
|
||||||
|
// ETSI TS 102 894-2 (CDD) ITS-Container.asn, CauseCodeType definition.
|
||||||
|
// (Values 1/2/3/14/26/27/91/94/95/97 were already cross-checked earlier
|
||||||
|
// against a real captured DENM; the rest are now confirmed the same way,
|
||||||
|
// from the actual ASN.1 module rather than guessed.)
|
||||||
|
#define DENM_CAUSE_RESERVED 0
|
||||||
|
#define DENM_CAUSE_TRAFFIC_CONDITION 1
|
||||||
|
#define DENM_CAUSE_ACCIDENT 2
|
||||||
|
#define DENM_CAUSE_ROADWORKS 3
|
||||||
|
#define DENM_CAUSE_IMPASSABILITY 5
|
||||||
|
#define DENM_CAUSE_ADVERSE_WEATHER_ADHESION 6
|
||||||
|
#define DENM_CAUSE_AQUAPLANNING 7
|
||||||
|
#define DENM_CAUSE_HAZARDOUS_LOCATION_SURFACE_CONDITION 9
|
||||||
|
#define DENM_CAUSE_HAZARDOUS_LOCATION_OBSTACLE_ON_ROAD 10
|
||||||
|
#define DENM_CAUSE_HAZARDOUS_LOCATION_ANIMAL_ON_ROAD 11
|
||||||
|
#define DENM_CAUSE_HUMAN_PRESENCE_ON_ROAD 12
|
||||||
|
#define DENM_CAUSE_WRONG_WAY_DRIVING 14
|
||||||
|
#define DENM_CAUSE_RESCUE_AND_RECOVERY_WORK_IN_PROGRESS 15
|
||||||
|
#define DENM_CAUSE_ADVERSE_WEATHER_EXTREME 17
|
||||||
|
#define DENM_CAUSE_ADVERSE_WEATHER_VISIBILITY 18
|
||||||
|
#define DENM_CAUSE_ADVERSE_WEATHER_PRECIPITATION 19
|
||||||
|
#define DENM_CAUSE_SLOW_VEHICLE 26
|
||||||
|
#define DENM_CAUSE_DANGEROUS_END_OF_QUEUE 27
|
||||||
|
#define DENM_CAUSE_VEHICLE_BREAKDOWN 91
|
||||||
|
#define DENM_CAUSE_POST_CRASH 92
|
||||||
|
#define DENM_CAUSE_HUMAN_PROBLEM 93
|
||||||
|
#define DENM_CAUSE_STATIONARY_VEHICLE 94
|
||||||
|
#define DENM_CAUSE_EMERGENCY_VEHICLE_APPROACHING 95
|
||||||
|
#define DENM_CAUSE_HAZARDOUS_LOCATION_DANGEROUS_CURVE 96
|
||||||
|
#define DENM_CAUSE_COLLISION_RISK 97
|
||||||
|
#define DENM_CAUSE_SIGNAL_VIOLATION 98
|
||||||
|
#define DENM_CAUSE_DANGEROUS_SITUATION 99
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
uint32_t station_id;
|
||||||
|
uint16_t sequence_number; // keep constant across repeats of the SAME event; only bump on a genuinely new event
|
||||||
|
uint8_t cause_code; // e.g. 94 = stationaryVehicle
|
||||||
|
uint8_t sub_cause_code; // 0 = unspecified
|
||||||
|
uint8_t station_type; // StationType, e.g. 5 = passengerCar - match geonet_wrap_shb's station_type param
|
||||||
|
int32_t latitude_tenmicrodeg; // 1/10 microdegree; 0 = placeholder/unavailable
|
||||||
|
int32_t longitude_tenmicrodeg; // 1/10 microdegree; 0 = placeholder/unavailable
|
||||||
|
bool terminate; // true = encode this as a Termination(isCancellation) message instead of a normal update
|
||||||
|
} denm_fields_t;
|
||||||
|
|
||||||
|
// Encodes a minimal DENM (ItsPduHeader + ManagementContainer +
|
||||||
|
// SituationContainer only - no location/alacarte containers) as ASN.1 UPER,
|
||||||
|
// per the actual ETSI EN 302 637-3 / TS 102 894-2 ASN.1 modules (fetched
|
||||||
|
// from forge.etsi.org, not reconstructed from memory). Returns bytes
|
||||||
|
// written, or -1 if buf too small.
|
||||||
|
//
|
||||||
|
// Two things worth knowing if you're reading this against the modules
|
||||||
|
// yourself: ManagementContainer, SituationContainer, and CauseCode are all
|
||||||
|
// declared with a trailing "..." (extensible), which means each needs its
|
||||||
|
// own leading extension bit in the UPER encoding - easy to miss, and this
|
||||||
|
// code got it wrong in an earlier version. Field bit-widths below (e.g.
|
||||||
|
// latitude=31 bits, longitude=32 bits, position-confidence fields=12 bits,
|
||||||
|
// altitudeValue=20 bits) are derived directly from each type's declared
|
||||||
|
// INTEGER constraint range, not assumed to match neighboring fields.
|
||||||
|
int denm_encode(const denm_fields_t *f, uint8_t *buf, size_t buf_len);
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
#include "dot11p.h"
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
int dot11p_build_frame(const uint8_t *gn_payload, int gn_len,
|
||||||
|
const uint8_t src_mac[6],
|
||||||
|
uint8_t *out, size_t out_len, bool qos)
|
||||||
|
{
|
||||||
|
static const uint8_t broadcast[6] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF};
|
||||||
|
static const uint8_t llc_snap[8] = {0xAA, 0xAA, 0x03, 0x00, 0x00, 0x00, 0x89, 0x47};
|
||||||
|
|
||||||
|
int hdr_len = qos ? 26 : 24; // QoS Data adds a 2-byte QoS Control field
|
||||||
|
int total = hdr_len + 8 /* LLC/SNAP */ + gn_len;
|
||||||
|
if ((size_t)total > out_len) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t *p = out;
|
||||||
|
|
||||||
|
// Frame Control: version=0, type=Data(2), subtype=QoS Data(8) -> bytes
|
||||||
|
// 0x88 0x00. This is what real ITS-G5 hardware actually transmits.
|
||||||
|
//
|
||||||
|
// Back on QoS Data again (previously downgraded to non-QoS, subtype 0,
|
||||||
|
// as a working-but-nonstandard fallback - see git history / old comments
|
||||||
|
// here for that whole detour). What changed: main.c no longer calls
|
||||||
|
// esp_wifi_80211_tx() at all - it now goes through
|
||||||
|
// esp_wifi_80211_tx_custom() (tx_custom.c, pulled from
|
||||||
|
// opentrafficmap/its-g5-receiver-firmware_txenabled), which bypasses the
|
||||||
|
// frame-type sanity check entirely by never calling the code path that
|
||||||
|
// contains it. Frame subtype is no longer gated, so there's no reason
|
||||||
|
// left to avoid matching real hardware here.
|
||||||
|
// Frame Control byte 0: version=0, type=Data(2). Subtype: QoS Data(8)=0x88
|
||||||
|
// for the tx_custom path, or plain Data(0)=0x08 for the standard
|
||||||
|
// esp_wifi_80211_tx() path (which rejects QoS Data outright).
|
||||||
|
*p++ = qos ? 0x88 : 0x08; *p++ = 0x00;
|
||||||
|
// Duration
|
||||||
|
*p++ = 0x00; *p++ = 0x00;
|
||||||
|
// Addr1 = destination = broadcast
|
||||||
|
memcpy(p, broadcast, 6); p += 6;
|
||||||
|
// Addr2 = source (our pseudonym)
|
||||||
|
memcpy(p, src_mac, 6); p += 6;
|
||||||
|
// Addr3 = BSSID = broadcast (no BSS exists in OCB mode)
|
||||||
|
memcpy(p, broadcast, 6); p += 6;
|
||||||
|
// Sequence control - left at 0; en_sys_seq=true fills this in for us
|
||||||
|
*p++ = 0x00; *p++ = 0x00;
|
||||||
|
// QoS Control field - only present in QoS Data frames
|
||||||
|
if (qos) {
|
||||||
|
*p++ = 0x00; *p++ = 0x00; // best-effort access category
|
||||||
|
}
|
||||||
|
|
||||||
|
// LLC/SNAP (Ethertype 0x8947 = GeoNetworking)
|
||||||
|
memcpy(p, llc_snap, 8); p += 8;
|
||||||
|
|
||||||
|
// GeoNetworking + BTP + DENM payload
|
||||||
|
memcpy(p, gn_payload, gn_len); p += gn_len;
|
||||||
|
|
||||||
|
return (int)(p - out);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
#ifndef DOT11P_H
|
||||||
|
#define DOT11P_H
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <stdbool.h>
|
||||||
|
|
||||||
|
// Wraps a GeoNetworking-layer payload in an 802.11 OCB frame: QoS Data
|
||||||
|
// (subtype 8, 26-byte header), matching real ITS-G5 hardware, broadcast, no
|
||||||
|
// BSS (Addr1=Addr3=broadcast), LLC/SNAP with Ethertype 0x8947
|
||||||
|
// (GeoNetworking's registered Ethertype). Output is ready to hand straight
|
||||||
|
// to esp_wifi_80211_tx_custom() (tx_custom.c) - NOT esp_wifi_80211_tx(),
|
||||||
|
// which rejects this frame type outright. `src_mac` is used as Addr2 - pass
|
||||||
|
// the same 6 bytes you gave geonet_wrap_shb, since GN_ADDR's MID field is
|
||||||
|
// defined to be this same link-layer address. Returns bytes written, or -1
|
||||||
|
// if out buffer too small.
|
||||||
|
//
|
||||||
|
// History: this used to be downgraded to non-QoS Data (subtype 0) because
|
||||||
|
// esp_wifi_80211_tx() rejects QoS Data ("unsupport QoS frame type" / esp_err
|
||||||
|
// 258) and an attempted linker-override bypass (old main/wifi_patches.c)
|
||||||
|
// didn't work. Restored to QoS Data now that main.c transmits via
|
||||||
|
// esp_wifi_80211_tx_custom() instead, which bypasses that gate entirely
|
||||||
|
// (see tx_custom.c) - so there's no longer a reason to deviate from the
|
||||||
|
// real frame format.
|
||||||
|
// qos=true -> QoS Data (subtype 8, 26-byte header) for esp_wifi_80211_tx_custom()
|
||||||
|
// qos=false -> plain Data (subtype 0, 24-byte header) which the STANDARD
|
||||||
|
// esp_wifi_80211_tx() accepts (used for the standard-TX isolation test)
|
||||||
|
int dot11p_build_frame(const uint8_t *gn_payload, int gn_len,
|
||||||
|
const uint8_t src_mac[6],
|
||||||
|
uint8_t *out, size_t out_len, bool qos);
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
#include "geonet.h"
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
int geonet_wrap_shb(const uint8_t *its_payload, int its_len,
|
||||||
|
const uint8_t mac[6], uint8_t station_type,
|
||||||
|
int32_t latitude_tenmicrodeg, int32_t longitude_tenmicrodeg,
|
||||||
|
uint16_t btp_dest_port,
|
||||||
|
uint8_t *out, size_t out_len)
|
||||||
|
{
|
||||||
|
// GN Basic Header (4) + GN Common Header (8) + SHB source LPV (24)
|
||||||
|
// + BTP-B header (4) + ITS payload
|
||||||
|
int total = 4 + 8 + 24 + 4 + its_len;
|
||||||
|
if ((size_t)total > out_len) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t *p = out;
|
||||||
|
|
||||||
|
// ---- GN Basic Header (4 bytes) ---- (EN 302 636-4-1 clause 9.6)
|
||||||
|
*p++ = (uint8_t)((1 << 4) | 1); // version=1, NextHeader=1 (Common Header, unsecured)
|
||||||
|
*p++ = 0x00; // reserved
|
||||||
|
*p++ = 0x83; // lifetime (~60s in the base/multiplier encoding) - tune if needed
|
||||||
|
*p++ = 1; // remaining hop limit = 1 (SHB single-hop; matches CAM in the Rust reference)
|
||||||
|
|
||||||
|
// ---- GN Common Header (8 bytes) ---- (clause 9.7)
|
||||||
|
*p++ = (uint8_t)((2 << 4) | 0); // NextHeader=2 (BTP-B), reserved nibble
|
||||||
|
// HeaderType=5 (TSB), HeaderSubtype=0 (SINGLE_HOP) per table 9 - this is
|
||||||
|
// the actual encoding for single-hop broadcast. An earlier version of
|
||||||
|
// this code used (2,0), which is GEOUNICAST - wrong header type entirely
|
||||||
|
// for a broadcast frame; real receivers would try to match the
|
||||||
|
// destination-address extended header GeoUnicast expects and mishandle
|
||||||
|
// or reject the packet.
|
||||||
|
*p++ = (uint8_t)((5 << 4) | 0);
|
||||||
|
*p++ = 0x02; // traffic class: SCF=0, ChannelOffload=0, TC-ID=2 (clause 9.7.5)
|
||||||
|
*p++ = 0x80; // flags: bit0 = "is mobile" station (clause 9.7.2)
|
||||||
|
// Payload length = what follows the WHOLE GeoNetworking header
|
||||||
|
// (Basic+Common+Extended), i.e. BTP-B header + ITS payload only - does
|
||||||
|
// NOT include the 24-byte extended header itself. An earlier version of
|
||||||
|
// this code wrongly added the 24 bytes in here too.
|
||||||
|
uint16_t payload_len = (uint16_t)(4 + its_len);
|
||||||
|
*p++ = (uint8_t)(payload_len >> 8);
|
||||||
|
*p++ = (uint8_t)(payload_len & 0xFF);
|
||||||
|
*p++ = 1; // max hop limit = 1, matches basic header RHL (SHB single-hop)
|
||||||
|
*p++ = 0x00; // reserved
|
||||||
|
|
||||||
|
// ---- SHB extended header: Source Long Position Vector (24 bytes) ----
|
||||||
|
// (clause 9.5.2). GN_ADDR (8 bytes) is itself structured, not a raw
|
||||||
|
// pseudonym (clause 9.5.1): bit0 M-flag(0=auto-derived), bits1-5 ITS-S
|
||||||
|
// type (5-bit), bits6-15 reserved(=0), then octets2-7 = MID, which is
|
||||||
|
// defined to BE the link-layer (802.11) address - so this must match
|
||||||
|
// the source address dot11p_build_frame uses, not just "look similar."
|
||||||
|
uint8_t gn_addr[8];
|
||||||
|
gn_addr[0] = (uint8_t)((0 << 7) | ((station_type & 0x1F) << 2)); // M=0, ST=station_type, top 2 reserved bits=0
|
||||||
|
gn_addr[1] = 0x00; // remaining 8 reserved bits
|
||||||
|
memcpy(&gn_addr[2], mac, 6); // MID = link-layer address
|
||||||
|
memcpy(p, gn_addr, 8); p += 8;
|
||||||
|
// Timestamp (4 bytes, ms since 2004-01-01 mod 2^32) - placeholder 0,
|
||||||
|
// same caveat as detectionTime in denm.c.
|
||||||
|
memset(p, 0, 4); p += 4;
|
||||||
|
// Latitude/Longitude (4+4 bytes, signed, big-endian, 1/10 microdegree) -
|
||||||
|
// fixed-width binary fields, not UPER bit-packed.
|
||||||
|
uint32_t lat_u = (uint32_t)latitude_tenmicrodeg;
|
||||||
|
*p++ = (uint8_t)(lat_u >> 24); *p++ = (uint8_t)(lat_u >> 16);
|
||||||
|
*p++ = (uint8_t)(lat_u >> 8); *p++ = (uint8_t)(lat_u);
|
||||||
|
uint32_t lon_u = (uint32_t)longitude_tenmicrodeg;
|
||||||
|
*p++ = (uint8_t)(lon_u >> 24); *p++ = (uint8_t)(lon_u >> 16);
|
||||||
|
*p++ = (uint8_t)(lon_u >> 8); *p++ = (uint8_t)(lon_u);
|
||||||
|
// PAI(1 bit) + Speed(15 bits), packed into 2 bytes: 0 = PAI false,
|
||||||
|
// speed 0 - which is actually correct semantics for a STATIONARY
|
||||||
|
// vehicle beacon, not just a placeholder.
|
||||||
|
*p++ = 0x00; *p++ = 0x00;
|
||||||
|
// Heading (16 bits, 0.1 degree units): 0 = due north / unavailable
|
||||||
|
*p++ = 0x00; *p++ = 0x00;
|
||||||
|
|
||||||
|
// ---- BTP-B header (4 bytes) ----
|
||||||
|
*p++ = (uint8_t)(btp_dest_port >> 8);
|
||||||
|
*p++ = (uint8_t)(btp_dest_port & 0xFF);
|
||||||
|
*p++ = 0x00; *p++ = 0x00; // destination port info, unused for BTP-B
|
||||||
|
|
||||||
|
// ---- ITS payload (DENM UPER bytes) ----
|
||||||
|
memcpy(p, its_payload, its_len);
|
||||||
|
p += its_len;
|
||||||
|
|
||||||
|
return (int)(p - out);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
#ifndef GEONET_H
|
||||||
|
#define GEONET_H
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <stddef.h>
|
||||||
|
|
||||||
|
// Wraps an ITS application payload (e.g. from denm_encode) with a minimal
|
||||||
|
// GeoNetworking Basic Header + Common Header + Single-Hop-Broadcast
|
||||||
|
// extended header (HeaderType=TSB(5), HeaderSubtype=SINGLE_HOP(0), per
|
||||||
|
// ETSI EN 302 636-4-1 table 9), then prepends a BTP-B header addressed to
|
||||||
|
// the DENM service port (2002).
|
||||||
|
//
|
||||||
|
// `mac` is the 6-byte pseudonym/link-layer address - pass the SAME address
|
||||||
|
// you hand to dot11p_build_frame's src address, since GN_ADDR's MID field
|
||||||
|
// (the last 6 bytes of the 8-byte GN_ADDR) is defined to BE that
|
||||||
|
// link-layer address (EN 302 636-4-1 clause 9.5.1). `station_type` is the
|
||||||
|
// 5-bit ITS-S type from the same clause (5 = passengerCar) and gets packed
|
||||||
|
// into GN_ADDR alongside the address.
|
||||||
|
//
|
||||||
|
// `latitude_tenmicrodeg`/`longitude_tenmicrodeg` go into the Source Long
|
||||||
|
// Position Vector (clause 9.5.2) as plain 32-bit signed big-endian fields -
|
||||||
|
// NOT UPER bit-packed like the DENM payload's position fields, this is a
|
||||||
|
// fixed-width binary protocol. Pass the SAME values you gave denm_encode's
|
||||||
|
// eventPosition, so the GN-layer position and the DENM's own claimed
|
||||||
|
// position agree.
|
||||||
|
//
|
||||||
|
// Deliberate simplification: real DENM dissemination normally uses
|
||||||
|
// GeoBroadcast (GBC, HeaderType=4) so RSUs/OBUs can forward it across an
|
||||||
|
// area - that needs a sequence number + geo-area fields this skeleton
|
||||||
|
// doesn't build yet. Single-hop broadcast is simpler and is the
|
||||||
|
// best-tested decode path in the receiver firmware you already have
|
||||||
|
// working (same extended header shape as CAM). Fine for a single-vehicle
|
||||||
|
// beacon; revisit if you need real multi-hop forwarding later.
|
||||||
|
//
|
||||||
|
// `btp_dest_port` is the BTP-B destination port for the service being carried
|
||||||
|
// (ETSI TS 103 248): 2001 = CAM, 2002 = DENM, 2003 = MAPEM, 2004 = SPATEM, ...
|
||||||
|
//
|
||||||
|
// Returns bytes written, or -1 if out buffer too small.
|
||||||
|
int geonet_wrap_shb(const uint8_t *its_payload, int its_len,
|
||||||
|
const uint8_t mac[6], uint8_t station_type,
|
||||||
|
int32_t latitude_tenmicrodeg, int32_t longitude_tenmicrodeg,
|
||||||
|
uint16_t btp_dest_port,
|
||||||
|
uint8_t *out, size_t out_len);
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
// Copied verbatim (no logic changes) from opentrafficmap/its-g5-receiver-firmware_txenabled,
|
||||||
|
// main/tx_custom.c (https://codeberg.org/opentrafficmap/its-g5-receiver-firmware_txenabled),
|
||||||
|
// same authors as the receiver firmware (V2X2MAP) already used on the RX side of this
|
||||||
|
// project. Same chip (ESP32-C5), same class of problem (getting a raw 802.11 frame past
|
||||||
|
// esp_wifi_80211_tx()'s built-in frame-type gate), and a proven-different approach from our
|
||||||
|
// own abandoned main/wifi_patches.c attempt - see docs/04-transmit-setup.md for why that one
|
||||||
|
// didn't work and why this one is expected to.
|
||||||
|
//
|
||||||
|
// WHAT THIS DOES DIFFERENTLY FROM esp_wifi_80211_tx(): it doesn't call the public API at all.
|
||||||
|
// It reaches one layer deeper into the closed WiFi driver - ic_ebuf_alloc() (allocates an
|
||||||
|
// internal driver buffer), ieee80211_post_hmac_tx() (submits that buffer straight to the MAC
|
||||||
|
// for transmission) - and never goes through the code path that contains the QoS-frame-type
|
||||||
|
// sanity check that was rejecting us. Notice line "esp_err_t result = 0;//ieee80211_raw_frame_
|
||||||
|
// sanity_check(...)" below: the upstream authors don't override that check (like our old
|
||||||
|
// wifi_patches.c tried to), they just never call the function that calls it.
|
||||||
|
//
|
||||||
|
// REAL RISK, carried over from upstream, not introduced by us: this skips ALL frame-type and
|
||||||
|
// sanity validation, same caveat as our old override attempt. A malformed frame from a bug
|
||||||
|
// elsewhere in our own code could behave worse (silent corruption, crash) than a clean
|
||||||
|
// rejection.
|
||||||
|
//
|
||||||
|
// UNVERIFIED FOR OUR EXACT TOOLCHAIN - things worth checking before trusting this blindly:
|
||||||
|
// 1. The symbols this depends on (ieee80211_post_hmac_tx, ic_ebuf_alloc, ic_get_default_sched,
|
||||||
|
// g_osi_funcs_p, g_wifi_global_lock) are undocumented/internal. We confirmed via `nm`
|
||||||
|
// earlier that ieee80211_raw_frame_sanity_check exists in OUR esp32c5/IDF libnet80211.a -
|
||||||
|
// we have NOT yet independently confirmed these other four/five symbols exist in our
|
||||||
|
// exact ESP-IDF version (as opposed to whatever version the upstream repo's pinned
|
||||||
|
// esp-idf submodule uses). If the linker can't find one of these, that's the first thing
|
||||||
|
// to check - see docs/04-transmit-setup.md for the nm command.
|
||||||
|
// 2. x_eb_txdesc_t / x_middle_data_t / x_ebuf_t below are REVERSE-ENGINEERED struct layouts
|
||||||
|
// of closed-source internal WiFi driver types, pinned only by a sizeof() static_assert -
|
||||||
|
// that assert catches a total-size mismatch but NOT a field-order/semantic mismatch if a
|
||||||
|
// different IDF version shuffled internal fields while keeping the same total size. If our
|
||||||
|
// ESP-IDF version differs meaningfully from upstream's, this could compile and link fine
|
||||||
|
// but write to the wrong offsets internally. Worth checking `idf.py --version` against
|
||||||
|
// whatever esp-idf commit opentrafficmap's repo has pinned as a submodule, as a rough
|
||||||
|
// compatibility signal (not a guarantee either way).
|
||||||
|
#include "esp_private/wifi_os_adapter.h"
|
||||||
|
#include "esp_wifi.h"
|
||||||
|
|
||||||
|
#include "tx_custom.h"
|
||||||
|
|
||||||
|
esp_err_t ieee80211_raw_frame_sanity_check(wifi_interface_t ifx, const void *buffer, int32_t len, bool en_sys_seq);
|
||||||
|
esp_err_t ieee80211_post_hmac_tx(void *ebuf);
|
||||||
|
void *ic_ebuf_alloc(const void *packet, uint32_t unknown, uint32_t len);
|
||||||
|
void *ic_get_default_sched(void);
|
||||||
|
|
||||||
|
extern wifi_osi_funcs_t *g_osi_funcs_p;
|
||||||
|
extern void *g_wifi_global_lock;
|
||||||
|
|
||||||
|
typedef struct x_eb_txdesc
|
||||||
|
{
|
||||||
|
uint32_t flags;
|
||||||
|
uint32_t field_4;
|
||||||
|
uint32_t field_8;
|
||||||
|
uint8_t rate;
|
||||||
|
uint8_t field_d;
|
||||||
|
uint8_t field_e;
|
||||||
|
uint8_t field_f;
|
||||||
|
uint32_t field_10;
|
||||||
|
uint32_t field_14;
|
||||||
|
uint32_t timestamp;
|
||||||
|
void* sched;
|
||||||
|
uint32_t field_20;
|
||||||
|
uint32_t field_24;
|
||||||
|
uint32_t field_28;
|
||||||
|
union {
|
||||||
|
uint32_t field_2c_32;
|
||||||
|
struct {
|
||||||
|
uint8_t field_2c;
|
||||||
|
uint8_t field_2d;
|
||||||
|
uint8_t field_2e;
|
||||||
|
uint8_t field_2f;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
union {
|
||||||
|
uint32_t field_30_32;
|
||||||
|
struct {
|
||||||
|
uint8_t field_30;
|
||||||
|
uint8_t field_31;
|
||||||
|
uint8_t field_32;
|
||||||
|
uint8_t field_33;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
uint32_t field_34;
|
||||||
|
uint32_t field_38;
|
||||||
|
uint32_t field_3c;
|
||||||
|
uint32_t field_40;
|
||||||
|
uint32_t field_44;
|
||||||
|
} x_eb_txdesc_t;
|
||||||
|
static_assert(sizeof(x_eb_txdesc_t) == 0x48);
|
||||||
|
|
||||||
|
typedef struct x_middle_data
|
||||||
|
{
|
||||||
|
uint32_t field_40;
|
||||||
|
uint8_t* buf;
|
||||||
|
uint32_t field_48;
|
||||||
|
uint32_t field_4c;
|
||||||
|
} x_middle_data_t;
|
||||||
|
static_assert(sizeof(x_middle_data_t) == 0x10);
|
||||||
|
|
||||||
|
typedef struct x_ebuf
|
||||||
|
{
|
||||||
|
uint32_t field_0;
|
||||||
|
x_middle_data_t* ds_head;
|
||||||
|
x_middle_data_t* ds_tail;
|
||||||
|
uint16_t field_c;
|
||||||
|
uint16_t field_e;
|
||||||
|
uint32_t extra_data_start;
|
||||||
|
uint16_t header_length;
|
||||||
|
uint32_t data_length;
|
||||||
|
uint16_t field_1c;
|
||||||
|
uint8_t alloc_type;
|
||||||
|
uint8_t field_1f;
|
||||||
|
uint32_t field_20;
|
||||||
|
uint8_t field_24;
|
||||||
|
uint8_t field_25;
|
||||||
|
uint8_t field_26;
|
||||||
|
uint8_t field_27;
|
||||||
|
uint32_t field_28;
|
||||||
|
uint8_t field_2c;
|
||||||
|
uint32_t field_30;
|
||||||
|
uint32_t next_free;
|
||||||
|
x_eb_txdesc_t* txdesc;
|
||||||
|
uint16_t field_3c;
|
||||||
|
uint8_t field_3e;
|
||||||
|
uint8_t field_3f;
|
||||||
|
} x_ebuf_t;
|
||||||
|
static_assert(sizeof(x_ebuf_t) == 0x40);
|
||||||
|
|
||||||
|
esp_err_t esp_wifi_80211_tx_custom(wifi_interface_t ifx, const void *buffer, int32_t len, bool en_sys_seq, wifi_tx_rate_config_t *tx_rate_config, wifi_band_t band, wifi_bandwidth_t bw)
|
||||||
|
{
|
||||||
|
esp_err_t result = 0;//ieee80211_raw_frame_sanity_check(ifx, buffer, len, en_sys_seq);
|
||||||
|
|
||||||
|
if (!result)
|
||||||
|
{
|
||||||
|
g_osi_funcs_p->_mutex_lock(g_wifi_global_lock);
|
||||||
|
x_ebuf_t* eb = ic_ebuf_alloc(buffer, 1, len);
|
||||||
|
|
||||||
|
if (eb)
|
||||||
|
{
|
||||||
|
//eb->data_length = len - 0x1a;
|
||||||
|
eb->data_length = 0;
|
||||||
|
x_eb_txdesc_t *txdesc_1 = eb->txdesc;
|
||||||
|
//eb->header_length = 0x1a;
|
||||||
|
eb->header_length = len;
|
||||||
|
txdesc_1->flags |= 0x4000;
|
||||||
|
txdesc_1->sched = ic_get_default_sched();
|
||||||
|
wifi_phy_rate_t rate = tx_rate_config->rate;
|
||||||
|
x_eb_txdesc_t *txdesc = eb->txdesc;
|
||||||
|
|
||||||
|
if (rate)
|
||||||
|
txdesc->rate = (char)rate;
|
||||||
|
else if (band != WIFI_BAND_5G)
|
||||||
|
txdesc->rate = 0;
|
||||||
|
else
|
||||||
|
txdesc->rate = (char)WIFI_PHY_RATE_6M;
|
||||||
|
|
||||||
|
wifi_phy_mode_t phymode = tx_rate_config->phymode;
|
||||||
|
|
||||||
|
if (phymode == WIFI_PHY_MODE_HE20)
|
||||||
|
{
|
||||||
|
txdesc->flags |= 0x80000000;
|
||||||
|
txdesc->field_2f =
|
||||||
|
(char)((((uint32_t)tx_rate_config->ersu + 6) & 0xf) << 3)
|
||||||
|
| (txdesc->field_2f & 0x87);
|
||||||
|
|
||||||
|
if ((uint32_t)tx_rate_config->dcm)
|
||||||
|
txdesc->field_31 |= 0x80;
|
||||||
|
}
|
||||||
|
else if (phymode == WIFI_PHY_MODE_VHT20)
|
||||||
|
txdesc->flags |= 0x1000000;
|
||||||
|
|
||||||
|
// No idea if this is correct, but this is what the original code does...
|
||||||
|
uint32_t bw_is_bw40 = bw == WIFI_BW40;
|
||||||
|
txdesc->field_8 = (bw_is_bw40 << 0xf) | (txdesc->field_8 & 0xffff7fff);
|
||||||
|
|
||||||
|
if (en_sys_seq)
|
||||||
|
txdesc->flags |= 1;
|
||||||
|
|
||||||
|
txdesc->field_10 =
|
||||||
|
(txdesc->field_10 & 0xfff3ffff) | ((ifx & WIFI_IF_MAX) << 0x12);
|
||||||
|
txdesc->field_14 = 0x100;
|
||||||
|
|
||||||
|
ieee80211_post_hmac_tx(eb);
|
||||||
|
g_osi_funcs_p->_mutex_unlock(g_wifi_global_lock);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
result = ESP_ERR_NO_MEM;
|
||||||
|
g_osi_funcs_p->_mutex_unlock(g_wifi_global_lock);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// Copied from opentrafficmap/its-g5-receiver-firmware_txenabled, main/tx_custom.h.
|
||||||
|
// See tx_custom.c for what this does and why we pulled it in.
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "esp_wifi.h"
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
esp_err_t esp_wifi_80211_tx_custom(wifi_interface_t ifx, const void *buffer, int32_t len, bool en_sys_seq, wifi_tx_rate_config_t *tx_rate_config, wifi_band_t band, wifi_bandwidth_t bw);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
// RETIRED - no longer built (removed from main/CMakeLists.txt SRCS), kept only
|
||||||
|
// for history. Confirmed not to work: linked cleanly with -Wl,-zmuldefs but
|
||||||
|
// the QoS-frame rejection persisted identically. Also turned out to be based
|
||||||
|
// on the wrong function signature - the real ieee80211_raw_frame_sanity_check
|
||||||
|
// takes (wifi_interface_t ifx, const void *buffer, int32_t len, bool
|
||||||
|
// en_sys_seq), confirmed from opentrafficmap/its-g5-receiver-firmware_txenabled's
|
||||||
|
// main/tx_custom.c, not the 3x int32_t guessed below. Superseded by
|
||||||
|
// tx_custom.c, which bypasses esp_wifi_80211_tx() (and the function that
|
||||||
|
// calls this check) entirely instead of trying to neutralize the check.
|
||||||
|
// See docs/04-transmit-setup.md.
|
||||||
|
|
||||||
|
// Overrides a function inside the closed-source WiFi library that gates
|
||||||
|
// which raw 802.11 frame types esp_wifi_80211_tx() will accept. By default
|
||||||
|
// it only allows beacon/probe-request/probe-response/action and non-QoS
|
||||||
|
// data frames - it explicitly rejects QoS Data (subtype 8), which is what
|
||||||
|
// real ITS-G5/802.11p hardware actually transmits and expects.
|
||||||
|
//
|
||||||
|
// This is the same technique used by ESP32 WiFi-security tools (deauther/
|
||||||
|
// injection projects) to unlock raw frame injection: define a function with
|
||||||
|
// the exact same name as the library's gate, and link with -Wl,-zmuldefs
|
||||||
|
// (see CMakeLists.txt) so the linker accepts having two definitions of the
|
||||||
|
// same symbol instead of erroring with "multiple definition of
|
||||||
|
// `ieee80211_raw_frame_sanity_check'" - and takes this one instead of the
|
||||||
|
// library's.
|
||||||
|
//
|
||||||
|
// Confirmed present for THIS target/IDF version: `nm` on
|
||||||
|
// components/esp_wifi/lib/esp32c5/libnet80211.a (IDF v5.5.4) shows
|
||||||
|
// `ieee80211_raw_frame_sanity_check` as a normal (non-weak) global text
|
||||||
|
// symbol in ieee80211_node.o. The exact argument count/meaning is
|
||||||
|
// reverse-engineered from community ESP32 (Xtensa) deauther tools, not
|
||||||
|
// confirmed byte-for-byte against esp32c5's actual implementation - if
|
||||||
|
// frames still get rejected, or this crashes, the real signature may take
|
||||||
|
// different arguments than assumed here.
|
||||||
|
//
|
||||||
|
// Real risk, not just an inconvenience: this disables ALL sanity checking
|
||||||
|
// on raw frames going through esp_wifi_80211_tx(), not just the QoS-type
|
||||||
|
// gate. Whatever else that check validates (frame length bounds, etc.) is
|
||||||
|
// now unchecked. Malformed frames from a bug elsewhere in this codebase
|
||||||
|
// could behave worse (silent corruption, crash) than they would have with
|
||||||
|
// the check in place, where they'd have just been rejected cleanly.
|
||||||
|
int ieee80211_raw_frame_sanity_check(int32_t arg1, int32_t arg2, int32_t arg3)
|
||||||
|
{
|
||||||
|
(void)arg1;
|
||||||
|
(void)arg2;
|
||||||
|
(void)arg3;
|
||||||
|
return 0; // 0 = "frame is sane" - always pass
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
|||||||
|
CONFIG_IDF_TARGET="esp32c5"
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user