From 0ccb867228b95546bf0ee9ca1d0c0252f8b49ffe Mon Sep 17 00:00:00 2001 From: Ashin Walpola Date: Mon, 17 Aug 2026 18:42:48 +0200 Subject: [PATCH] DENM over-the-air receive on the ESP32-C5 path The firmware forwarded CAM only: gn_unwrap_cam accepted single-hop broadcast (HT=5) and BTP port 2001, so every DENM was dropped before it reached the phone. Real OBUs disseminate DENM by GeoBroadcast (HT=4), whose 44-byte extended header also carries the hazard's relevance area - materially more useful on a map than the sender's own position, since a sender may be relaying for someone else. Firmware - gn_unwrap_cam -> gn_unwrap_its: accepts GeoBroadcast alongside TSB/SHB, and BTP ports 2001 and 2002, extracting the GeoBroadcast destination area. Both extended-header lengths were measured against live air capture rather than read off a spec table. Secured packets (Basic Header NextHeader=2) are rejected rather than misparsed. - SERIAL_MSG_CAM_RX (0x02) superseded by SERIAL_MSG_V2X_RX (0x04): a 14-byte prefix carrying BTP port, RSSI and the destination area. Adding MAPEM later needs a decoder on the phone but no protocol change. 0x02 stays reserved so the numbering is not silently reused. - Promiscuous RX capture buffer 400 -> 800 bytes. A real GeoBroadcast DENM is around 500 bytes on air and was being truncated mid-payload, which no amount of correct unwrapping downstream could have recovered from. - geonet_wrap_shb, both firmwares: the SHB extended header is 28 bytes, not 24. The Source Position Vector is followed by a 4-byte reserved field; without it a standards-strict receiver reads the CAM payload's first two bytes as the BTP destination port. App - DenmUperCodec: UPER decoder for the ManagementContainer and the SituationContainer's eventType. ValidityDuration is 17 bits, not 16, and ManagementContainer, SituationContainer and CauseCode each carry their own extension bit - a single wrong bit made a real frame read causeCode 47 instead of 94. - DenmEvent gains actionID (originatingStationID + sequenceNumber), stationType, termination, detectionTime, relevance radius and RSSI. Dedup keys on actionID where available, so a termination lands on the event it ends instead of creating a second pin. - denmEvents merges the MQTT and over-the-air sources and drops terminated events. The V2X list view now shows hazards above the CAM stations; it previously took no DENM parameter at all, so hazards reached the map but never the list. - DenmParser: the Use Case API sends causeCode as a string enum, so reading it as an Int always yielded null. Testing - DenmAirReceiveTest covers the V2X_RX prefix and the decoder using real frames from a live capture as fixtures. Expected values were cross-checked against the ETSI ASN.1 modules via asn1tools, which agreed on all 1885 decodable DENMs across the capture set, every field including detectionTime. - Verified on hardware: a CiT One HLN-SV DENM decodes as cause 94/0 with a 1000 m relevance radius at 1 Hz alongside CAM, with no decode failures and no unexpected BTP ports. Also replaces em dashes with hyphens throughout the user-facing strings, including the German translation. --- .../hawhamburg/micr0bu/data/CsvExporter.kt | 4 +- .../hawhamburg/micr0bu/data/TripExporter.kt | 2 +- .../micr0bu/data/cam/CamUseCaseRepository.kt | 67 ++++-- .../micr0bu/data/transport/SerialFrame.kt | 65 +++++- .../micr0bu/domain/asn1/DenmUperCodec.kt | 170 +++++++++++++++ .../micr0bu/domain/denm/DenmEvent.kt | 79 ++++++- .../ui/screens/MqttTopicViewerScreen.kt | 116 +++++++++- .../micr0bu/viewmodel/MqttViewModel.kt | 35 +-- .../micr0bu/viewmodel/SensorViewModel.kt | 2 +- app/src/main/res/values-de/strings.xml | 56 ++--- app/src/main/res/values/strings.xml | 63 +++--- .../hawhamburg/micr0bu/DenmAirReceiveTest.kt | 199 ++++++++++++++++++ obu-cam-transmistter/main/geonet.c | 13 +- obu-firmware/main/geonet.c | 13 +- obu-firmware/main/gn_unwrap.c | 102 ++++++--- obu-firmware/main/gn_unwrap.h | 79 ++++--- obu-firmware/main/main.c | 27 ++- obu-firmware/main/serial_link.c | 42 +++- obu-firmware/main/serial_link.h | 43 +++- 19 files changed, 989 insertions(+), 188 deletions(-) create mode 100644 app/src/main/java/com/hawhamburg/micr0bu/domain/asn1/DenmUperCodec.kt create mode 100644 app/src/test/java/com/hawhamburg/micr0bu/DenmAirReceiveTest.kt diff --git a/app/src/main/java/com/hawhamburg/micr0bu/data/CsvExporter.kt b/app/src/main/java/com/hawhamburg/micr0bu/data/CsvExporter.kt index 0d40acd..0212d81 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/data/CsvExporter.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/data/CsvExporter.kt @@ -51,7 +51,7 @@ suspend fun shareSessionCsv(context: Context, session: RecordingSession) { val intent = Intent(Intent.ACTION_SEND).apply { type = "text/csv" putExtra(Intent.EXTRA_STREAM, uri) - putExtra(Intent.EXTRA_SUBJECT, "MicrOBU Session Export — $fileName") + putExtra(Intent.EXTRA_SUBJECT, "MicrOBU Session Export - $fileName") addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } context.startActivity(Intent.createChooser(intent, "Export session")) @@ -82,7 +82,7 @@ suspend fun saveSessionCsvToUri(context: Context, session: RecordingSession, uri */ fun buildSessionCsv(s: RecordingSession): String = buildString { appendLine("# MicrOBU Session Export") - appendLine("# Generated by MicrOBU v0.2.0 — HAW Hamburg / Project MicrOBU") + appendLine("# Generated by MicrOBU v0.2.0 - HAW Hamburg / Project MicrOBU") appendLine("# Session ID,${s.id}") appendLine("# Start,${iso.format(Date(s.startTime))}") appendLine("# End,${iso.format(Date(s.endTime))}") diff --git a/app/src/main/java/com/hawhamburg/micr0bu/data/TripExporter.kt b/app/src/main/java/com/hawhamburg/micr0bu/data/TripExporter.kt index a59d1b7..dd055ca 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/data/TripExporter.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/data/TripExporter.kt @@ -127,7 +127,7 @@ suspend fun shareTripCsv( val intent = Intent(Intent.ACTION_SEND).apply { type = "text/csv" putExtra(Intent.EXTRA_STREAM, uri) - putExtra(Intent.EXTRA_SUBJECT, "MicrOBU Trip Export — $fileName") + putExtra(Intent.EXTRA_SUBJECT, "MicrOBU Trip Export - $fileName") addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } context.startActivity(Intent.createChooser(intent, "Export trip")) diff --git a/app/src/main/java/com/hawhamburg/micr0bu/data/cam/CamUseCaseRepository.kt b/app/src/main/java/com/hawhamburg/micr0bu/data/cam/CamUseCaseRepository.kt index 9357186..aa5ce3f 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/data/cam/CamUseCaseRepository.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/data/cam/CamUseCaseRepository.kt @@ -9,14 +9,18 @@ import com.hawhamburg.micr0bu.data.mqtt.MqttRepository import com.hawhamburg.micr0bu.data.mqtt.ObuHardwarePreferences import com.hawhamburg.micr0bu.data.mqtt.UseCaseAlertPreferences import com.hawhamburg.micr0bu.data.transport.ObuHardware +import com.hawhamburg.micr0bu.data.transport.BtpPort import com.hawhamburg.micr0bu.data.transport.SerialFrameType +import com.hawhamburg.micr0bu.data.transport.V2xRxFrame import com.hawhamburg.micr0bu.data.transport.UsbSerialState import com.hawhamburg.micr0bu.data.transport.UsbSerialTransport +import com.hawhamburg.micr0bu.domain.asn1.DenmUperCodec import com.hawhamburg.micr0bu.domain.asn1.RealAsn1UperCodec import com.hawhamburg.micr0bu.domain.cam.Cam import com.hawhamburg.micr0bu.domain.cam.CamParser import com.hawhamburg.micr0bu.domain.cam.ObuGnssParser import com.hawhamburg.micr0bu.domain.cam.StationType +import com.hawhamburg.micr0bu.domain.denm.DenmEvent import com.hawhamburg.micr0bu.domain.usecase.UseCaseAlert import com.hawhamburg.micr0bu.domain.usecase.UseCaseDetectionEngine import com.hawhamburg.micr0bu.domain.usecase.UseCaseType @@ -66,7 +70,8 @@ private const val OBU_GNSS_STALE_MS = 2_500L * A singleton so detection keeps running (and alert state survives) even while no screen is * collecting it — same rationale as [MqttRepository]'s per-topic message log. * - * No DENM is generated or consumed anywhere in this class. + * DENM is decoded from the ESP32-C5 serial path (see [airDenm]) but deliberately kept out of + * [UseCaseDetectionEngine] — that engine reasons about moving road users from CAM kinematics. */ @Singleton class CamUseCaseRepository @Inject constructor( @@ -121,6 +126,14 @@ class CamUseCaseRepository @Inject constructor( */ val processedCam: SharedFlow = _processedCam.asSharedFlow() + private val _airDenm = MutableSharedFlow(replay = 32, extraBufferCapacity = 32) + /** + * DENMs decoded from over-the-air traffic on the ESP32-C5 path. `replay` so a screen opened + * after a hazard was first heard still sees it - DENMs repeat at ~1 Hz but a subscriber that + * missed the last repetition shouldn't have to wait for the next. + */ + val airDenm: SharedFlow = _airDenm.asSharedFlow() + init { scope.launch { mqttRepository.messages.collect { msg -> @@ -171,9 +184,16 @@ class CamUseCaseRepository @Inject constructor( // 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 + if (frame.type != SerialFrameType.V2X_RX) return@collect if (usbSerialTransport.state.value != UsbSerialState.CONNECTED) return@collect - handleCamFromSerial(frame.payload) + val v2x = V2xRxFrame.parse(frame.payload) ?: return@collect + when (v2x.btpPort) { + BtpPort.CAM -> handleCamFromSerial(v2x) + BtpPort.DENM -> handleDenmFromSerial(v2x) + // The firmware only forwards ports it was told to accept, so anything else + // means the two sides have drifted out of sync. + else -> Log.w(TAG, "unexpected BTP port ${v2x.btpPort} from firmware") + } } } @@ -281,29 +301,52 @@ class CamUseCaseRepository @Inject constructor( * 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 rssiDbm = payload[0].toInt() // signed dBm from the firmware's promiscuous RX metadata - val camBytes = payload.copyOfRange(1, payload.size) // payload[0] is RSSI, not part of the CAM - val cam = camCodec.decodeCam(camBytes, System.currentTimeMillis())?.copy(rssiDbm = rssiDbm) + private fun handleCamFromSerial(v2x: V2xRxFrame) { + val cam = camCodec.decodeCam(v2x.uper, System.currentTimeMillis())?.copy(rssiDbm = v2x.rssiDbm) if (cam == null) { // Logged, not silently dropped: "the app shows nothing" has two completely different // causes - frames not arriving at all, versus arriving and failing to decode - and - // without this line they're indistinguishable from the outside. rssi is signed. + // without this line they're indistinguishable from the outside. Log.w( TAG, - "handleCamFromSerial: decode FAILED for ${camBytes.size}-byte CAM " + - "(rssi=$rssiDbm dBm) - first bytes: ${camBytes.toHexPreview()}", + "handleCamFromSerial: decode FAILED for ${v2x.uper.size}-byte CAM " + + "(rssi=${v2x.rssiDbm} dBm) - first bytes: ${v2x.uper.toHexPreview()}", ) return } Log.d(TAG, "handleCamFromSerial: decoded station=${cam.stationId} " + - "lat=${cam.latitude} lon=${cam.longitude} speed=${cam.speedMps} rssi=$rssiDbm dBm") + "lat=${cam.latitude} lon=${cam.longitude} speed=${cam.speedMps} rssi=${v2x.rssiDbm} dBm") if (_ownStationId.value != null && cam.stationId == _ownStationId.value) return // self-heard TX engine.onRemoteCam(cam) _processedCam.tryEmit(cam) } + /** + * A DENM heard over the air. Deliberately NOT fed to [UseCaseDetectionEngine] - that engine + * reasons about moving road users from CAM kinematics, and a static hazard is a different kind + * of thing. DENMs go to the map and the message list only. + */ + private fun handleDenmFromSerial(v2x: V2xRxFrame) { + val denm = DenmUperCodec.decode( + bytes = v2x.uper, + receivedAtEpochMs = System.currentTimeMillis(), + rssiDbm = v2x.rssiDbm, + relevanceRadiusM = v2x.geoArea?.radiusMeters, + ) + if (denm == null) { + Log.w( + TAG, + "handleDenmFromSerial: decode FAILED for ${v2x.uper.size}-byte DENM " + + "(rssi=${v2x.rssiDbm} dBm) - first bytes: ${v2x.uper.toHexPreview()}", + ) + return + } + Log.d(TAG, "handleDenmFromSerial: decoded station=${denm.stationId}/${denm.sequenceNumber} " + + "cause=${denm.causeCode}/${denm.subCauseCode} lat=${denm.latitude} lon=${denm.longitude} " + + "radius=${denm.relevanceRadiusM}m termination=${denm.isTermination} rssi=${v2x.rssiDbm} dBm") + _airDenm.tryEmit(denm) + } + private fun ByteArray.toHexPreview(limit: Int = 16): String = take(limit).joinToString(" ") { "%02x".format(it) } + if (size > limit) " ..." else "" } diff --git a/app/src/main/java/com/hawhamburg/micr0bu/data/transport/SerialFrame.kt b/app/src/main/java/com/hawhamburg/micr0bu/data/transport/SerialFrame.kt index cd31ae9..a9ddd73 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/data/transport/SerialFrame.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/data/transport/SerialFrame.kt @@ -14,10 +14,12 @@ 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`. */ + /** Superseded by [V2X_RX]; the firmware no longer sends this. Kept so the number isn't reused. */ const val CAM_RX: Int = 0x02 + /** ESP32 -> phone: any received ITS message — see [V2xRxFrame] for the payload layout. */ + const val V2X_RX: Int = 0x04 + /** ESP32 -> phone: periodic heartbeat + drop counters, independent of CAM traffic. * Payload layout is [EspLinkStatus] — see its KDoc. */ const val STATUS: Int = 0x03 @@ -93,6 +95,65 @@ object Crc16CcittFalse { } } +/** BTP-B destination ports (ETSI TS 103 248) the firmware forwards. */ +object BtpPort { + const val CAM = 2001 + const val DENM = 2002 +} + +/** + * Decoded [SerialFrameType.V2X_RX] payload: a 14-byte little-endian prefix followed by the UPER + * message. Must stay in lockstep with `serial_link.h`'s `SERIAL_V2X_RX_PREFIX_LEN` and the layout + * documented there. + * + * Deliberately generic — [btpPort] says what [uper] is, so adding MAPEM or SPATEM later needs a + * decoder here and one accepted port in the firmware's `gn_unwrap.c`, but no protocol change. + */ +data class V2xRxFrame( + /** 2001 = CAM, 2002 = DENM. See [BtpPort]. */ + val btpPort: Int, + /** Received signal strength, dBm, from the firmware's promiscuous RX metadata. */ + val rssiDbm: Int, + /** + * GeoBroadcast destination area, or null when the source frame was single-hop broadcast and + * carried none. For a DENM this is the hazard's relevance circle — "applies within + * [GeoArea.radiusMeters] of this point" — which is more useful on a map than the sender's own + * position, since the sender may be relaying for someone else. + */ + val geoArea: GeoArea?, + /** The raw UPER message bytes. */ + val uper: ByteArray, +) { + data class GeoArea(val latitude: Double, val longitude: Double, val radiusMeters: Int) + + companion object { + const val PREFIX_SIZE = 14 + + /** Returns null if [payload] is too short to be a well-formed V2X_RX payload. */ + fun parse(payload: ByteArray): V2xRxFrame? { + if (payload.size <= PREFIX_SIZE) return null + fun u8(i: Int) = payload[i].toInt() and 0xFF + fun u16(i: Int) = u8(i) or (u8(i + 1) shl 8) + fun i32(i: Int) = u8(i) or (u8(i + 1) shl 8) or (u8(i + 2) shl 16) or (u8(i + 3) shl 24) + + val hasArea = (u8(3) and 0x01) != 0 + return V2xRxFrame( + btpPort = u16(0), + rssiDbm = payload[2].toInt(), // signed + geoArea = if (hasArea) { + GeoArea( + // GeoNetworking carries these in 1/10 microdegree. + latitude = i32(4) / 1e7, + longitude = i32(8) / 1e7, + radiusMeters = u16(12), + ) + } else null, + uper = payload.copyOfRange(PREFIX_SIZE, payload.size), + ) + } + } +} + data class DecodedFrame(val type: Int, val payload: ByteArray) object SerialFrameEncoder { diff --git a/app/src/main/java/com/hawhamburg/micr0bu/domain/asn1/DenmUperCodec.kt b/app/src/main/java/com/hawhamburg/micr0bu/domain/asn1/DenmUperCodec.kt new file mode 100644 index 0000000..6568fb0 --- /dev/null +++ b/app/src/main/java/com/hawhamburg/micr0bu/domain/asn1/DenmUperCodec.kt @@ -0,0 +1,170 @@ +package com.hawhamburg.micr0bu.domain.asn1 + +import com.hawhamburg.micr0bu.domain.denm.DenmEvent + +/** + * ASN.1 UPER **decoder** for DENM (ETSI EN 302 637-3 v1.3.1 DENM-PDU-Descriptions + + * TS 102 894-2 v1.3.1 ITS-Container), for messages received over the air on the ESP32-C5 path. + * + * Decode-only by design: this project transmits CAM, not DENM, so there is no encode direction to + * keep symmetric. (`obu-cam-transmistter/main/denm.c` does encode DENM, but that's a separate + * firmware with its own purpose.) + * + * ## Scope + * Decodes the `ManagementContainer` and the `SituationContainer`'s `eventType` — that is, *what* + * the hazard is, *where* it is, and *when* it was detected, which is everything the map and list + * need. It deliberately stops after `causeCode`/`subCauseCode` and does not parse `linkedCause`, + * `eventHistory`, the `LocationContainer` (traces, road type) or the `AlacarteContainer`. Those are + * large, deeply nested, and nothing consumes them yet. + * + * ## Field widths + * Every width below is taken from the ETSI ASN.1 modules in the `C-ITS-Parser` checkout + * (`autogen/asn.1/denm_1_3_1.asn`, `cdd_1_3_1_1.asn`), and every extension marker was + * cross-checked against how `rasn` renders the same type (`#[non_exhaustive]` marks an extensible + * SEQUENCE). That cross-check matters: hand-derived widths are exactly how this project shipped a + * one-bit `CurvatureCalculationMode` bug in CAM that was invisible until measured against real + * traffic. Two traps worth naming here: + * + * - **`ValidityDuration` is 17 bits**, not 16. It's `INTEGER (0..86400)`, and 86401 values need + * 17 bits. A hand-decode of a real frame landed on `causeCode` 47 instead of 94 purely from + * getting this one wrong — a single bit doubles or halves everything after it. + * - **`ManagementContainer`, `SituationContainer` and `CauseCode` are all extensible**, so each + * needs its own leading extension bit before its optional-presence bitmap. The DENM body + * SEQUENCE is *not* extensible and has no extension bit — only the three optional bits. + * + * Verified end-to-end against a live capture (2026-08-17): a CiT One HLN-SV trigger decodes as + * `causeCode` 94 (stationaryVehicle), `subCauseCode` 0. + * + * Returns null rather than guessing whenever an extension bit is set or a field is out of range — + * a dropped hazard is recoverable (they repeat at 1 Hz), a misplaced one is not. + */ +object DenmUperCodec { + + private const val MESSAGE_ID_DENM = 1 + private const val PROTOCOL_VERSION = 2 + + /** TimestampIts epoch: 2004-01-01T00:00:00Z in Unix epoch milliseconds. */ + private const val TS_ITS_EPOCH_MS = 1_072_915_200_000L + + /** + * Decodes a UPER DENM into a [DenmEvent]. + * + * @param receivedAtEpochMs wall-clock receipt time, used only as a fallback if the message's + * own detectionTime is unusable. + * @param rssiDbm signal strength from the serial frame, carried through for range analysis. + * @param relevanceRadiusM the GeoBroadcast destination-area radius from the GeoNetworking + * header, if the frame carried one. Not part of the DENM payload itself. + */ + fun decode( + bytes: ByteArray, + receivedAtEpochMs: Long, + rssiDbm: Int? = null, + relevanceRadiusM: Int? = null, + ): DenmEvent? = try { + decodeOrThrow(bytes, receivedAtEpochMs, rssiDbm, relevanceRadiusM) + } catch (e: IndexOutOfBoundsException) { + null // truncated frame + } + + private fun decodeOrThrow( + bytes: ByteArray, + receivedAtEpochMs: Long, + rssiDbm: Int?, + relevanceRadiusM: Int?, + ): DenmEvent? { + val br = BitReader(bytes) + + // ---- ItsPduHeader ---- no extension marker, no optionals, so no preamble. + if (br.getBitsInt(8) != PROTOCOL_VERSION) return null + if (br.getBitsInt(8) != MESSAGE_ID_DENM) return null + br.getBits(32) // header stationID - actionID.originatingStationID below is the identity + + // ---- DecentralizedEnvironmentalNotificationMessage ---- + // NOT extensible (rasn renders it without #[non_exhaustive]), so three optional bits only + // and no leading extension bit. + val situationPresent = br.getBitsInt(1) == 1 + br.getBits(1) // location container present - not parsed + br.getBits(1) // alacarte container present - not parsed + + // ---- ManagementContainer ---- extensible: 1 extension bit + 5 optional/DEFAULT bits. + if (br.getBitsInt(1) != 0) return null // extension in use - can't trust later offsets + val terminationPresent = br.getBitsInt(1) == 1 + val relevanceDistancePresent = br.getBitsInt(1) == 1 + val relevanceTrafficDirectionPresent = br.getBitsInt(1) == 1 + val validityDurationPresent = br.getBitsInt(1) == 1 + val transmissionIntervalPresent = br.getBitsInt(1) == 1 + + // actionID: the real ETSI identity of an event. Successive repetitions of the same hazard + // reuse it, and GeoBroadcast means several stations may relay the same DENM - so this, not + // the radio source, is what dedup must key on. + val originatingStationId = br.getBits(32) + val sequenceNumber = br.getBitsInt(16) + + val detectionTimeIts = br.getBits(42) // TimestampIts (0..4398046511103) -> 42 bits + br.getBits(42) // referenceTime - not used + + val isTermination = if (terminationPresent) { + // Termination ::= ENUMERATED {isCancellation(0), isNegation(1)} - 2 values, not + // extensible, so a single bit. Either value means "this event is over". + br.getBits(1); true + } else false + + // ---- eventPosition: ReferencePosition ---- same layout as CAM's, see CamUperCodec. + val latitude = (br.getBits(31) + (-900000000L)) / 1e7 + val longitude = (br.getBits(32) + (-1800000000L)) / 1e7 + br.getBits(12) // semiMajorConfidence + br.getBits(12) // semiMinorConfidence + br.getBits(12) // semiMajorOrientation + br.getBits(20) // altitudeValue + br.getBits(4) // altitudeConfidence + + if (relevanceDistancePresent) br.getBits(3) // ENUMERATED, 8 values + if (relevanceTrafficDirectionPresent) br.getBits(2) // ENUMERATED, 4 values + if (validityDurationPresent) br.getBits(17) // INTEGER (0..86400) -> 17 bits + if (transmissionIntervalPresent) br.getBits(14) // INTEGER (1..10000) -> 14 bits + + val stationType = br.getBitsInt(8) + + // ---- SituationContainer ---- carries what the hazard actually is. Optional in the + // grammar; without it there is no causeCode and the event is not worth showing. + var causeCode: Int? = null + var subCauseCode: Int? = null + if (situationPresent) { + if (br.getBitsInt(1) != 0) return null // extensible: extension in use + br.getBits(1) // linkedCause present - not parsed + br.getBits(1) // eventHistory present - not parsed + br.getBits(3) // informationQuality (0..7) + + // CauseCode is itself an extensible SEQUENCE, so it has its own extension bit before + // its two 8-bit fields. Omitting this bit is what made a real frame read 47 instead + // of 94. + if (br.getBitsInt(1) != 0) return null + causeCode = br.getBitsInt(8) + subCauseCode = br.getBitsInt(8) + } + + // Everything after this point - the rest of the SituationContainer, the LocationContainer + // and the AlacarteContainer - is deliberately unread. Safe because nothing above depends + // on it; if any of it is ever needed, the unparsed optionals must be consumed in order + // first or every later read lands at the wrong bit offset. + + val detectionTimeMs = detectionTimeIts + TS_ITS_EPOCH_MS + + return DenmEvent( + stationId = originatingStationId, + sequenceNumber = sequenceNumber, + latitude = latitude, + longitude = longitude, + causeCode = causeCode, + subCauseCode = subCauseCode, + stationType = stationType, + isTermination = isTermination, + detectionTimeMs = detectionTimeMs.takeIf { it in 0..(receivedAtEpochMs + DAY_MS) }, + relevanceRadiusM = relevanceRadiusM, + rssiDbm = rssiDbm, + timestamp = receivedAtEpochMs, + ) + } + + private const val DAY_MS = 86_400_000L +} diff --git a/app/src/main/java/com/hawhamburg/micr0bu/domain/denm/DenmEvent.kt b/app/src/main/java/com/hawhamburg/micr0bu/domain/denm/DenmEvent.kt index 8d65022..dedc956 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/domain/denm/DenmEvent.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/domain/denm/DenmEvent.kt @@ -17,9 +17,17 @@ import org.json.JSONObject * anything is forwarded over the serial link. See that file's header comment. */ data class DenmEvent( - /** Originating station ID. */ + /** Originating station ID — `actionID.originatingStationID`, not the radio source. */ val stationId: Long, + /** + * `actionID.sequenceNumber`. Together with [stationId] this is ETSI's real event identity: + * repetitions of one hazard reuse it, and under GeoBroadcast several stations may relay the + * same DENM, so this pair is what dedup must key on. Null on the MQTT path when the Use Case + * API doesn't supply it. + */ + val sequenceNumber: Int? = null, + /** Event position (WGS84 degrees) — where the hazard is, not where the sender is. */ val latitude: Double, val longitude: Double, @@ -30,16 +38,39 @@ data class DenmEvent( /** SubCauseCode qualifying [causeCode], or null. */ val subCauseCode: Int?, + /** Originating station's ETSI stationType, where known. */ + val stationType: Int? = null, + + /** + * True when this DENM cancels or negates the event (`termination` present). A terminated event + * should be removed from the map rather than drawn — the hazard is over. + */ + val isTermination: Boolean = false, + + /** Event detection time in epoch ms, where the message carried a usable one. */ + val detectionTimeMs: Long? = null, + + /** + * Radius of the GeoBroadcast destination area in metres, i.e. how far the warning is meant to + * apply. Comes from the GeoNetworking header rather than the DENM payload, so it's only + * available on the ESP32-C5 path (the MQTT path never exposes the GN layer). + */ + val relevanceRadiusM: Int? = null, + + /** Received signal strength, dBm — ESP32-C5 path only. */ + val rssiDbm: Int? = null, + /** Wall-clock ms this DENM was received. */ val timestamp: Long, ) { /** - * Stable identity for map/list dedup: successive DENMs about the same hazard from the same - * station should replace each other rather than pile up as separate pins. ETSI's real identity - * is actionID (stationID + sequenceNumber); this approximates it with the cause, since the - * Use Case API's JSON doesn't reliably expose a sequence number. + * Stable identity for map/list dedup. Prefers ETSI's actionID (`stationId` + `sequenceNumber`) + * where available; falls back to station + cause on the MQTT path, which doesn't reliably + * expose a sequence number. */ - val dedupKey: String get() = "$stationId/${causeCode ?: -1}/${subCauseCode ?: -1}" + val dedupKey: String + get() = if (sequenceNumber != null) "$stationId/$sequenceNumber" + else "$stationId/${causeCode ?: -1}/${subCauseCode ?: -1}" } /** @@ -54,6 +85,37 @@ data class DenmEvent( */ object DenmParser { + /** + * The Use Case API's `causeCode` string enum mapped back to its ITS-G5 integer, so a DENM from + * the MQTT path and one decoded off the air are directly comparable. Values are from + * CauseCodeType in the ETSI CDD; the names are the API's spelling. + */ + private val CAUSE_CODE_BY_NAME = mapOf( + "trafficCondition" to 1, "accident" to 2, "roadworks" to 3, "impassability" to 5, + "adverseWeatherCondition_Adhesion" to 6, "aquaplanning" to 7, + "hazardousLocation_SurfaceCondition" to 9, "hazardousLocation_ObstacleOnTheRoad" to 10, + "hazardousLocation_AnimalOnTheRoad" to 11, "humanPresenceOnTheRoad" to 12, + "wrongWayDriving" to 14, "rescueAndRecoveryWorkInProgress" to 15, + "adverseWeatherCondition_ExtremeWeatherCondition" to 17, + "adverseWeatherCondition_Visibility" to 18, + "adverseWeatherCondition_Precipitation" to 19, "slowVehicle" to 26, + "dangerousEndOfQueue" to 27, "vehicleBreakdown" to 91, "postCrash" to 92, + "humanProblem" to 93, "stationaryVehicle" to 94, "emergencyVehicleApproaching" to 95, + "hazardousLocation_DangerousCurve" to 96, "collisionRisk" to 97, + "signalViolation" to 98, "dangerousSituation" to 99, + ) + + private val NAME_BY_CAUSE_CODE = CAUSE_CODE_BY_NAME.entries.associate { (n, c) -> c to n } + + /** + * The ETSI CauseCode name for [causeCode], or null for a code this table doesn't cover. + * + * Deliberately the API's own camelCase spelling ("stationaryVehicle") rather than prose: it's + * the vocabulary the MQTT payloads, the V2X2MAP dashboard and the sniffer all use, so a bench + * operator can compare what the app says against what those show without translating. + */ + fun causeCodeName(causeCode: Int?): String? = causeCode?.let { NAME_BY_CAUSE_CODE[it] } + fun parse(json: String, timestamp: Long = System.currentTimeMillis()): DenmEvent? { val obj = runCatching { JSONObject(json) }.getOrNull() ?: return null @@ -72,8 +134,13 @@ object DenmParser { ?: return null val situation = obj.optJSONObject("situation") + // The Use Case API sends causeCode as a STRING enum ("stationaryVehicle", "roadworks", ...), + // per CI-CiT-MQTT_API_Documentation-v6 section 2.2.4 - not the ITS-G5 integer. An earlier + // version of this parser read it as an Int and therefore always got null. Both forms are + // accepted: the air path (DenmUperCodec) produces the integer. val causeCode = JsonFieldReader.firstInt(obj, "causeCode", "cause_code", "cause") ?: situation?.let { JsonFieldReader.firstInt(it, "causeCode", "cause_code", "cause") } + ?: CAUSE_CODE_BY_NAME[obj.optString("causeCode").takeIf { it.isNotBlank() }] val subCauseCode = JsonFieldReader.firstInt(obj, "subCauseCode", "sub_cause_code", "subCause") ?: situation?.let { JsonFieldReader.firstInt(it, "subCauseCode", "sub_cause_code", "subCause") } diff --git a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/MqttTopicViewerScreen.kt b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/MqttTopicViewerScreen.kt index ca5ac02..d69a792 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/MqttTopicViewerScreen.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/MqttTopicViewerScreen.kt @@ -30,6 +30,7 @@ import androidx.compose.material.icons.filled.Link import androidx.compose.material.icons.filled.LinkOff import androidx.compose.material.icons.filled.NotificationsActive import androidx.compose.material.icons.filled.VerticalAlignBottom +import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.Badge import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults @@ -69,6 +70,7 @@ import com.hawhamburg.micr0bu.data.transport.EspLinkStatus import com.hawhamburg.micr0bu.data.transport.ObuHardware import com.hawhamburg.micr0bu.data.transport.UsbSerialState import com.hawhamburg.micr0bu.domain.cam.CamParser +import com.hawhamburg.micr0bu.domain.denm.DenmParser import com.hawhamburg.micr0bu.domain.denm.DenmUseCase import com.hawhamburg.micr0bu.domain.usecase.AlertLevel import com.hawhamburg.micr0bu.domain.usecase.GeoMath @@ -215,7 +217,7 @@ fun MqttTopicViewerScreen( TopicListPane( topics = sortedTopics, topicMessages = topicMessages, - connectionState = connectionState, + connectionState = effectiveState, denmActive = denmActive, lastDenmPayload = lastDenmPayload, activeDenmUseCase = activeDenmUseCase, @@ -358,6 +360,7 @@ private fun TopicListPane( own = ownCamPosition, remotes = remoteCamPositions, alerts = useCaseAlerts, + denms = denmEvents, modifier = Modifier.fillMaxSize(), ) } else if (topics.isEmpty()) { @@ -407,15 +410,20 @@ private fun TopicListPane( * * Sorted nearest-first: on a bike, the closest station is the one that matters. Rows are tinted * by that station's most severe active alert, matching [UseCaseAlertPanel] and the map markers. + * + * Hazards ([denms]) are listed above the stations rather than mixed in: a DENM is a warning about + * a place, a CAM is a report about a moving road user, and a hazard outranks a neighbour even when + * the neighbour is closer. Both sections live in one [LazyColumn] so the pane scrolls as a whole. */ @Composable private fun ReceivedCamPane( own: com.hawhamburg.micr0bu.domain.cam.Cam?, remotes: Map, alerts: List, + denms: List, modifier: Modifier = Modifier, ) { - if (remotes.isEmpty()) { + if (remotes.isEmpty() && denms.isEmpty()) { Box(modifier = modifier, contentAlignment = Alignment.Center) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( @@ -452,20 +460,110 @@ private fun ReceivedCamPane( .sortedBy { (_, d) -> d ?: Double.MAX_VALUE } } - Column(modifier = modifier) { + // Same treatment as the CAM rows: distance resolved once here so sort order and the displayed + // value can't disagree. A DENM's position is the hazard's, not the sender's. + val hazards = remember(denms, own) { + denms + .map { denm -> + val distance = own?.let { + GeoMath.haversineMeters(it.latitude, it.longitude, denm.latitude, denm.longitude) + } + denm to distance + } + .sortedBy { (_, d) -> d ?: Double.MAX_VALUE } + } + + LazyColumn(modifier = modifier) { + if (hazards.isNotEmpty()) { + item { + PaneSectionHeader(stringResource(R.string.v2x_denm_rx_count, hazards.size)) + } + // Keys can't collide with the CAM rows below - dedupKey is a String, stationId a Long. + items(hazards, key = { (denm, _) -> denm.dedupKey }) { (denm, distance) -> + ReceivedDenmRow(denm, distance) + HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f)) + } + } + + item { + PaneSectionHeader( + if (rows.isEmpty()) stringResource(R.string.v2x_cam_rx_none_stations) + else stringResource(R.string.v2x_cam_rx_count, rows.size) + ) + } + items(rows, key = { (cam, _) -> cam.stationId }) { (cam, distance) -> + ReceivedCamRow(cam, distance, alertByStation[cam.stationId]) + HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f)) + } + } +} + +@Composable +private fun PaneSectionHeader(text: String) { + Column { Text( - text = stringResource(R.string.v2x_cam_rx_count, rows.size), + text = text, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), color = MaterialTheme.colorScheme.onSurfaceVariant, ) HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f)) + } +} - LazyColumn(modifier = Modifier.fillMaxSize()) { - items(rows, key = { (cam, _) -> cam.stationId }) { (cam, distance) -> - ReceivedCamRow(cam, distance, alertByStation[cam.stationId]) - HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f)) - } +/** + * One hazard row: what it is, where it is, and how well it was heard. + * + * [DenmEvent.relevanceRadiusM] and [DenmEvent.rssiDbm] come from the GeoNetworking header and the + * serial prefix rather than the DENM payload, so they're only populated on the ESP32-C5 path and + * are omitted rather than shown as zeroes when absent. + */ +@Composable +private fun ReceivedDenmRow( + denm: com.hawhamburg.micr0bu.domain.denm.DenmEvent, + distanceMeters: Double?, +) { + val title = DenmParser.causeCodeName(denm.causeCode) + ?: denm.causeCode?.let { + stringResource(R.string.v2x_denm_rx_cause_code, it, denm.subCauseCode ?: 0) + } + ?: stringResource(R.string.v2x_map_denm_plain, denm.stationId) + + val detail = listOfNotNull( + distanceMeters?.let { stringResource(R.string.v2x_cam_rx_distance, it) } + ?: stringResource(R.string.v2x_cam_rx_distance_unknown), + denm.relevanceRadiusM?.let { stringResource(R.string.v2x_denm_rx_radius, it) }, + denm.rssiDbm?.let { stringResource(R.string.v2x_cam_rx_rssi, it) }, + ).joinToString(" · ") + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Default.Warning, + contentDescription = null, + tint = DenmRed, + modifier = Modifier.size(14.dp), + ) + Spacer(Modifier.width(10.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.v2x_denm_rx_hazard, title, denm.stationId), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = DenmRed, + ) + Spacer(Modifier.height(2.dp)) + Text( + text = detail, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontFamily = FontFamily.Monospace, + ) } } } diff --git a/app/src/main/java/com/hawhamburg/micr0bu/viewmodel/MqttViewModel.kt b/app/src/main/java/com/hawhamburg/micr0bu/viewmodel/MqttViewModel.kt index b9a19cc..948eda6 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/viewmodel/MqttViewModel.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/viewmodel/MqttViewModel.kt @@ -23,6 +23,8 @@ import com.hawhamburg.micr0bu.domain.usecase.UseCaseType import com.hawhamburg.micr0bu.service.CamPinger import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.runningFold import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -135,22 +137,31 @@ class MqttViewModel @Inject constructor( * Hazards received from other stations, newest first, deduped by [DenmEvent.dedupKey] so a * repeating DENM about the same hazard stays one pin instead of stacking up. * - * Derived from the raw `v2x-uca/output/json/denm` messages the repository already buffers, - * rather than a second subscription — the repository caps each topic's history, so this is - * bounded by construction. + * Two sources, merged: the CiT One path's `v2x-uca/output/json/denm` MQTT topic (parsed by + * [DenmParser]), and the ESP32-C5 path's over-the-air DENMs (GeoBroadcast, BTP port 2002, + * decoded by [com.hawhamburg.micr0bu.domain.asn1.DenmUperCodec]). Only one is ever active at a + * time since the hardware selection decides the transport, so merging costs nothing and keeps + * the UI transport-agnostic. * - * Always empty on the ESP32-C5 path: that firmware forwards BTP-B port 2001 (CAM) only and - * drops DENM before it reaches the phone. See [DenmEvent]'s KDoc. + * Events carrying `termination` are filtered out rather than shown — the hazard is over. */ - val denmEvents: StateFlow> = repo.topicMessages - .map { byTopic -> + val denmEvents: StateFlow> = combine( + repo.topicMessages.map { byTopic -> (byTopic[DENM_RX_TOPIC] ?: emptyList()) .mapNotNull { DenmParser.parse(it.payload, it.timestamp) } - .associateBy { it.dedupKey } // last write wins = most recent per hazard - .values - .sortedByDescending { it.timestamp } - } - .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) + }, + // Air DENMs accumulate here rather than being a snapshot: the serial path delivers one + // event at a time, so runningFold keeps the set of hazards heard so far. + camUseCaseRepository.airDenm + .runningFold(emptyMap()) { acc, denm -> acc + (denm.dedupKey to denm) } + .map { it.values.toList() }, + ) { fromMqtt, fromAir -> + (fromMqtt + fromAir) + .filterNot { it.isTermination } // the hazard is over - stop drawing it + .associateBy { it.dedupKey } // last write wins = most recent per hazard + .values + .sortedByDescending { it.timestamp } + }.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) private companion object { /** Use Case API topic carrying received DENMs (CiT One path only). */ diff --git a/app/src/main/java/com/hawhamburg/micr0bu/viewmodel/SensorViewModel.kt b/app/src/main/java/com/hawhamburg/micr0bu/viewmodel/SensorViewModel.kt index 142cefa..90cdaff 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/viewmodel/SensorViewModel.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/viewmodel/SensorViewModel.kt @@ -194,7 +194,7 @@ class SensorViewModel(application: Application) : AndroidViewModel(application) csvWriter = BufferedWriter(FileWriter(File(sessionsDir, "$recordingSessionId.csv"))) csvWriter?.apply { appendLine("# MicrOBU Session Export") - appendLine("# Generated by MicrOBU v0.2.0 — HAW Hamburg / Project MicrOBU") + appendLine("# Generated by MicrOBU v0.2.0 - HAW Hamburg / Project MicrOBU") appendLine("# Session ID,$recordingSessionId") appendLine("# Start,${isoFmt.format(Date(startTime))}") appendLine() diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 4a99c16..795763f 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1,11 +1,11 @@ - + English Deutsch - V2X Begleiter — Phase 02 + V2X Begleiter - Phase 02 Dashboard @@ -26,7 +26,7 @@ Verbunden MQTT verbunden Verbindung zum Broker… - Broker nicht erreichbar — V2X-Einstellungen prüfen + Broker nicht erreichbar - V2X-Einstellungen prüfen Aufnahme Messwerte Fahrsitzung starten @@ -63,12 +63,12 @@ Bluetooth Bluetooth-Verbindung ist für Phase 03 geplant und noch nicht implementiert. ESP32-C5 (USB Seriell) - 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. - Nicht verbunden — ESP32-C5 an den nativen USB-C-Port anschließen und Verbinden antippen. + 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. + Nicht verbunden - ESP32-C5 an den nativen USB-C-Port anschließen und Verbinden antippen. Gerät erkannt, wird geöffnet… Warte auf USB-Berechtigung… Verbunden - Verbindungsfehler — Kabel und nativen USB-C-Port prüfen und erneut versuchen. + Verbindungsfehler - Kabel und nativen USB-C-Port prüfen und erneut versuchen. Mit ESP32-C5 verbinden Geräte suchen Suche läuft… @@ -126,7 +126,7 @@ Öffnet Ihre bevorzugte Karten-App In App anzeigen Aktuellen Standort auf einer In-App-Karte anzeigen - Noch kein GPS-Signal — gehen Sie ins Freie + Noch kein GPS-Signal - gehen Sie ins Freie Standortkarte Aktueller Standort %1$d erfasste externe Verkehrsteilnehmer @@ -160,17 +160,17 @@ Entwickler Entwicklermodus WLAN-OBU-Verbindung - Nur Entwicklermodus — noch nicht implementiert + Nur Entwicklermodus - noch nicht implementiert Über App-Version - 0.5.0 (Phase 03 — ESP32-C5-Seriellverbindung + CAM vom Smartphone) + 0.5.0 (Phase 03 - ESP32-C5-Seriellverbindung + CAM vom Smartphone) Verbindung OBU per USB-C automatisch erkennen OBU-IP (manuell) OBU-Hardware CiT One ESP32-C5 - Der ESP32-C5 arbeitet als „dummer" Transceiver: CAM wird auf dem Smartphone erstellt und kodiert, über USB-Seriell an den ESP32 gesendet und über ITS-G5 gesendet. Auf diesem Pfad gibt es keinen MQTT-Broker und keine DENM-Use-Case-Engine — siehe den CAM-Pinger im V2X-Monitor für ein manuelles Testwerkzeug. + Der ESP32-C5 arbeitet als „dummer" Transceiver: CAM wird auf dem Smartphone erstellt und kodiert, über USB-Seriell an den ESP32 gesendet und über ITS-G5 gesendet. Auf diesem Pfad gibt es keinen MQTT-Broker und keine DENM-Use-Case-Engine - siehe den CAM-Pinger im V2X-Monitor für ein manuelles Testwerkzeug. Aktiver Transport USB-C WLAN @@ -187,31 +187,31 @@ Mit der OBU verbinden und auf V2X-Verkehr warten Noch keine Nachrichten zu diesem Thema - + Zuletzt gesendete Nutzlast: DENM-Übertragung (manueller Test) Test-DENM senden DENM stoppen Liegengebliebenes Fahrzeug (causeCode 94) Mit OBU verbinden, um DENM-Auslösung zu aktivieren - DENM aktiv — OBU sendet über ITS-G5 - Manueller Antennen-/RSU-Reichweitentest — sendet uca-denmctrl (retained) an v2x-uca/input/denmtrg. Wird nicht durch erkannte Ereignisse oder Use-Case-Alarme ausgelöst. + DENM aktiv - OBU sendet über ITS-G5 + Manueller Antennen-/RSU-Reichweitentest - sendet uca-denmctrl (retained) an v2x-uca/input/denmtrg. Wird nicht durch erkannte Ereignisse oder Use-Case-Alarme ausgelöst. Letztes TX anzeigen Ausblenden - + CAM-Pinger (manueller Test) - 1-Hz-CAM-Ping aus Live-GNSS- und IMU-Daten — prüft die serielle Verbindung und den ESP32-Funkpfad ohne Fahrtaufzeichnung. - Warte auf GNSS-Fix — noch nichts gesendet + 1-Hz-CAM-Ping aus Live-GNSS- und IMU-Daten - prüft die serielle Verbindung und den ESP32-Funkpfad ohne Fahrtaufzeichnung. + Warte auf GNSS-Fix - noch nichts gesendet - %1$d Station(en) in Reichweite — jeweils neueste CAM + %1$d Station(en) in Reichweite - jeweils neueste CAM Keine CAMs empfangen Dekodierte CAMs benachbarter Stationen erscheinen hier, sobald sie über die serielle Verbindung eintreffen. Station %1$d · %2$s %1$.1f km/h · Kurs %2$.0f° %1$.0f m - — m + - m %1$d dBm @@ -227,9 +227,9 @@ Straßenseiteneinheit Typ %1$d ESP32-C5 verbinden, um den CAM-Pinger zu aktivieren - Sendet — 1 CAM/s über die serielle Verbindung + Sendet - 1 CAM/s über die serielle Verbindung Gesendet: %1$d - Schreibfehler: %1$d in Folge — CAMs erreichen den ESP32 nicht + Schreibfehler: %1$d in Folge - CAMs erreichen den ESP32 nicht ESP32: TX-Fehler %1$d · zu groß %2$d · CRC-Fehler %3$d Pinger starten Pinger stoppen @@ -241,15 +241,15 @@ Station %1$d · %2$.0f m · Annäherung %3$.1f m/s · TTC %4$.1f s · %5$s - Kreuzendes Fahrzeug — %1$.0f s bis Konflikt - Stehendes Fahrzeug könnte losfahren — %1$.0f s - Auto biegt rechts auf dich zu — %1$.0f s - Auto biegt links auf dich zu — %1$.0f s - Schnell nahendes Fahrzeug — %1$.0f s + Kreuzendes Fahrzeug - %1$.0f s bis Konflikt + Stehendes Fahrzeug könnte losfahren - %1$.0f s + Auto biegt rechts auf dich zu - %1$.0f s + Auto biegt links auf dich zu - %1$.0f s + Schnell nahendes Fahrzeug - %1$.0f s Use Case Alerts - Ein-/Ausschalten pro Use Case für das CAM-basierte Use-Case-Alarm-Panel im V2X-Monitor. Alle Use Cases sind CAM-only — keiner löst ein DENM aus. + Ein-/Ausschalten pro Use Case für das CAM-basierte Use-Case-Alarm-Panel im V2X-Monitor. Alle Use Cases sind CAM-only - keiner löst ein DENM aus. Alarmstufen-Schwellenwerte (nur lesend) Warning Awareness @@ -263,12 +263,12 @@ BT - ⚠ OBU-stationType ≠ 2 (Radfahrer) — VRU-Erkennung an ausgerüsteten Kreuzungen ggf. beeinträchtigt + ⚠ OBU-stationType ≠ 2 (Radfahrer) - VRU-Erkennung an ausgerüsteten Kreuzungen ggf. beeinträchtigt Plattform Android / Kotlin / Jetpack Compose Projekt - MicrOBU — HAW Hamburg & consider it GmbH + MicrOBU - HAW Hamburg & consider it GmbH Fahrten diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4a3bb55..5ccf06e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2,7 +2,7 @@ MicrOBU - V2X Companion — Phase 02 + V2X Companion - Phase 02 English @@ -27,7 +27,7 @@ Connected MQTT connected Connecting to broker… - Broker unreachable — check V2X settings + Broker unreachable - check V2X settings Recording samples Start Driving Session @@ -64,12 +64,12 @@ Bluetooth Bluetooth connection is planned for Phase 03 and is not yet implemented. ESP32-C5 (USB Serial) - 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. - Not connected — plug the ESP32-C5 into the native USB-C port and tap Connect. + 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. + Not connected - plug the ESP32-C5 into the native USB-C port and tap Connect. Device detected, opening… Waiting for USB permission… Connected - Connection error — check the cable and native USB-C port, then try again. + Connection error - check the cable and native USB-C port, then try again. Connect to ESP32-C5 Scan for Devices Scanning… @@ -127,7 +127,7 @@ Opens in your preferred maps application View in App Show current location on an in-app map - No GPS fix yet — move to an open area + No GPS fix yet - move to an open area Location Map Current Location %1$d tracked remote road user(s) @@ -161,17 +161,17 @@ Developer Developer mode Wi-Fi OBU connection - Dev mode only — not implemented + Dev mode only - not implemented About App version - 0.5.0 (Phase 03 — ESP32-C5 serial link + phone-built CAM) + 0.5.0 (Phase 03 - ESP32-C5 serial link + phone-built CAM) Connection Auto-detect OBU via USB-C Manual OBU IP OBU Hardware CiT One ESP32-C5 - ESP32-C5 acts as a "dumb" transceiver: CAM is built and encoded on the phone, sent to the ESP32 over USB serial, and broadcast over ITS-G5. No MQTT broker or DENM use-case engine on this path — see the V2X Monitor screen\'s CAM Pinger for a manual test tool. + ESP32-C5 acts as a "dumb" transceiver: CAM is built and encoded on the phone, sent to the ESP32 over USB serial, and broadcast over ITS-G5. No MQTT broker or DENM use-case engine on this path - see the V2X Monitor screen\'s CAM Pinger for a manual test tool. Active transport USB-C Wi-Fi @@ -188,32 +188,39 @@ Connect to the OBU and wait for V2X traffic No messages on this topic yet - + Last transmitted payload: DENM Transmission (Manual Test) Send Test DENM Stop DENM Aftermarket Stationary Vehicle (causeCode 94) Connect to the OBU to enable DENM triggering - DENM active — OBU broadcasting via ITS-G5 - Manual antenna/RSU range test — publishes uca-denmctrl (retained) to v2x-uca/input/denmtrg. Not triggered by detected events or use case alerts. + DENM active - OBU broadcasting via ITS-G5 + Manual antenna/RSU range test - publishes uca-denmctrl (retained) to v2x-uca/input/denmtrg. Not triggered by detected events or use case alerts. Show last TX Hide - + CAM Pinger (Manual Test) - 1 Hz CAM ping built from live GNSS and IMU data — verifies the serial link and ESP32 radio path without needing a trip recording. - Waiting for GNSS fix — nothing transmitted yet + 1 Hz CAM ping built from live GNSS and IMU data - verifies the serial link and ESP32 radio path without needing a trip recording. + Waiting for GNSS fix - nothing transmitted yet - %1$d station(s) in range — latest CAM per station + %1$d station(s) in range - latest CAM per station No CAMs received Decoded CAMs from nearby stations appear here as they arrive over the serial link. Station %1$d · %2$s %1$.1f km/h · heading %2$.0f° %1$.0f m - — m + - m %1$d dBm + No CAMs received + + + %1$d active hazard(s) - latest DENM per event + %1$s · station %2$d + cause %1$d/%2$d + %1$d m radius Hazard: cause %1$d/%2$d (station %3$d) @@ -228,9 +235,9 @@ Roadside unit Type %1$d Connect the ESP32-C5 to enable the CAM pinger - Pinging — 1 CAM/s over the serial link + Pinging - 1 CAM/s over the serial link Sent: %1$d - Write failures: %1$d consecutive — CAMs are not reaching the ESP32 + Write failures: %1$d consecutive - CAMs are not reaching the ESP32 ESP32: tx fail %1$d · oversize %2$d · crc err %3$d Start Pinger Stop Pinger @@ -241,16 +248,16 @@ No active use case alerts Station %1$d · %2$.0f m · closing %3$.1f m/s · TTC %4$.1f s · %5$s - - Crossing vehicle ahead — %1$.0f s to conflict - Stopped vehicle may pull out — %1$.0f s - Car turning right toward you — %1$.0f s - Car turning left toward you — %1$.0f s - Fast-closing vehicle nearby — %1$.0f s + + Crossing vehicle ahead - %1$.0f s to conflict + Stopped vehicle may pull out - %1$.0f s + Car turning right toward you - %1$.0f s + Car turning left toward you - %1$.0f s + Fast-closing vehicle nearby - %1$.0f s Use Case Alerts - Per-use-case enable/disable for the CAM-based Use Case Alert panel on the V2X Monitor screen. All use cases are CAM-only — none of them trigger a DENM. + Per-use-case enable/disable for the CAM-based Use Case Alert panel on the V2X Monitor screen. All use cases are CAM-only - none of them trigger a DENM. Alert level thresholds (read-only) Warning Awareness @@ -264,12 +271,12 @@ BT - ⚠ OBU stationType ≠ 2 (cyclist) — VRU detection may be impaired at equipped intersections + ⚠ OBU stationType ≠ 2 (cyclist) - VRU detection may be impaired at equipped intersections Platform Android / Kotlin / Jetpack Compose Project - MicrOBU — HAW Hamburg & consider it GmbH + MicrOBU - HAW Hamburg & consider it GmbH Trips diff --git a/app/src/test/java/com/hawhamburg/micr0bu/DenmAirReceiveTest.kt b/app/src/test/java/com/hawhamburg/micr0bu/DenmAirReceiveTest.kt new file mode 100644 index 0000000..94f7983 --- /dev/null +++ b/app/src/test/java/com/hawhamburg/micr0bu/DenmAirReceiveTest.kt @@ -0,0 +1,199 @@ +package com.hawhamburg.micr0bu + +import com.hawhamburg.micr0bu.data.transport.BtpPort +import com.hawhamburg.micr0bu.data.transport.V2xRxFrame +import com.hawhamburg.micr0bu.domain.asn1.DenmUperCodec +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Regression tests for the ESP32-C5 over-the-air receive path: the `V2X_RX` serial payload layout + * and [DenmUperCodec]. + * + * ## Where the fixtures come from + * These are **real frames**, not hand-built ones. They were taken from + * `its-g5-receiver-firmware/recordings/capture_20260817_171055.pcap` — a live capture of a CiT One + * OBU running the HLN-SV use case — by replaying the capture through a port of the firmware's + * `gn_unwrap_its()` and `serial_link_send_v2x_rx()`, so each fixture is byte-for-byte what the + * ESP32-C5 hands the phone over USB. The capture's 4-byte 802.11 FCS is trimmed, because the WiFi + * driver strips it before the promiscuous callback ever sees the frame. + * + * ## Why the expected values can be trusted + * Every asserted field was cross-checked against `asn1tools` decoding the same bytes with the real + * ETSI modules from the `C-ITS-Parser` checkout (`denm_1_3_1.asn` + `cdd_1_3_1_1.asn`) — an + * independent implementation, not this codebase's own arithmetic. Across the full capture set that + * cross-check agreed on all 1885 decodable DENMs, on every field below including `detectionTime`. + * + * That matters because this project has twice shipped a UPER bug that was invisible until measured + * against real traffic (a one-bit `CurvatureCalculationMode` in CAM, and a 16-vs-17-bit + * `ValidityDuration` here that made a real frame read causeCode 47 instead of 94). Hand-built + * fixtures would have happily reproduced both. If a field width is ever "cleaned up", these tests + * are what should fail. + */ +class DenmAirReceiveTest { + + // ---- real captured V2X_RX serial payloads ---------------------------------------------- + + /** + * An active DENM: GeoBroadcast, BTP port 2002, stationaryVehicle (94/0), 1000 m relevance + * radius. 406-byte UPER message — most of it the AlacarteContainer this decoder deliberately + * stops before reading. + */ + private val ACTIVE_V2X_RX = + "d207c10102bfeb1f7c53f905e8030201fa012bd0e77d0095e8000314c8317dba65320c5f6ff5590a8027143257c1dd1d" + + "d0001970898000781432f0030008b9f1be8a2fe943f9e6d390895181e3603696f542543bf04d0052201c02d9df83d7f6" + + "e159a88c4f016c402b2d548063f814fa02d66d24044bc0f2d018fb8cb0275e07d480681e550595eff16bfc5cc4e0040f" + + "80989ffe970e40d0bbffceffdcb1e20045dffe5802e184200bdefd313f9f4b3b008977e571fb0c58c00d1fc0ed301a7b" + + "5840121e04a380d518ce008beffa8c0044c2dc018f7f80a00775bf401dfbf39500fda4ea038de000d800a18970036f02" + + "7fc01fee0b006b781ee6007a7e2c026bbfc0eff7e2f6c012bdf86b7f4953a1018af014b4004cd890031f8088200fb67c" + + "0018fc00e4ffe4328100efe024e7ff41afe0090f00bc3fff8d1880207802e1ffcd666a00c7c082aff763c1e017bdffe1" + + "7fee59230167efd073fb70abe005ef7f31dffaf5a3c05b3bff6effecb16a0063e0036804dd8380077efe833ff62b7c00" + + "4d77ee4200a249c010dfc0f84fecfc3f808d3dfbb47fa795fe007cc0e1178000f9010000" + + /** + * The termination of that same event, sent once when the hazard ended. Note it carries **no + * SituationContainer** — a terminating DENM says "event over", not what the event was — so + * `causeCode` is legitimately null here and code must not treat that as a decode failure. + */ + private val TERMINATION_V2X_RX = + "d207c101b6c6eb1f2158f905e8030201fa012bd00f7d0095e8000314c8329f58e5320ca7d792ac857db38a1951098498" + + "48000ccd7d40003c0a00000019" + + /** A real CAM from the same capture, for checking the port routing rejects non-DENM cleanly. */ + private val CAM_V2X_RX = + "d107c100000000000000000000000202fa012bd0f8e8605ab214f9ae2864b2415015000032c950487c1fa0010ebfe9ea" + + "7b33ff01fffa0028331400fbfab8fe6eb5a222ebe078d80da5bd50950efc134014880980b677e0f5fdb856542313c05b" + + "100acb552018fe053f80b59b490112f03cb4063ee33009d781f5201a079541657bfc5aff1731380103e02627ffa5c390" + + "342efff3bff72c7b001177ff9600b8610802f7bf4c4fe7d2ce20225df95c7ec316300347f03b4c069ed6100486900000" + + "0801" + + /** Later than every fixture's detectionTime, so the decoder's sanity window accepts them. */ + private val receivedAt = 1_787_100_000_000L + + private fun String.hexToBytes(): ByteArray = + chunked(2).map { it.toInt(16).toByte() }.toByteArray() + + // ---- the V2X_RX prefix contract -------------------------------------------------------- + + @Test + fun `active DENM frame parses its metadata prefix`() { + val frame = V2xRxFrame.parse(ACTIVE_V2X_RX.hexToBytes()) + assertNotNull(frame) + frame!! + + assertEquals(BtpPort.DENM, frame.btpPort) + assertEquals(-63, frame.rssiDbm) // int8: must survive as negative, not 193 + + // GeoBroadcast destination area, converted from GeoNetworking's big-endian 1/10 microdegree + // to the little-endian prefix and back out again. + val area = frame.geoArea + assertNotNull(area) + assertEquals(53.5543554, area!!.latitude, 1e-7) + assertEquals(10.0225916, area.longitude, 1e-7) + assertEquals(1000, area.radiusMeters) + + assertEquals(406, frame.uper.size) + } + + @Test + fun `frame no larger than the firmware's serial payload cap`() { + // SERIAL_LINK_MAX_PAYLOAD is 512 on both sides; the firmware counts an oversize drop rather + // than truncating. A real GeoBroadcast DENM is the largest thing this path carries today. + assertTrue( + "real DENM V2X_RX payload must fit SERIAL_LINK_MAX_PAYLOAD", + ACTIVE_V2X_RX.hexToBytes().size <= 512, + ) + } + + @Test + fun `parse rejects a payload with no room for a message`() { + assertNull(V2xRxFrame.parse(ByteArray(V2xRxFrame.PREFIX_SIZE))) + assertNull(V2xRxFrame.parse(ByteArray(3))) + } + + // ---- DENM decode ---------------------------------------------------------------------- + + @Test + fun `decodes a real stationaryVehicle DENM`() { + val frame = V2xRxFrame.parse(ACTIVE_V2X_RX.hexToBytes())!! + val denm = DenmUperCodec.decode( + bytes = frame.uper, + receivedAtEpochMs = receivedAt, + rssiDbm = frame.rssiDbm, + relevanceRadiusM = frame.geoArea?.radiusMeters, + ) + assertNotNull("real captured DENM must decode", denm) + denm!! + + // actionID - the ETSI event identity, cross-checked against asn1tools. + assertEquals(4_194_380_752L, denm.stationId) + assertEquals(6, denm.sequenceNumber) + + assertEquals(53.5543554, denm.latitude, 1e-7) + assertEquals(10.0225916, denm.longitude, 1e-7) + + assertEquals(94, denm.causeCode) // stationaryVehicle + assertEquals(0, denm.subCauseCode) + assertEquals(5, denm.stationType) // passengerCar + + assertFalse(denm.isTermination) + + // detectionTime is a 42-bit TimestampIts counted from the 2004 ITS epoch. Getting either + // the width or the epoch wrong lands the hazard decades away, so the absolute value is + // asserted rather than a range. + assertEquals(1_786_979_460_563L, denm.detectionTimeMs) + + // Carried through from the GeoNetworking header and the serial prefix, not the payload. + assertEquals(1000, denm.relevanceRadiusM) + assertEquals(-63, denm.rssiDbm) + } + + @Test + fun `decodes a termination DENM and keeps the same event identity`() { + val active = V2xRxFrame.parse(ACTIVE_V2X_RX.hexToBytes())!! + val term = V2xRxFrame.parse(TERMINATION_V2X_RX.hexToBytes())!! + + val activeDenm = DenmUperCodec.decode(active.uper, receivedAt)!! + val termDenm = DenmUperCodec.decode(term.uper, receivedAt)!! + + assertTrue(termDenm.isTermination) + assertNull("a terminating DENM carries no SituationContainer", termDenm.causeCode) + assertEquals(4_194_380_752L, termDenm.stationId) + assertEquals(6, termDenm.sequenceNumber) + assertEquals(1_786_980_053_703L, termDenm.detectionTimeMs) + + // The whole point of keying dedup on actionID: the termination must land on the same key as + // the event it ends, so filtering terminations actually removes that hazard from the map + // instead of leaving the active pin behind next to a hidden one. + assertEquals(activeDenm.dedupKey, termDenm.dedupKey) + } + + @Test + fun `does not decode a CAM as a DENM`() { + val cam = V2xRxFrame.parse(CAM_V2X_RX.hexToBytes())!! + assertEquals(BtpPort.CAM, cam.btpPort) + assertNull("CAM must not decode as DENM - messageID guards this", DenmUperCodec.decode(cam.uper, receivedAt)) + } + + @Test + fun `returns null for a truncated DENM rather than a misplaced hazard`() { + val frame = V2xRxFrame.parse(ACTIVE_V2X_RX.hexToBytes())!! + // Cut inside the ManagementContainer: the BitReader runs out mid-field. + assertNull(DenmUperCodec.decode(frame.uper.copyOfRange(0, 12), receivedAt)) + } + + @Test + fun `future detection time beyond the sanity window is dropped, not surfaced`() { + val frame = V2xRxFrame.parse(ACTIVE_V2X_RX.hexToBytes())!! + // A phone whose clock is more than a day behind the sender: the event still decodes, but + // the implausible timestamp is reported as unknown instead of being shown. + val denm = DenmUperCodec.decode(frame.uper, receivedAtEpochMs = 1_700_000_000_000L) + assertNotNull(denm) + assertEquals(94, denm!!.causeCode) + assertNull(denm.detectionTimeMs) + } +} diff --git a/obu-cam-transmistter/main/geonet.c b/obu-cam-transmistter/main/geonet.c index 9fd2555..60ea6e0 100644 --- a/obu-cam-transmistter/main/geonet.c +++ b/obu-cam-transmistter/main/geonet.c @@ -7,9 +7,9 @@ int geonet_wrap_shb(const uint8_t *its_payload, int its_len, uint16_t btp_dest_port, uint8_t *out, size_t out_len) { - // GN Basic Header (4) + GN Common Header (8) + SHB source LPV (24) + // GN Basic Header (4) + GN Common Header (8) + SHB extended header (28) // + BTP-B header (4) + ITS payload - int total = 4 + 8 + 24 + 4 + its_len; + int total = 4 + 8 + 28 + 4 + its_len; if ((size_t)total > out_len) { return -1; } @@ -43,7 +43,7 @@ int geonet_wrap_shb(const uint8_t *its_payload, int its_len, *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) ---- + // ---- SHB extended header: Source Position Vector (24) + Reserved (4) = 28 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 @@ -71,6 +71,13 @@ int geonet_wrap_shb(const uint8_t *its_payload, int its_len, *p++ = 0x00; *p++ = 0x00; // Heading (16 bits, 0.1 degree units): 0 = due north / unavailable *p++ = 0x00; *p++ = 0x00; + // Reserved (4 bytes) - clause 9.8.4: the SHB extended header is the 24-byte Source Position + // Vector FOLLOWED BY a 4-byte reserved field (media-dependent data), 28 bytes in total. These + // four bytes were missing, which is why a standards-compliant receiver read our CAM payload's + // first two bytes (0x02 0x02 = protocolVersion/messageID) as the BTP destination port and saw + // 514 instead of 2001 - confirmed against live air capture, 2026-08-13. Our own gn_unwrap.c + // had the identical off-by-four, so ESP32<->ESP32 worked and nothing else did. + *p++ = 0x00; *p++ = 0x00; *p++ = 0x00; *p++ = 0x00; // ---- BTP-B header (4 bytes) ---- *p++ = (uint8_t)(btp_dest_port >> 8); diff --git a/obu-firmware/main/geonet.c b/obu-firmware/main/geonet.c index 9fd2555..60ea6e0 100644 --- a/obu-firmware/main/geonet.c +++ b/obu-firmware/main/geonet.c @@ -7,9 +7,9 @@ int geonet_wrap_shb(const uint8_t *its_payload, int its_len, uint16_t btp_dest_port, uint8_t *out, size_t out_len) { - // GN Basic Header (4) + GN Common Header (8) + SHB source LPV (24) + // GN Basic Header (4) + GN Common Header (8) + SHB extended header (28) // + BTP-B header (4) + ITS payload - int total = 4 + 8 + 24 + 4 + its_len; + int total = 4 + 8 + 28 + 4 + its_len; if ((size_t)total > out_len) { return -1; } @@ -43,7 +43,7 @@ int geonet_wrap_shb(const uint8_t *its_payload, int its_len, *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) ---- + // ---- SHB extended header: Source Position Vector (24) + Reserved (4) = 28 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 @@ -71,6 +71,13 @@ int geonet_wrap_shb(const uint8_t *its_payload, int its_len, *p++ = 0x00; *p++ = 0x00; // Heading (16 bits, 0.1 degree units): 0 = due north / unavailable *p++ = 0x00; *p++ = 0x00; + // Reserved (4 bytes) - clause 9.8.4: the SHB extended header is the 24-byte Source Position + // Vector FOLLOWED BY a 4-byte reserved field (media-dependent data), 28 bytes in total. These + // four bytes were missing, which is why a standards-compliant receiver read our CAM payload's + // first two bytes (0x02 0x02 = protocolVersion/messageID) as the BTP destination port and saw + // 514 instead of 2001 - confirmed against live air capture, 2026-08-13. Our own gn_unwrap.c + // had the identical off-by-four, so ESP32<->ESP32 worked and nothing else did. + *p++ = 0x00; *p++ = 0x00; *p++ = 0x00; *p++ = 0x00; // ---- BTP-B header (4 bytes) ---- *p++ = (uint8_t)(btp_dest_port >> 8); diff --git a/obu-firmware/main/gn_unwrap.c b/obu-firmware/main/gn_unwrap.c index 4e39440..a349a9a 100644 --- a/obu-firmware/main/gn_unwrap.c +++ b/obu-firmware/main/gn_unwrap.c @@ -12,22 +12,47 @@ #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) +// Extended-header lengths per GeoNetworking header type - see gn_unwrap.h for why these exact +// numbers, and why they must not be assumed equal. +#define GN_SHB_EXT_HEADER_LEN (28) // SO PV (24) + Reserved (4) +#define GN_GBC_EXT_HEADER_LEN (44) // SN(2) + Rsvd(2) + SO PV(24) + area(12) + Rsvd(4) + +// Offsets of the destination-area fields within the GBC extended header. +#define GBC_AREA_LAT_OFFSET (28) +#define GBC_AREA_LON_OFFSET (32) +#define GBC_AREA_DIST_A_OFFSET (36) + +#define GN_HEADER_TYPE_GBC (4) // GeoBroadcast #define GN_HEADER_TYPE_TSB (5) // Topologically-Scoped Broadcast #define GN_HEADER_SUBTYPE_SINGLE_HOP (0) +#define GN_NEXT_HEADER_COMMON (1) // unsecured; 2 would be a secured packet +#define GN_COMMON_NEXT_HEADER_BTP_B (2) + #define BTP_DEST_PORT_CAM (2001) // ETSI TS 103 248 +#define BTP_DEST_PORT_DENM (2002) 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) +static int32_t be32(const uint8_t *p) { - if (!frame || frame_len < IEEE80211_HEADER_LEN) { + return (int32_t)(((uint32_t)p[0] << 24) | ((uint32_t)p[1] << 16) | + ((uint32_t)p[2] << 8) | (uint32_t)p[3]); +} + +static uint16_t be16(const uint8_t *p) +{ + return (uint16_t)(((uint16_t)p[0] << 8) | (uint16_t)p[1]); +} + +bool gn_unwrap_its(const uint8_t *frame, int frame_len, gn_rx_t *out) +{ + if (!frame || !out || frame_len < IEEE80211_HEADER_LEN) { return false; } + memset(out, 0, sizeof(*out)); uint8_t fc0 = frame[0]; uint8_t fc1 = frame[1]; @@ -36,8 +61,8 @@ bool gn_unwrap_cam(const uint8_t *frame, int frame_len, 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). + // Only plain broadcast Data frames, no WDS. Both QoS Data (what real ITS-G5 hardware sends, + // 26-byte header) and non-QoS Data (24-byte, what our own TX currently builds) are accepted. if (type != IEEE80211_FC_TYPE_DATA || (to_ds && from_ds)) { return false; } @@ -53,17 +78,22 @@ bool gn_unwrap_cam(const uint8_t *frame, int frame_len, 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) { + if (be16(frame + offset + 6) != 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.) + // ---- GN Basic Header (4 bytes) ---- if (frame_len < offset + GN_BASIC_HEADER_LEN) { return false; } + // NextHeader distinguishes an unsecured packet (1 = Common Header follows) from a secured one + // (2 = a TS 103 097 SecuredMessage follows, with the Common Header buried inside it at a + // variable offset). Checking this rather than blindly skipping means a secured packet is + // rejected cleanly instead of having its security envelope misread as a Common Header. + if ((frame[offset] & 0x0F) != GN_NEXT_HEADER_COMMON) { + return false; + } offset += GN_BASIC_HEADER_LEN; // ---- GN Common Header (8 bytes) ---- @@ -73,42 +103,54 @@ bool gn_unwrap_cam(const uint8_t *frame, int frame_len, 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. + if (next_header != GN_COMMON_NEXT_HEADER_BTP_B) { 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) { + // ---- Extended header: length depends on the header type ---- + int ext_len; + bool is_gbc = false; + if (header_type == GN_HEADER_TYPE_TSB && header_subtype == GN_HEADER_SUBTYPE_SINGLE_HOP) { + ext_len = GN_SHB_EXT_HEADER_LEN; + } else if (header_type == GN_HEADER_TYPE_GBC) { + // Subtype selects the area shape (0 circle, 1 rectangle, 2 ellipse). All three carry the + // same field layout - DistanceB and Angle are simply unused for a circle - so the length + // is the same and we don't need to branch on it. + ext_len = GN_GBC_EXT_HEADER_LEN; + is_gbc = true; + } else { + return false; // Beacon / GeoUnicast / GeoAnycast / multi-hop TSB - see header comment + } + if (frame_len < offset + ext_len) { return false; } - offset += GN_SHB_EXT_HEADER_LEN; + if (is_gbc) { + out->has_geo_area = true; + out->geo_area_lat_tenmicrodeg = be32(frame + offset + GBC_AREA_LAT_OFFSET); + out->geo_area_lon_tenmicrodeg = be32(frame + offset + GBC_AREA_LON_OFFSET); + out->geo_area_distance_a_m = be16(frame + offset + GBC_AREA_DIST_A_OFFSET); + } + offset += ext_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 + uint16_t dest_port = be16(frame + offset); + if (dest_port != BTP_DEST_PORT_CAM && dest_port != BTP_DEST_PORT_DENM) { + return false; } offset += BTP_B_HEADER_LEN; - // ---- Whatever's left is the CAM UPER payload ---- - int cam_len = frame_len - offset; - if (cam_len <= 0) { + // ---- Whatever's left is the ITS UPER payload ---- + int payload_len = frame_len - offset; + if (payload_len <= 0) { return false; } - *out_cam = frame + offset; - *out_cam_len = cam_len; + out->btp_dest_port = dest_port; + out->payload = frame + offset; + out->payload_len = payload_len; return true; } diff --git a/obu-firmware/main/gn_unwrap.h b/obu-firmware/main/gn_unwrap.h index f57ecb2..02e4faf 100644 --- a/obu-firmware/main/gn_unwrap.h +++ b/obu-firmware/main/gn_unwrap.h @@ -5,35 +5,64 @@ #include // 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). +// the WiFi driver's promiscuous RX callback and strips 802.11 header -> LLC/SNAP -> +// GeoNetworking Basic/Common/extended header -> BTP-B header, leaving the ITS payload (a UPER +// message) plus the metadata the phone needs to know what it received. // -// 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. +// ---- Supported GeoNetworking header types -------------------------------------------------- +// Two shapes, chosen by the Common Header's HeaderType, with DIFFERENT extended-header lengths: // -// 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. +// TSB/SINGLE_HOP (HT=5, HST=0) - 28 bytes: Source Position Vector (24) + Reserved (4). +// What CAM uses, and what geonet_wrap_shb() builds. +// GEOBROADCAST (HT=4) - 44 bytes: SeqNum (2) + Reserved (2) + SO PV (24) + +// GeoArea lat (4) + lon (4) + DistanceA (2) + DistanceB (2) + Angle (2) + Reserved (2). +// What DENM uses in practice - real RSUs and OBUs disseminate DENM by GeoBroadcast so it +// can be forwarded across an area, not by single-hop broadcast. // -// 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). +// Both lengths are measured facts, not spec-table guesses: verified against live air capture on +// 2026-08-17 (its-g5-receiver-firmware/recordings/capture_20260817_171055.pcap) by locating the +// BTP port and ItsPduHeader and checking they agree. An earlier version of this file used 24 for +// the SHB case, four bytes short, which read the BTP port out of the Reserved field and silently +// dropped EVERY real CAM. Do not "simplify" these constants without re-measuring. // -// 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. +// Beacon, GeoUnicast, GeoAnycast and multi-hop TSB are still rejected - nothing this project +// talks to sends them, and each has its own extended-header length that would need measuring. // -// 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); +// ---- Accepted BTP-B ports (ETSI TS 103 248) ------------------------------------------------ +// 2001 (CAM) and 2002 (DENM). MAPEM (2003), SPATEM (2004) and the rest are deliberately not +// accepted yet: the phone has no decoder for them, so forwarding would just burn serial +// bandwidth. Adding one is a one-line change here plus a decoder on the phone - the serial +// protocol itself is already generic (see SERIAL_MSG_V2X_RX in serial_link.h). +// +// ---- What is NOT handled ------------------------------------------------------------------- +// Secured packets (GN Basic Header NextHeader=2, i.e. ETSI TS 103 097 signed messages). The +// units on this bench run with ItsGnSecurity=0 so everything observed is unsecured; a secured +// packet is rejected rather than mis-parsed. +// +// No FCS/CRC check: the WiFi driver has already validated and stripped it. +typedef struct { + // BTP-B destination port, identifying the service: 2001 = CAM, 2002 = DENM. + uint16_t btp_dest_port; + + // ITS payload (UPER message bytes). Points INTO the caller's `frame` buffer - NOT a copy, so + // it is only valid while `frame` is. + const uint8_t *payload; + int payload_len; + + // GeoBroadcast destination area, when this frame carried one (GEOBROADCAST only; false for + // TSB/SHB). This is the hazard's relevance area - for a DENM it says "this warning applies + // within DistanceA metres of this point", which is materially more useful on a map than the + // originator's own position. + bool has_geo_area; + int32_t geo_area_lat_tenmicrodeg; + int32_t geo_area_lon_tenmicrodeg; + uint16_t geo_area_distance_a_m; +} gn_rx_t; + +// Returns true and fills *out if this was a well-formed, supported ITS frame. Returns false +// otherwise (wrong ethertype, secured, unsupported header type, unaccepted BTP port, truncated, +// or promiscuous-capture garbage) - all common and expected on an open-air capture, so the caller +// should treat false as "not for us", not as an error worth logging per frame. +bool gn_unwrap_its(const uint8_t *frame, int frame_len, gn_rx_t *out); #endif diff --git a/obu-firmware/main/main.c b/obu-firmware/main/main.c index 07f90de..46a0f08 100644 --- a/obu-firmware/main/main.c +++ b/obu-firmware/main/main.c @@ -154,8 +154,15 @@ static void tx_radio_task(void *arg) // 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. +// Capture buffer per queued frame. 800 bytes because real traffic is much larger than our own +// TX: a CiT One CAM measures 286-355 bytes on air and its GeoBroadcast DENM measures 528 +// (measured 2026-08-17). The previous 400 silently truncated every DENM mid-payload, which no +// amount of correct unwrapping downstream could have recovered from. Raise this before adding +// MAPEM, which is larger again. +#define RX_FRAME_MAX_LEN 800 + typedef struct { - uint8_t data[400]; // generous vs. our own ~300-byte TX frames; longer frames are truncated + uint8_t data[RX_FRAME_MAX_LEN]; int len; int8_t rssi; } rx_item_t; @@ -200,13 +207,17 @@ static void rx_forward_task(void *arg) 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); + // Most promiscuously-captured frames are NOT ITS traffic we handle (management/control + // frames, other message types, our own loopback if the driver echoes it) - gn_unwrap_its + // returning false here is the common case, not an error, so it isn't logged per frame. + gn_rx_t rx; + if (gn_unwrap_its(item.data, item.len, &rx)) { + serial_link_send_v2x_rx(rx.btp_dest_port, item.rssi, + rx.has_geo_area, + rx.geo_area_lat_tenmicrodeg, + rx.geo_area_lon_tenmicrodeg, + rx.geo_area_distance_a_m, + rx.payload, rx.payload_len); } } } diff --git a/obu-firmware/main/serial_link.c b/obu-firmware/main/serial_link.c index 42d95cf..bf68bae 100644 --- a/obu-firmware/main/serial_link.c +++ b/obu-firmware/main/serial_link.c @@ -112,23 +112,47 @@ static bool send_frame(uint8_t type, const uint8_t *payload, int len) 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) +bool serial_link_send_v2x_rx(uint16_t btp_dest_port, int8_t rssi, + bool has_geo_area, + int32_t geo_area_lat_tenmicrodeg, + int32_t geo_area_lon_tenmicrodeg, + uint16_t geo_area_distance_a_m, + const uint8_t *uper, int uper_len) { - if (cam_len < 0 || cam_len > SERIAL_LINK_MAX_PAYLOAD - 1) { + if (uper_len < 0 || uper_len > SERIAL_LINK_MAX_PAYLOAD - SERIAL_V2X_RX_PREFIX_LEN) { // Counted, not just logged: this log line goes to the flashing port, which nobody is // watching during a phone bench session - so the symptom would be "that station just // never shows up in the app" with no visible cause. bump(&s_oversize_drops); - ESP_LOGW(TAG, "send_cam_rx: cam_len too large (%d), total oversize drops %u", - cam_len, s_oversize_drops); + ESP_LOGW(TAG, "send_v2x_rx: port %u payload too large (%d), total oversize drops %u", + btp_dest_port, uper_len, s_oversize_drops); return false; } - // static, not stack (515 bytes at MAX_PAYLOAD 512); only rx_forward_task calls this, and + // static, not stack (526 bytes at MAX_PAYLOAD 512); only rx_forward_task calls this, and // send_frame's mutex covers the handoff onto the wire. - static uint8_t s_cam_rx_payload[SERIAL_LINK_MAX_PAYLOAD]; - s_cam_rx_payload[0] = (uint8_t)rssi; - memcpy(s_cam_rx_payload + 1, cam_uper, (size_t)cam_len); - return send_frame(SERIAL_MSG_CAM_RX, s_cam_rx_payload, 1 + cam_len); + static uint8_t s_v2x_payload[SERIAL_LINK_MAX_PAYLOAD]; + + // Little-endian prefix, layout documented in serial_link.h - keep in lockstep with the app's + // SerialFrame.kt. + s_v2x_payload[0] = (uint8_t)(btp_dest_port & 0xFF); + s_v2x_payload[1] = (uint8_t)((btp_dest_port >> 8) & 0xFF); + s_v2x_payload[2] = (uint8_t)rssi; + s_v2x_payload[3] = has_geo_area ? 0x01 : 0x00; + uint32_t lat = (uint32_t)geo_area_lat_tenmicrodeg; + uint32_t lon = (uint32_t)geo_area_lon_tenmicrodeg; + s_v2x_payload[4] = (uint8_t)(lat & 0xFF); + s_v2x_payload[5] = (uint8_t)((lat >> 8) & 0xFF); + s_v2x_payload[6] = (uint8_t)((lat >> 16) & 0xFF); + s_v2x_payload[7] = (uint8_t)((lat >> 24) & 0xFF); + s_v2x_payload[8] = (uint8_t)(lon & 0xFF); + s_v2x_payload[9] = (uint8_t)((lon >> 8) & 0xFF); + s_v2x_payload[10] = (uint8_t)((lon >> 16) & 0xFF); + s_v2x_payload[11] = (uint8_t)((lon >> 24) & 0xFF); + s_v2x_payload[12] = (uint8_t)(geo_area_distance_a_m & 0xFF); + s_v2x_payload[13] = (uint8_t)((geo_area_distance_a_m >> 8) & 0xFF); + + if (uper_len > 0) memcpy(s_v2x_payload + SERIAL_V2X_RX_PREFIX_LEN, uper, (size_t)uper_len); + return send_frame(SERIAL_MSG_V2X_RX, s_v2x_payload, SERIAL_V2X_RX_PREFIX_LEN + uper_len); } bool serial_link_send_status(uint8_t status) diff --git a/obu-firmware/main/serial_link.h b/obu-firmware/main/serial_link.h index 6d0be8e..7a6f555 100644 --- a/obu-firmware/main/serial_link.h +++ b/obu-firmware/main/serial_link.h @@ -28,11 +28,25 @@ // 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_CAM_RX (0x02), ESP32 -> phone: SUPERSEDED by SERIAL_MSG_V2X_RX, no longer sent. +// The constant is kept so the numbering is not silently reused by a future message type. +// SERIAL_MSG_V2X_RX (0x04), ESP32 -> phone: any ITS message 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. Payload is a fixed 14-byte prefix followed by the UPER bytes: +// +// [0..1] btp_dest_port uint16 LE 2001 = CAM, 2002 = DENM (ETSI TS 103 248) +// [2] rssi int8 dBm, from the promiscuous RX metadata +// [3] flags uint8 bit0: geo area fields below are valid +// [4..7] geo_area_lat int32 LE 1/10 microdegree, GeoBroadcast destination area +// [8..11] geo_area_lon int32 LE 1/10 microdegree +// [12..13] geo_area_dist uint16 LE Distance A, metres (relevance radius for a circle) +// [14..] UPER message bytes +// +// All prefix fields are LITTLE-endian, matching this framing's own length field - note the +// GeoNetworking wire format they came from is big-endian, so gn_unwrap.c converts. +// Generic on purpose: adding MAPEM/SPATEM later needs a decoder on the phone and one port in +// gn_unwrap.c, but no change to this protocol. No station id is carried separately - each +// message's own ItsPduHeader.stationID is the meaningful identifier. // SERIAL_MSG_STATUS (0x03), ESP32 -> phone: heartbeat + counters, sent at 1 Hz so the phone can // distinguish "link idle" from "link dead" independent of CAM traffic (the app's watchdog in // UsbSerialTransport.kt declares the link dead after 3 missed beats). Payload is 7 bytes: @@ -44,6 +58,10 @@ #define SERIAL_MSG_CAM_TX 0x01 #define SERIAL_MSG_CAM_RX 0x02 #define SERIAL_MSG_STATUS 0x03 +#define SERIAL_MSG_V2X_RX 0x04 + +// Size of the V2X_RX prefix documented above. Must match the app's SerialFrame.kt. +#define SERIAL_V2X_RX_PREFIX_LEN 14 // USB Serial/JTAG has no baud rate or GPIO pins to configure - it's a fixed on-chip USB device // controller wired directly to the native USB-C port's D+/D- lines in silicon. RX/TX buffer @@ -76,10 +94,17 @@ 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 SERIAL_MSG_V2X_RX frame: the metadata prefix plus the UPER bytes gn_unwrap.c extracted +// from an over-the-air frame. Pass has_geo_area=false and zeroes for the area fields when the +// source frame carried no destination area (i.e. it was single-hop broadcast, not GeoBroadcast). +// Returns true if the frame was written to the USB endpoint - not an end-to-end ack, the phone +// may still drop it. +bool serial_link_send_v2x_rx(uint16_t btp_dest_port, int8_t rssi, + bool has_geo_area, + int32_t geo_area_lat_tenmicrodeg, + int32_t geo_area_lon_tenmicrodeg, + uint16_t geo_area_distance_a_m, + const uint8_t *uper, int uper_len); // Sends one SERIAL_MSG_STATUS heartbeat frame immediately (status byte + the current counters). // Normally unnecessary to call by hand - serial_link_init() starts a task that does this at 1 Hz.