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()
|
||||
|
||||
@@ -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 & consider it GmbH</string>
|
||||
<string name="settings_project_val">MicrOBU - HAW Hamburg & consider it GmbH</string>
|
||||
|
||||
<!-- Phase A: Trips (bottom nav) -->
|
||||
<string name="nav_trips">Fahrten</string>
|
||||
|
||||
@@ -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 & consider it GmbH</string>
|
||||
<string name="settings_project_val">MicrOBU - HAW Hamburg & 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user