Decode raw v2x/rx on the CiT One path, and stop tracking our own CAM pings

The Use Case app's v2x-uca/output/json topics are a rate-limited and
lossy view: traffic the OBU's radio actually heard, the ESP32's CAM
pinger among it, never reached the app. The raw v2x/rx topics carry
everything, as RecvV2XMessage protobuf with the ITS-G5 PDU in one bytes
field (CI-CiT MQTT API section 2.4).

RecvV2xMessage is a minimal protobuf wire-format reader for the three
fields needed: btpHeader type and destination port, the GeoNetworking
destination-area radius, and the payload. Hand-written for the same
reason the ASN.1 codecs are, rather than adding protoc and the protobuf
Gradle plugin and vendoring a third-party .proto into this repository.
Field numbers are pinned by a byte fixture written out by hand from the
encoding rules, not generated by our own encoder.

Raw payloads now travel as bytes rather than String. The previous UTF-8
round trip replaced every byte that is not valid UTF-8, leaving a
payload that still looked plausible in a log and decoded to nothing.

CAM, DENM and SPATEM from both transports now meet in shared handlers,
so everything downstream is transport-agnostic. SPATEM works on the CiT
One path for the first time, and DENM gains its relevance radius there.
Where both sources describe the same event the decoded one wins: remote
CAMs from the processed topic are suppressed while the raw topic is
live, and DENMs dedup on ETSI's actionID with the decoded list last.
The processed topics stay subscribed as a fallback for an OBU whose
configuration does not publish the raw ones.

Two defects found while testing this:

CamPinger transmits under a fixed bench station id, deliberately
distinct from the persisted one, but the self-heard filter only knew
the persisted id. Every ping therefore came back through the ESP32's
promiscuous receive as a remote road user sitting exactly on top of the
ego position, moving at the ego's own speed and heading, and was handed
to the detection engine as a collision partner for itself. The rule now
lives in OwnStationIds, covers both ids, and has tests, so a third
transmit path cannot reintroduce the same gap quietly.

Self-heard frames are now counted and reported on the pinger card
instead of being discarded. That round trip is the only direct evidence
the serial link, the ESP32's transmit path and its receive path all
work, which is what the bench pinger exists to demonstrate.

Also: the stationType warning banner no longer shows in ESP32-C5 mode.
It reads a value from the CiT One's obu_gnss topic, which that hardware
never publishes, so it stayed on screen reporting on an OBU that was no
longer in use.
This commit is contained in:
Ashin Walpola
2026-09-02 15:25:31 +02:00
parent ebe1c9edfd
commit 034ef22336
12 changed files with 868 additions and 56 deletions
@@ -0,0 +1,151 @@
package com.hawhamburg.micr0bu
import com.hawhamburg.micr0bu.data.mqtt.RecvV2xMessage
import com.hawhamburg.micr0bu.domain.asn1.CamUperCodec
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Pins [RecvV2xMessage] to the protobuf wire format of consider it's `RecvV2XMessage`
* (`v2x_interface.proto`, V2X RX protocol v2.4.2), the envelope the CiT One publishes on its raw
* `v2x/rx` topics.
*
* ## Where the fixtures come from
* The envelope bytes are written out here by hand from the protobuf encoding rules and the field
* numbers in that `.proto`, with the derivation in the comments, so a reviewer can check them
* without running anything. They are deliberately **not** produced by an encoder in this
* repository: a fixture generated by our own code would agree with our own reader no matter how
* wrong both were, which is exactly the failure mode the ASN.1 work in this project ran into
* three times.
*
* The CAM payload inside is the golden UPER frame from [CamEncodeGoldenTest], itself verified
* against `asn1tools` and the real ETSI modules in `asn1/`.
*
* ## Why this matters
* Field numbers are wire-format constants with no self-describing names on the wire. Reading
* field 2 where the schema says field 3 does not fail loudly, it silently yields a plausible
* looking byte string that decodes to nothing. These tests are what should fail if the constants
* in [RecvV2xMessage] are ever "tidied".
*/
class RecvV2xMessageTest {
/**
* The golden CAM UPER, 43 bytes, from [CamEncodeGoldenTest]. Its ItsPduHeader reads
* protocolVersion 2, messageID 2 (CAM), stationID 0x000f423f = 999999.
*/
private val goldenCam =
"0202000f423f3700402ab215af6e286477dffffffc23b7743e0027ffc0d0fe0118329337feebfff6000000"
/**
* A complete `RecvV2XMessage` carrying [goldenCam], byte by byte:
*
* ```
* 0a 05 field 1 (btpHeader), length-delimited, 5 bytes
* 08 02 field 1 (type) varint = 2, CAM
* 10 d1 0f field 2 (destinationPort) varint = 2001
* 12 07 field 2 (gnHeader), length-delimited, 7 bytes
* 42 05 field 8 (dest), length-delimited, 5 bytes
* 0a 03 field 1 (area), length-delimited, 3 bytes
* 18 f4 03 field 3 (distA) varint = 500 metres
* 1a 2b field 3 (payload), length-delimited, 0x2b = 43 bytes
* ```
*/
private val camEnvelope = "0a05080210d10f120742050a0318f4031a2b" + goldenCam
private fun String.hexToBytes(): ByteArray =
chunked(2).map { it.toInt(16).toByte() }.toByteArray()
// ---- the happy path --------------------------------------------------------------------
@Test
fun `parses btp header, geo radius and payload from a full envelope`() {
val msg = RecvV2xMessage.parse(camEnvelope.hexToBytes())
assertNotNull("envelope should parse", msg)
msg!!
assertEquals("btpHeader.type: CAM", 2, msg.pduType)
assertEquals("btpHeader.destinationPort", 2001, msg.destinationPort)
assertEquals("gnHeader.dest.area.distA, metres", 500, msg.destAreaRadiusM)
assertTrue(
"payload must be the CAM UPER byte for byte",
msg.payload.contentEquals(goldenCam.hexToBytes()),
)
}
@Test
fun `extracted payload is decodable UPER, not a mangled copy`() {
val msg = RecvV2xMessage.parse(camEnvelope.hexToBytes())!!
// The whole point of carrying bytes rather than a String through the MQTT layer: a UTF-8
// round trip would replace most of these bytes and this decode would fail.
val cam = CamUperCodec.decode(msg.payload, receivedAtEpochMs = 1_787_100_000_000L)
assertNotNull("payload should decode as a CAM", cam)
assertEquals("stationID from the ItsPduHeader", 999_999L, cam!!.stationId)
}
@Test
fun `reads a DENM envelope's relevance radius`() {
// Same shape, DENM values: type 1, port 2002, distA 1000 m, a 2-byte stand-in payload.
// 0a 05 08 01 10 d2 0f | 12 07 42 05 0a 03 18 e8 07 | 1a 02 02 01
val msg = RecvV2xMessage.parse("0a05080110d20f120742050a0318e8071a020201".hexToBytes())
assertNotNull(msg)
assertEquals(1, msg!!.pduType)
assertEquals(2002, msg.destinationPort)
assertEquals(1000, msg.destAreaRadiusM)
}
// ---- forward compatibility -------------------------------------------------------------
@Test
fun `skips unknown fields and does not depend on field order`() {
// payload first, then an unknown varint (field 7) and an unknown fixed32 (field 6) that
// this schema revision does not define, then the btpHeader. Protobuf permits all three,
// and a reader that assumed order or choked on unknowns would break the first time
// consider it added a field.
val bytes = ("1a2b" + goldenCam + "38b96035deadbeef0a05080210d10f").hexToBytes()
val msg = RecvV2xMessage.parse(bytes)
assertNotNull(msg)
assertEquals(2, msg!!.pduType)
assertEquals(2001, msg.destinationPort)
assertTrue(msg.payload.contentEquals(goldenCam.hexToBytes()))
}
@Test
fun `accepts an envelope carrying nothing but a payload`() {
val msg = RecvV2xMessage.parse(("1a2b" + goldenCam).hexToBytes())
assertNotNull(msg)
assertNull("no btpHeader was sent", msg!!.pduType)
assertNull("no gnHeader was sent", msg.destAreaRadiusM)
assertTrue(msg.payload.contentEquals(goldenCam.hexToBytes()))
}
// ---- malformed input -------------------------------------------------------------------
// These arrive off a network topic. A reader that throws takes the MQTT callback thread with
// it, so every one of these must return null instead.
@Test
fun `returns null for a truncated envelope`() {
val full = camEnvelope.hexToBytes()
assertNull(RecvV2xMessage.parse(full.copyOfRange(0, full.size / 2)))
}
@Test
fun `returns null when the payload field is present but empty`() {
assertNull(RecvV2xMessage.parse("1a00".hexToBytes()))
}
@Test
fun `returns null when there is no payload field at all`() {
assertNull(RecvV2xMessage.parse("0a05080210d10f".hexToBytes()))
}
@Test
fun `returns null for empty input and for bytes that are not protobuf`() {
assertNull(RecvV2xMessage.parse(ByteArray(0)))
// A run of continuation bytes: a varint that never terminates, which is what would walk
// an unguarded reader off the end of the buffer.
assertNull(RecvV2xMessage.parse(ByteArray(24) { 0xFF.toByte() }))
}
}