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:
@@ -0,0 +1,66 @@
|
||||
package com.hawhamburg.micr0bu
|
||||
|
||||
import com.hawhamburg.micr0bu.domain.cam.OwnStationIds
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pins the rule that decides whether a received CAM is one this phone sent.
|
||||
*
|
||||
* ## The bug this exists to prevent
|
||||
* The phone transmits under two station IDs: the persisted per-install one used by
|
||||
* `CamTransmitLoop`, and a fixed bench ID used by `CamPinger` so pings stay identifiable in
|
||||
* captures. The ESP32-C5 receives promiscuously, so both come straight back off the air.
|
||||
*
|
||||
* The filter originally checked only the persisted ID. Every bench ping therefore returned as a
|
||||
* remote road user sitting exactly on top of the ego position, moving at the ego's own speed and
|
||||
* heading, and was fed to the detection engine as a collision partner for itself. Nothing failed
|
||||
* loudly: the app simply raised use case alerts against itself for as long as the pinger ran.
|
||||
*
|
||||
* These tests are what should fail if a third transmit path is ever added without teaching this
|
||||
* rule about it.
|
||||
*/
|
||||
class OwnStationIdsTest {
|
||||
|
||||
private val persisted = 1_691_338_363L
|
||||
|
||||
@Test
|
||||
fun `recognises the persisted transmit id`() {
|
||||
assertTrue(OwnStationIds.isOwn(persisted, persisted))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recognises the bench ping id even though it is not the persisted one`() {
|
||||
// The regression. The pinger's id is deliberately different, which is exactly why a
|
||||
// filter written around the persisted id alone let every ping through.
|
||||
assertNotEquals(
|
||||
"the bench id is meant to be distinct, or this test proves nothing",
|
||||
persisted,
|
||||
OwnStationIds.BENCH_PING,
|
||||
)
|
||||
assertTrue(OwnStationIds.isOwn(OwnStationIds.BENCH_PING, persisted))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recognises the bench ping id before the persisted id has loaded`() {
|
||||
// The persisted id is read asynchronously, so it can still be null while the pinger is
|
||||
// already transmitting. The ping must be recognised as ours regardless.
|
||||
assertTrue(OwnStationIds.isOwn(OwnStationIds.BENCH_PING, null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `treats a genuine remote station as remote`() {
|
||||
assertFalse(OwnStationIds.isOwn(2_741_041_966L, persisted))
|
||||
assertFalse(OwnStationIds.isOwn(2_741_041_966L, null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `station id zero is never ours`() {
|
||||
// 0 is the "not resolved yet" placeholder for the ego identity. Matching on it would
|
||||
// swallow real traffic from any station that reported 0.
|
||||
assertFalse(OwnStationIds.isOwn(0L, null))
|
||||
assertFalse(OwnStationIds.isOwn(0L, 0L))
|
||||
}
|
||||
}
|
||||
@@ -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() }))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user