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.
This commit is contained in:
Ashin Walpola
2026-08-17 18:42:48 +02:00
parent f1770e11dd
commit 0ccb867228
19 changed files with 989 additions and 188 deletions
@@ -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))}")
@@ -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"))
@@ -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<Cam> = _processedCam.asSharedFlow()
private val _airDenm = MutableSharedFlow<DenmEvent>(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<DenmEvent> = _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 ""
}
@@ -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 {
@@ -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
}
@@ -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") }
@@ -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<Long, com.hawhamburg.micr0bu.domain.cam.Cam>,
alerts: List<UseCaseAlert>,
denms: List<com.hawhamburg.micr0bu.domain.denm.DenmEvent>,
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,
)
}
}
}
@@ -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<List<DenmEvent>> = repo.topicMessages
.map { byTopic ->
val denmEvents: StateFlow<List<DenmEvent>> = combine(
repo.topicMessages.map { byTopic ->
(byTopic[DENM_RX_TOPIC] ?: emptyList())
.mapNotNull { DenmParser.parse(it.payload, it.timestamp) }
},
// 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<String, DenmEvent>()) { 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())
}.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
private companion object {
/** Use Case API topic carrying received DENMs (CiT One path only). */
@@ -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()
+28 -28
View File
@@ -1,11 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Language names — intentionally NOT translated so they always read in their own language -->
<!-- Language names - intentionally NOT translated so they always read in their own language -->
<string name="lang_english">English</string>
<string name="lang_german">Deutsch</string>
<!-- App -->
<string name="app_subtitle">V2X Begleiter — Phase 02</string>
<string name="app_subtitle">V2X Begleiter - Phase 02</string>
<!-- Navigation -->
<string name="nav_dashboard">Dashboard</string>
@@ -26,7 +26,7 @@
<string name="dash_connected">Verbunden</string>
<string name="dash_mqtt_connected">MQTT verbunden</string>
<string name="dash_mqtt_connecting">Verbindung zum Broker…</string>
<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_samples">Messwerte</string>
<string name="dash_start_driving_session">Fahrsitzung starten</string>
@@ -63,12 +63,12 @@
<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_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_esp32_state_disconnected">Nicht verbunden — ESP32-C5 an den nativen USB-C-Port anschließen und Verbinden antippen.</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_esp32_state_disconnected">Nicht verbunden - ESP32-C5 an den nativen USB-C-Port anschließen und Verbinden antippen.</string>
<string name="conn_esp32_state_device_attached">Gerät erkannt, wird geöffnet…</string>
<string name="conn_esp32_state_permission_requested">Warte auf USB-Berechtigung…</string>
<string name="conn_esp32_state_connected">Verbunden</string>
<string name="conn_esp32_state_error">Verbindungsfehler — Kabel und nativen USB-C-Port prüfen und erneut versuchen.</string>
<string name="conn_esp32_state_error">Verbindungsfehler - Kabel und nativen USB-C-Port prüfen und erneut versuchen.</string>
<string name="conn_esp32_connect">Mit ESP32-C5 verbinden</string>
<string name="conn_scan">Geräte suchen</string>
<string name="conn_scanning">Suche läuft…</string>
@@ -126,7 +126,7 @@
<string name="gnss_open_maps_sub">Öffnet Ihre bevorzugte Karten-App</string>
<string name="gnss_view_inapp">In App anzeigen</string>
<string name="gnss_view_inapp_sub">Aktuellen Standort auf einer In-App-Karte anzeigen</string>
<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_location_label">Aktueller Standort</string>
<string name="v2x_map_remote_count">%1$d erfasste externe Verkehrsteilnehmer</string>
@@ -160,17 +160,17 @@
<string name="settings_developer">Entwickler</string>
<string name="settings_dev_mode">Entwicklermodus</string>
<string name="settings_wifi">WLAN-OBU-Verbindung</string>
<string name="settings_wifi_val">Nur Entwicklermodus — noch nicht implementiert</string>
<string name="settings_wifi_val">Nur Entwicklermodus - noch nicht implementiert</string>
<string name="settings_about">Über</string>
<string name="settings_app_version">App-Version</string>
<string name="settings_app_version_val">0.5.0 (Phase 03 — ESP32-C5-Seriellverbindung + CAM vom Smartphone)</string>
<string name="settings_app_version_val">0.5.0 (Phase 03 - ESP32-C5-Seriellverbindung + CAM vom Smartphone)</string>
<string name="settings_connection">Verbindung</string>
<string name="settings_usb_auto_detect">OBU per USB-C automatisch erkennen</string>
<string name="settings_usb_manual_ip">OBU-IP (manuell)</string>
<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">Der ESP32-C5 arbeitet als „dummer" Transceiver: CAM wird auf dem Smartphone erstellt und kodiert, über USB-Seriell an den ESP32 gesendet und über ITS-G5 gesendet. Auf diesem Pfad gibt es keinen MQTT-Broker und keine DENM-Use-Case-Engine — siehe den CAM-Pinger im V2X-Monitor für ein manuelles Testwerkzeug.</string>
<string name="settings_obu_hardware_esp32_note">Der ESP32-C5 arbeitet als „dummer" Transceiver: CAM wird auf dem Smartphone erstellt und kodiert, über USB-Seriell an den ESP32 gesendet und über ITS-G5 gesendet. Auf diesem Pfad gibt es keinen MQTT-Broker und keine DENM-Use-Case-Engine - siehe den CAM-Pinger im V2X-Monitor für ein manuelles Testwerkzeug.</string>
<string name="settings_usb_transport">Aktiver Transport</string>
<string name="settings_transport_usbc">USB-C</string>
<string name="settings_transport_wifi">WLAN</string>
@@ -187,31 +187,31 @@
<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>
<!-- DENM TX — nur manueller Antennen-/RSU-Reichweitentest, nicht Use-Case-gesteuert -->
<!-- DENM TX - nur manueller Antennen-/RSU-Reichweitentest, nicht Use-Case-gesteuert -->
<string name="mqtt_last_tx">Zuletzt gesendete Nutzlast:</string>
<string name="mqtt_denm_tx_title">DENM-Übertragung (manueller Test)</string>
<string name="mqtt_send_denm">Test-DENM senden</string>
<string name="mqtt_stop_denm">DENM stoppen</string>
<string name="mqtt_denm_use_case_desc">Liegengebliebenes Fahrzeug (causeCode 94)</string>
<string name="mqtt_denm_not_connected">Mit OBU verbinden, um DENM-Auslösung zu aktivieren</string>
<string name="mqtt_denm_active">DENM aktiv — OBU sendet über ITS-G5</string>
<string name="mqtt_denm_hint">Manueller Antennen-/RSU-Reichweitentest — sendet uca-denmctrl (retained) an v2x-uca/input/denmtrg. Wird nicht durch erkannte Ereignisse oder Use-Case-Alarme ausgelöst.</string>
<string name="mqtt_denm_active">DENM aktiv - OBU sendet über ITS-G5</string>
<string name="mqtt_denm_hint">Manueller Antennen-/RSU-Reichweitentest - sendet uca-denmctrl (retained) an v2x-uca/input/denmtrg. Wird nicht durch erkannte Ereignisse oder Use-Case-Alarme ausgelöst.</string>
<string name="mqtt_denm_show">Letztes TX anzeigen</string>
<string name="mqtt_denm_hide">Ausblenden</string>
<!-- CAM-Pinger — nur ESP32-C5, manueller Bank-Test, Gegenstück zur DENM-TX-Karte oben -->
<!-- CAM-Pinger - nur ESP32-C5, manueller Bank-Test, Gegenstück zur DENM-TX-Karte oben -->
<string name="mqtt_cam_pinger_title">CAM-Pinger (manueller Test)</string>
<string name="mqtt_cam_pinger_desc">1-Hz-CAM-Ping aus Live-GNSS- und IMU-Daten — prüft die serielle Verbindung und den ESP32-Funkpfad ohne Fahrtaufzeichnung.</string>
<string name="mqtt_cam_pinger_no_fix">Warte auf GNSS-Fix — noch nichts gesendet</string>
<string name="mqtt_cam_pinger_desc">1-Hz-CAM-Ping aus Live-GNSS- und IMU-Daten - prüft die serielle Verbindung und den ESP32-Funkpfad ohne Fahrtaufzeichnung.</string>
<string name="mqtt_cam_pinger_no_fix">Warte auf GNSS-Fix - noch nichts gesendet</string>
<!-- Empfangene CAMs (ESP32-C5-Pfad) -->
<string name="v2x_cam_rx_count">%1$d Station(en) in Reichweite — jeweils neueste CAM</string>
<string name="v2x_cam_rx_count">%1$d Station(en) in Reichweite - jeweils neueste CAM</string>
<string name="v2x_cam_rx_none">Keine CAMs empfangen</string>
<string name="v2x_cam_rx_none_hint">Dekodierte CAMs benachbarter Stationen erscheinen hier, sobald sie über die serielle Verbindung eintreffen.</string>
<string name="v2x_cam_rx_station">Station %1$d · %2$s</string>
<string name="v2x_cam_rx_kinematics">%1$.1f km/h · Kurs %2$.0f°</string>
<string name="v2x_cam_rx_distance">%1$.0f m</string>
<string name="v2x_cam_rx_distance_unknown">— m</string>
<string name="v2x_cam_rx_distance_unknown">- m</string>
<string name="v2x_cam_rx_rssi">%1$d dBm</string>
<!-- DENM-Kartenmarker -->
@@ -227,9 +227,9 @@
<string name="station_type_rsu">Straßenseiteneinheit</string>
<string name="station_type_other">Typ %1$d</string>
<string name="mqtt_cam_pinger_not_connected">ESP32-C5 verbinden, um den CAM-Pinger zu aktivieren</string>
<string name="mqtt_cam_pinger_active">Sendet — 1 CAM/s über die serielle Verbindung</string>
<string name="mqtt_cam_pinger_active">Sendet - 1 CAM/s über die serielle Verbindung</string>
<string name="mqtt_cam_pinger_sent_count">Gesendet: %1$d</string>
<string name="mqtt_cam_pinger_send_failures">Schreibfehler: %1$d in Folge — CAMs erreichen den ESP32 nicht</string>
<string name="mqtt_cam_pinger_send_failures">Schreibfehler: %1$d in Folge - CAMs erreichen den ESP32 nicht</string>
<string name="mqtt_cam_pinger_fw_counters">ESP32: TX-Fehler %1$d · zu groß %2$d · CRC-Fehler %3$d</string>
<string name="mqtt_start_pinger">Pinger starten</string>
<string name="mqtt_stop_pinger">Pinger stoppen</string>
@@ -241,15 +241,15 @@
<string name="mqtt_usecase_detail">Station %1$d · %2$.0f m · Annäherung %3$.1f m/s · TTC %4$.1f s · %5$s</string>
<!-- Verständliche Use-Case-Beschreibungen -->
<string name="usecase_narrative_ima_b">Kreuzendes Fahrzeug — %1$.0f s bis Konflikt</string>
<string name="usecase_narrative_ima_s">Stehendes Fahrzeug könnte losfahren — %1$.0f s</string>
<string name="usecase_narrative_rtw_b">Auto biegt rechts auf dich zu — %1$.0f s</string>
<string name="usecase_narrative_ltw_b">Auto biegt links auf dich zu — %1$.0f s</string>
<string name="usecase_narrative_smva_bcw_b">Schnell nahendes Fahrzeug — %1$.0f s</string>
<string name="usecase_narrative_ima_b">Kreuzendes Fahrzeug - %1$.0f s bis Konflikt</string>
<string name="usecase_narrative_ima_s">Stehendes Fahrzeug könnte losfahren - %1$.0f s</string>
<string name="usecase_narrative_rtw_b">Auto biegt rechts auf dich zu - %1$.0f s</string>
<string name="usecase_narrative_ltw_b">Auto biegt links auf dich zu - %1$.0f s</string>
<string name="usecase_narrative_smva_bcw_b">Schnell nahendes Fahrzeug - %1$.0f s</string>
<!-- Einstellungen > Use Case Alerts -->
<string name="settings_usecase_alerts">Use Case Alerts</string>
<string name="settings_usecase_alerts_desc">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.</string>
<string name="settings_usecase_alerts_desc">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.</string>
<string name="settings_usecase_alert_levels_title">Alarmstufen-Schwellenwerte (nur lesend)</string>
<string name="settings_usecase_level_warning">Warning</string>
<string name="settings_usecase_level_awareness">Awareness</string>
@@ -263,12 +263,12 @@
<string name="dash_transport_bt">BT</string>
<!-- OBU stationType warning -->
<string name="dash_station_type_warning">⚠ OBU-stationType ≠ 2 (Radfahrer) — VRU-Erkennung an ausgerüsteten Kreuzungen ggf. beeinträchtigt</string>
<string name="dash_station_type_warning">⚠ OBU-stationType ≠ 2 (Radfahrer) - VRU-Erkennung an ausgerüsteten Kreuzungen ggf. beeinträchtigt</string>
<string name="settings_platform">Plattform</string>
<string name="settings_platform_val">Android / Kotlin / Jetpack Compose</string>
<string name="settings_project">Projekt</string>
<string name="settings_project_val">MicrOBU — HAW Hamburg &amp; consider it GmbH</string>
<string name="settings_project_val">MicrOBU - HAW Hamburg &amp; consider it GmbH</string>
<!-- Phase A: Trips (bottom nav) -->
<string name="nav_trips">Fahrten</string>
+35 -28
View File
@@ -2,7 +2,7 @@
<resources>
<!-- App -->
<string name="app_name">MicrOBU</string>
<string name="app_subtitle">V2X Companion — Phase 02</string>
<string name="app_subtitle">V2X Companion - Phase 02</string>
<!-- Language names (always shown in their own language) -->
<string name="lang_english">English</string>
@@ -27,7 +27,7 @@
<string name="dash_connected">Connected</string>
<string name="dash_mqtt_connected">MQTT connected</string>
<string name="dash_mqtt_connecting">Connecting to broker…</string>
<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_samples">samples</string>
<string name="dash_start_driving_session">Start Driving Session</string>
@@ -64,12 +64,12 @@
<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_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_esp32_state_disconnected">Not connected — plug the ESP32-C5 into the native USB-C port and tap Connect.</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_esp32_state_disconnected">Not connected - plug the ESP32-C5 into the native USB-C port and tap Connect.</string>
<string name="conn_esp32_state_device_attached">Device detected, opening…</string>
<string name="conn_esp32_state_permission_requested">Waiting for USB permission…</string>
<string name="conn_esp32_state_connected">Connected</string>
<string name="conn_esp32_state_error">Connection error — check the cable and native USB-C port, then try again.</string>
<string name="conn_esp32_state_error">Connection error - check the cable and native USB-C port, then try again.</string>
<string name="conn_esp32_connect">Connect to ESP32-C5</string>
<string name="conn_scan">Scan for Devices</string>
<string name="conn_scanning">Scanning…</string>
@@ -127,7 +127,7 @@
<string name="gnss_open_maps_sub">Opens in your preferred maps application</string>
<string name="gnss_view_inapp">View in App</string>
<string name="gnss_view_inapp_sub">Show current location on an in-app map</string>
<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_location_label">Current Location</string>
<string name="v2x_map_remote_count">%1$d tracked remote road user(s)</string>
@@ -161,17 +161,17 @@
<string name="settings_developer">Developer</string>
<string name="settings_dev_mode">Developer mode</string>
<string name="settings_wifi">Wi-Fi OBU connection</string>
<string name="settings_wifi_val">Dev mode only — not implemented</string>
<string name="settings_wifi_val">Dev mode only - not implemented</string>
<string name="settings_about">About</string>
<string name="settings_app_version">App version</string>
<string name="settings_app_version_val">0.5.0 (Phase 03 — ESP32-C5 serial link + phone-built CAM)</string>
<string name="settings_app_version_val">0.5.0 (Phase 03 - ESP32-C5 serial link + phone-built CAM)</string>
<string name="settings_connection">Connection</string>
<string name="settings_usb_auto_detect">Auto-detect OBU via USB-C</string>
<string name="settings_usb_manual_ip">Manual OBU IP</string>
<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 acts as a "dumb" transceiver: CAM is built and encoded on the phone, sent to the ESP32 over USB serial, and broadcast over ITS-G5. No MQTT broker or DENM use-case engine on this path — see the V2X Monitor screen\'s CAM Pinger for a manual test tool.</string>
<string name="settings_obu_hardware_esp32_note">ESP32-C5 acts as a "dumb" transceiver: CAM is built and encoded on the phone, sent to the ESP32 over USB serial, and broadcast over ITS-G5. No MQTT broker or DENM use-case engine on this path - see the V2X Monitor screen\'s CAM Pinger for a manual test tool.</string>
<string name="settings_usb_transport">Active transport</string>
<string name="settings_transport_usbc">USB-C</string>
<string name="settings_transport_wifi">Wi-Fi</string>
@@ -188,32 +188,39 @@
<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>
<!-- DENM TX — manual antenna/RSU-range test tool only, not use-case-driven -->
<!-- DENM TX - manual antenna/RSU-range test tool only, not use-case-driven -->
<string name="mqtt_last_tx">Last transmitted payload:</string>
<string name="mqtt_denm_tx_title">DENM Transmission (Manual Test)</string>
<string name="mqtt_send_denm">Send Test DENM</string>
<string name="mqtt_stop_denm">Stop DENM</string>
<string name="mqtt_denm_use_case_desc">Aftermarket Stationary Vehicle (causeCode 94)</string>
<string name="mqtt_denm_not_connected">Connect to the OBU to enable DENM triggering</string>
<string name="mqtt_denm_active">DENM active — OBU broadcasting via ITS-G5</string>
<string name="mqtt_denm_hint">Manual antenna/RSU range test — publishes uca-denmctrl (retained) to v2x-uca/input/denmtrg. Not triggered by detected events or use case alerts.</string>
<string name="mqtt_denm_active">DENM active - OBU broadcasting via ITS-G5</string>
<string name="mqtt_denm_hint">Manual antenna/RSU range test - publishes uca-denmctrl (retained) to v2x-uca/input/denmtrg. Not triggered by detected events or use case alerts.</string>
<string name="mqtt_denm_show">Show last TX</string>
<string name="mqtt_denm_hide">Hide</string>
<!-- CAM Pinger — ESP32-C5-only manual bench test, equivalent to the DENM TX card above -->
<!-- CAM Pinger - ESP32-C5-only manual bench test, equivalent to the DENM TX card above -->
<string name="mqtt_cam_pinger_title">CAM Pinger (Manual Test)</string>
<string name="mqtt_cam_pinger_desc">1 Hz CAM ping built from live GNSS and IMU data — verifies the serial link and ESP32 radio path without needing a trip recording.</string>
<string name="mqtt_cam_pinger_no_fix">Waiting for GNSS fix — nothing transmitted yet</string>
<string name="mqtt_cam_pinger_desc">1 Hz CAM ping built from live GNSS and IMU data - verifies the serial link and ESP32 radio path without needing a trip recording.</string>
<string name="mqtt_cam_pinger_no_fix">Waiting for GNSS fix - nothing transmitted yet</string>
<!-- Received-CAM list (ESP32-C5 path) -->
<string name="v2x_cam_rx_count">%1$d station(s) in range — latest CAM per station</string>
<string name="v2x_cam_rx_count">%1$d station(s) in range - latest CAM per station</string>
<string name="v2x_cam_rx_none">No CAMs received</string>
<string name="v2x_cam_rx_none_hint">Decoded CAMs from nearby stations appear here as they arrive over the serial link.</string>
<string name="v2x_cam_rx_station">Station %1$d · %2$s</string>
<string name="v2x_cam_rx_kinematics">%1$.1f km/h · heading %2$.0f°</string>
<string name="v2x_cam_rx_distance">%1$.0f m</string>
<string name="v2x_cam_rx_distance_unknown">— m</string>
<string name="v2x_cam_rx_distance_unknown">- m</string>
<string name="v2x_cam_rx_rssi">%1$d dBm</string>
<string name="v2x_cam_rx_none_stations">No CAMs received</string>
<!-- Received-DENM list (ESP32-C5 path) -->
<string name="v2x_denm_rx_count">%1$d active hazard(s) - latest DENM per event</string>
<string name="v2x_denm_rx_hazard">%1$s · station %2$d</string>
<string name="v2x_denm_rx_cause_code">cause %1$d/%2$d</string>
<string name="v2x_denm_rx_radius">%1$d m radius</string>
<!-- DENM map pins -->
<string name="v2x_map_denm_labeled">Hazard: cause %1$d/%2$d (station %3$d)</string>
@@ -228,9 +235,9 @@
<string name="station_type_rsu">Roadside unit</string>
<string name="station_type_other">Type %1$d</string>
<string name="mqtt_cam_pinger_not_connected">Connect the ESP32-C5 to enable the CAM pinger</string>
<string name="mqtt_cam_pinger_active">Pinging — 1 CAM/s over the serial link</string>
<string name="mqtt_cam_pinger_active">Pinging - 1 CAM/s over the serial link</string>
<string name="mqtt_cam_pinger_sent_count">Sent: %1$d</string>
<string name="mqtt_cam_pinger_send_failures">Write failures: %1$d consecutive — CAMs are not reaching the ESP32</string>
<string name="mqtt_cam_pinger_send_failures">Write failures: %1$d consecutive - CAMs are not reaching the ESP32</string>
<string name="mqtt_cam_pinger_fw_counters">ESP32: tx fail %1$d · oversize %2$d · crc err %3$d</string>
<string name="mqtt_start_pinger">Start Pinger</string>
<string name="mqtt_stop_pinger">Stop Pinger</string>
@@ -241,16 +248,16 @@
<string name="mqtt_usecase_none_active">No active use case alerts</string>
<string name="mqtt_usecase_detail">Station %1$d · %2$.0f m · closing %3$.1f m/s · TTC %4$.1f s · %5$s</string>
<!-- Human-readable use-case narratives (Section 10.4 — not just raw JSON) -->
<string name="usecase_narrative_ima_b">Crossing vehicle ahead — %1$.0f s to conflict</string>
<string name="usecase_narrative_ima_s">Stopped vehicle may pull out — %1$.0f s</string>
<string name="usecase_narrative_rtw_b">Car turning right toward you — %1$.0f s</string>
<string name="usecase_narrative_ltw_b">Car turning left toward you — %1$.0f s</string>
<string name="usecase_narrative_smva_bcw_b">Fast-closing vehicle nearby — %1$.0f s</string>
<!-- Human-readable use-case narratives (Section 10.4 - not just raw JSON) -->
<string name="usecase_narrative_ima_b">Crossing vehicle ahead - %1$.0f s to conflict</string>
<string name="usecase_narrative_ima_s">Stopped vehicle may pull out - %1$.0f s</string>
<string name="usecase_narrative_rtw_b">Car turning right toward you - %1$.0f s</string>
<string name="usecase_narrative_ltw_b">Car turning left toward you - %1$.0f s</string>
<string name="usecase_narrative_smva_bcw_b">Fast-closing vehicle nearby - %1$.0f s</string>
<!-- Settings > Use Case Alerts -->
<string name="settings_usecase_alerts">Use Case Alerts</string>
<string name="settings_usecase_alerts_desc">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.</string>
<string name="settings_usecase_alerts_desc">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.</string>
<string name="settings_usecase_alert_levels_title">Alert level thresholds (read-only)</string>
<string name="settings_usecase_level_warning">Warning</string>
<string name="settings_usecase_level_awareness">Awareness</string>
@@ -264,12 +271,12 @@
<string name="dash_transport_bt">BT</string>
<!-- OBU stationType warning -->
<string name="dash_station_type_warning">⚠ OBU stationType ≠ 2 (cyclist) — VRU detection may be impaired at equipped intersections</string>
<string name="dash_station_type_warning">⚠ OBU stationType ≠ 2 (cyclist) - VRU detection may be impaired at equipped intersections</string>
<string name="settings_platform">Platform</string>
<string name="settings_platform_val">Android / Kotlin / Jetpack Compose</string>
<string name="settings_project">Project</string>
<string name="settings_project_val">MicrOBU — HAW Hamburg &amp; consider it GmbH</string>
<string name="settings_project_val">MicrOBU - HAW Hamburg &amp; consider it GmbH</string>
<!-- Phase A: Trip Recording (bottom nav) -->
<string name="nav_trips">Trips</string>
@@ -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)
}
}
+10 -3
View File
@@ -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);
+10 -3
View File
@@ -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);
+72 -30
View File
@@ -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;
}
+54 -25
View File
@@ -5,35 +5,64 @@
#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).
// 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
+19 -8
View File
@@ -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);
}
}
}
+33 -9
View File
@@ -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)
+34 -9
View File
@@ -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.