Files
MicrOBU/app/src/test/java/com/hawhamburg/micr0bu/SpatemUperCodecTest.kt
T
Ashin Walpola a5ad3dcc5d SPATEM receive, RSU CAM decode, and two ASN.1 encoding fixes
SPATEM over the air
- gn_unwrap.c accepts BTP-B port 2004 alongside 2001/2002. The serial protocol
  already carries the port in its V2X_RX prefix, so nothing else changed there.
  Note the crossover that makes this easy to get wrong: SPATEM is port 2004 but
  messageID 4, while MAPEM is port 2003 and messageID 5.
- SpatemUperCodec decodes SPAT down to per-signal-group phase and timing. The
  bit layout was validated by replaying 79,042 real SPATEMs - the whole
  2026-03-18 drive across 7+ RSUs plus the bench trigger - against asn1tools
  using the ETSI modules. All 79,042 matched on every field, none hit an
  unsupported branch. Two traps are pinned by tests: TimeChangeDetails is the
  one SEQUENCE here that is NOT extensible (5 optional bits, no extension bit),
  and maneuverAssistList cannot be skipped when present - it is variable-length,
  so it has to be walked to find where the next movement starts.
- The V2X list shows one row per intersection with each signal group coloured by
  phase and a countdown where the RSU supplies timing. TimeMark wraps hourly, so
  the countdown corrects for it; without that it reads hugely negative once an
  hour, precisely when someone is watching it.
- Entries expire after 15 s, much shorter than DENM's window: a traffic light
  that stopped updating is not "still green".

  Size caveat, deliberately deferred: SERIAL_LINK_MAX_PAYLOAD is still 512, so a
  SPATEM over ~498 bytes is counted as an oversize drop. The bench RSU sends 58
  bytes and is unaffected, but real road RSUs measured 555 median / 1243 max, so
  roughly 70% would not arrive. Raising the cap also requires enlarging
  RX_FRAME_MAX_LEN and moving rx_item_t off the WiFi driver's callback stack,
  where it would otherwise overflow.

RSU CAM decode
- HighFrequencyContainer is a CHOICE, and a roadside unit picks
  rsuContainerHighFrequency, which carries no kinematics at all. The decoder
  bailed on that branch, so every RSU CAM was dropped - including the bench RSU,
  which sends CAM and SPATEM from the same station id. It now decodes for
  position and stationType.
- RSU CAMs are kept out of UseCaseDetectionEngine. They arrive as a permanently
  stationary station at a fixed point, which is exactly the shape the
  stopped-vehicle and intersection-movement use cases match, and would raise a
  standing false alert for as long as the RSU was in range.

CAM transmit: yawRateConfidence
- YawRateConfidence has nine enumerands (0..8), so UPER needs 4 bits and
  "unavailable" is 8. The encoder wrote 3 bits with value 7 - one bit short and
  the wrong symbol - shifting every field after yawRate for any standards-strict
  receiver. The decoder read 3 bits too, so phone and ESP32 agreed with each
  other and with nothing else.
- This is the third instance of that exact failure mode in this project, after
  CurvatureCalculationMode and the GeoNetworking reserved bytes. A round-trip
  test through our own decoder structurally cannot catch it, so CamEncodeGolden
  Test asserts the bytes asn1tools produces instead: it decoded this encoder's
  output and re-encoded it byte-identically. Confirmed on air afterwards - 26 of
  our own CAMs captured back off the OBU's receiver, all 26 accepted, where the
  same decoder rejected them before.

DENM
- Hazards now expire 60 s after their last repetition. This needs a clock, not
  just a filter: both source flows only emit when a DENM arrives, so a sender
  that drives away or loses power would never trigger a recompute and its hazard
  would stay on screen indefinitely.
- The MQTT path was dropping every DENM for two independent reasons, both found
  by checking the payload against CI-CiT-MQTT_API_Documentation-v6 listing 2.6
  rather than guessing: the station id key is originatingStationId, and
  eventPosition IS a GeoJSON Point rather than an object containing one. Also
  parses termination (presence is the signal), sequenceNumber, stationType and
  the RFC3339 detectionTime. Note roadSideUnit is 15, not 12 - the enumeration
  has a gap after tram(11).

V2X screen
- The decoded CAM/DENM list now renders on the CiT One path too; it was gated to
  the ESP32-C5 path and CiT One fell through to the raw MQTT topic list. Those
  topics move to their own tab, hidden on the ESP32-C5 path where there is no
  broker.

Testing
- Adds org.json as a test-only dependency: the android.jar stub throws
  "not mocked" on every JSONObject call, which made the MQTT payload parsers
  untestable off-device.
- 23 V2X tests pass. EventDetectorTest's 4 failures are pre-existing and
  untouched by this change.
2026-08-20 15:47:14 +02:00

169 lines
7.6 KiB
Kotlin

package com.hawhamburg.micr0bu
import com.hawhamburg.micr0bu.domain.asn1.SpatemUperCodec
import com.hawhamburg.micr0bu.domain.spat.SignalPhase
import com.hawhamburg.micr0bu.domain.spat.SignalPhaseEvent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Regression tests for [SpatemUperCodec], using real SPATEMs as fixtures.
*
* ## Where the fixtures come from
* Both are genuine over-the-air messages, taken from the protobuf-wrapped UPER the OBU publishes
* (field 3 of the blob) - one from the live bench RSU trigger, one from the 2026-03-18 drive
* recorded in `its-g5-receiver-firmware/recordings/its_messages_*.db`.
*
* ## Why the expected values can be trusted
* The bit layout was validated by replaying **79,042 real SPATEMs** - the whole drive across 7+
* RSUs plus the live trigger - through a port of this decoder and comparing every field against
* `asn1tools` decoding the same bytes with the ETSI modules from the `C-ITS-Parser` checkout.
* All 79,042 matched exactly, and none hit an unsupported branch. The values asserted below are
* that independent decoder's output, not this codebase's own arithmetic.
*
* The two fixtures are deliberately different shapes: the live one is minimal (no region, no
* maneuverAssistList), the recorded one exercises `region`, a 7-entry event list, and the
* variable-length `maneuverAssistList` that has to be walked to find the next field.
*/
class SpatemUperCodecTest {
/**
* Live bench RSU, 58 bytes: one intersection (id 23, no region), two signal groups.
* This is the smallest shape seen in practice.
*/
private val liveSpatem =
"020439b9de89451672018000b81040051672adb401001143707ff07ff07ff7a23840484048404bc00851dc1fd41fd41f" +
"d5e86e112a112a112af0"
/**
* Real RSU from the drive, 251 bytes: region 3 / intersection 121, four signal groups, up to
* seven predicted phases each, and a maneuverAssistList.
*/
private val recordedSpatem =
"0204001233b441ae520188001803c8402001ae527d6b032016467032f0424039d2a43819f021981d6150dc0ed812ac10" +
"91088e077609600852846703f705dc04fb3a43820302f582851d0dc120c19a0161d020008004404c8ae065e0848073a5" +
"477033e043303ac2a1b81db025582122111c0eec12c010a508ae07ee0bb809f67477040605eb050a3a1b8241833402c3" +
"a04003001480d919c0cbc10900e74a90e067c08660758543703b604ab0424422381dd82580214a119c0fdc177013ece9" +
"0e080c0bd60a1474370483066805874080080031021a1b81a6821201d89919c0dfc11580f8ce86e08200b0409f694670" +
"44205b9052d48801800680"
private val receivedAt = 1_787_100_000_000L
private fun String.hexToBytes(): ByteArray =
chunked(2).map { it.toInt(16).toByte() }.toByteArray()
@Test
fun `decodes the live bench SPATEM`() {
val spat = SpatemUperCodec.decode(liveSpatem.hexToBytes(), receivedAt, rssiDbm = -55)
assertNotNull("real captured SPATEM must decode", spat)
spat!!
assertEquals(968_482_441L, spat.stationId)
assertEquals(333_426, spat.minuteOfYear)
assertEquals(-55, spat.rssiDbm)
assertEquals(1, spat.intersections.size)
val i = spat.intersections[0]
assertNull("this RSU sends no RoadRegulatorID", i.region)
assertEquals(23, i.id)
assertEquals(1, i.revision)
assertEquals(333_426, i.moy)
assertEquals(44_468, i.timeStampMs)
assertEquals("-1/23", i.key)
assertEquals(2, i.movements.size)
val sg1 = i.movements[0]
assertEquals(1, sg1.signalGroup)
assertEquals(2, sg1.events.size)
assertEquals(SignalPhase.STOP_AND_REMAIN, sg1.current!!.phase)
assertEquals(4094, sg1.current!!.minEndTimeDs)
val sg2 = i.movements[1]
assertEquals(2, sg2.signalGroup)
assertEquals(SignalPhase.PERMISSIVE_CLEARANCE, sg2.current!!.phase)
assertEquals(4074, sg2.current!!.minEndTimeDs)
}
@Test
fun `decodes a real RSU SPATEM with region and maneuverAssistList`() {
val spat = SpatemUperCodec.decode(recordedSpatem.hexToBytes(), receivedAt)
assertNotNull(spat)
spat!!
assertEquals(1_192_884L, spat.stationId)
assertEquals(1, spat.intersections.size)
val i = spat.intersections[0]
// IntersectionID is only unique within a RoadRegulatorID, so the region must survive
// decoding - the drive contains the same intersection id under different regions.
assertEquals(3, i.region)
assertEquals(121, i.id)
assertEquals(4, i.revision)
assertEquals(110_162, i.moy)
assertEquals(32_107, i.timeStampMs)
assertEquals("3/121", i.key)
assertEquals(4, i.movements.size)
assertEquals(listOf(1, 2, 3, 4), i.movements.map { it.signalGroup })
// A 7-entry prediction list: the current phase plus the upcoming sequence.
val sg1 = i.movements[0]
assertEquals(7, sg1.events.size)
assertEquals(SignalPhase.PROTECTED_MOVEMENT_ALLOWED, sg1.current!!.phase)
assertEquals(1630, sg1.current!!.minEndTimeDs)
assertEquals(2120, sg1.current!!.maxEndTimeDs)
assertEquals(1850, sg1.current!!.likelyTimeDs)
assertEquals(SignalPhase.PERMISSIVE_MOVEMENT_ALLOWED, i.movements[1].current!!.phase)
assertEquals(SignalPhase.STOP_AND_REMAIN, i.movements[3].current!!.phase)
assertEquals(4, i.movements[3].events.size)
}
@Test
fun `phase helpers classify the states a driver cares about`() {
assertTrue(SignalPhase.PROTECTED_MOVEMENT_ALLOWED.isGo)
assertTrue(SignalPhase.PERMISSIVE_MOVEMENT_ALLOWED.isGo)
assertTrue(SignalPhase.STOP_AND_REMAIN.isStop)
assertTrue(SignalPhase.PRE_MOVEMENT.isTransition)
assertTrue(SignalPhase.PROTECTED_CLEARANCE.isTransition)
// dark and unavailable are none of the three - they must not read as "go".
assertTrue(!SignalPhase.DARK.isGo && !SignalPhase.DARK.isStop)
}
@Test
fun `countdown handles TimeMark wrapping at the hour boundary`() {
val topOfHour = receivedAt - (receivedAt % 3_600_000L)
// 400.0 s into the hour, light changes at 409.4 s -> 9.4 s away.
val soon = SignalPhaseEvent(SignalPhase.STOP_AND_REMAIN, 4094, null, null)
assertEquals(9.4, soon.secondsUntil(topOfHour + 400_000L)!!, 1e-6)
// 3590 s into the hour, mark is 10.0 s - that is the NEXT hour, i.e. 20 s away, not
// 3580 s in the past. Without the wrap correction a countdown goes hugely negative once
// per hour, which is exactly when a driver is watching it.
val wrapped = SignalPhaseEvent(SignalPhase.STOP_AND_REMAIN, 100, null, null)
assertEquals(20.0, wrapped.secondsUntil(topOfHour + 3_590_000L)!!, 1e-6)
// 36001 is the spec's "unknown" marker and must not be shown as a real countdown.
val unknown = SignalPhaseEvent(SignalPhase.DARK, 36001, null, null)
assertNull(unknown.secondsUntil(topOfHour))
assertNull(SignalPhaseEvent(SignalPhase.DARK, null, null, null).secondsUntil(topOfHour))
}
@Test
fun `does not decode another message type as SPATEM`() {
// protocolVersion 2, messageId 2 (CAM) - the header guard must reject it outright.
val cam = byteArrayOf(2, 2, 0, 0, 0, 1, 0, 0, 0, 0)
assertNull(SpatemUperCodec.decode(cam, receivedAt))
}
@Test
fun `returns null for a truncated SPATEM rather than a wrong light`() {
val full = liveSpatem.hexToBytes()
assertNull(SpatemUperCodec.decode(full.copyOfRange(0, 12), receivedAt))
}
}