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.
169 lines
7.6 KiB
Kotlin
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))
|
|
}
|
|
}
|