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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user