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) }
.associateBy { it.dedupKey } // last write wins = most recent per hazard
.values
.sortedByDescending { it.timestamp }
}
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
},
// Air DENMs accumulate here rather than being a snapshot: the serial path delivers one
// event at a time, so runningFold keeps the set of hazards heard so far.
camUseCaseRepository.airDenm
.runningFold(emptyMap<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())
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)
}
}