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.
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
package com.hawhamburg.micr0bu
|
||||
|
||||
import com.hawhamburg.micr0bu.domain.asn1.CamUperCodec
|
||||
import com.hawhamburg.micr0bu.domain.cam.Cam
|
||||
import com.hawhamburg.micr0bu.domain.cam.StationType
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Golden-byte test for the CAM this app transmits.
|
||||
*
|
||||
* ## Why a byte-for-byte fixture
|
||||
* This project has now shipped the same class of bug three times: a field encoded with the wrong
|
||||
* number of bits, which both ends of this codebase then read back with the *same* wrong number.
|
||||
* Phone and ESP32 agree perfectly with each other and with nothing else, so every internal test
|
||||
* passes while the frames on air are malformed. It cost a hardware session each time
|
||||
* (`CurvatureCalculationMode`, the GeoNetworking reserved bytes, and `yawRateConfidence`).
|
||||
*
|
||||
* A round-trip test through this codebase's own decoder cannot catch that - it shares the
|
||||
* mistake. Only an independent implementation can. So the expected bytes below were produced by
|
||||
* `asn1tools` compiled from the real ETSI modules in the `C-ITS-Parser` checkout: it decoded this
|
||||
* encoder's output and re-encoded it, and the result was byte-identical to what is asserted here.
|
||||
* That is stronger than "it parses" - it means this encoder emits exactly what the reference
|
||||
* encoder emits.
|
||||
*
|
||||
* If a field width is ever "tidied up", this test fails. Do not regenerate the expected value from
|
||||
* this encoder's own output - regenerate it through asn1tools, or the test is worthless.
|
||||
*/
|
||||
class CamEncodeGoldenTest {
|
||||
|
||||
/**
|
||||
* asn1tools-verified encoding of [referenceCam]. The trap this pins down: `YawRateConfidence`
|
||||
* has nine enumerands (0..8), so it needs 4 bits and `unavailable` is 8 - not 3 bits and 7.
|
||||
*/
|
||||
private val expectedHex =
|
||||
"0202000f423f3700402ab215af6e286477dffffffc23b7743e0027ffc0d0fe0118329337feebfff6000000"
|
||||
|
||||
private val referenceCam = Cam(
|
||||
stationId = 999_999L,
|
||||
stationType = StationType.CYCLIST,
|
||||
latitude = 53.5544955,
|
||||
longitude = 10.0225470,
|
||||
speedMps = 4.17,
|
||||
headingDeg = 63.9,
|
||||
yawRateDps = null,
|
||||
driveDirection = 0,
|
||||
vehicleLengthM = 1.8,
|
||||
vehicleWidthM = 0.7,
|
||||
accelerationMps2 = 0.4,
|
||||
timestamp = 1_787_100_000_000L,
|
||||
isOwn = true,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `encodes a CAM exactly as the ETSI reference encoder does`() {
|
||||
val encoded = CamUperCodec.encode(referenceCam)
|
||||
.joinToString("") { "%02x".format(it) }
|
||||
assertEquals(expectedHex, encoded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `own decoder agrees with the encoder on every field it reads`() {
|
||||
// Self-consistency is necessary but NOT sufficient - see the class KDoc. This guards the
|
||||
// decoder against drifting away from the encoder, while the golden bytes above are what
|
||||
// guards both of them against drifting away from the standard.
|
||||
val round = CamUperCodec.decode(CamUperCodec.encode(referenceCam), referenceCam.timestamp)
|
||||
requireNotNull(round)
|
||||
assertEquals(referenceCam.stationId, round.stationId)
|
||||
assertEquals(referenceCam.stationType, round.stationType)
|
||||
assertEquals(referenceCam.latitude, round.latitude, 1e-7)
|
||||
assertEquals(referenceCam.longitude, round.longitude, 1e-7)
|
||||
assertEquals(referenceCam.speedMps, round.speedMps, 1e-9)
|
||||
assertEquals(referenceCam.headingDeg, round.headingDeg, 1e-9)
|
||||
assertEquals(referenceCam.vehicleLengthM!!, round.vehicleLengthM!!, 1e-9)
|
||||
assertEquals(referenceCam.vehicleWidthM!!, round.vehicleWidthM!!, 1e-9)
|
||||
assertEquals(referenceCam.accelerationMps2!!, round.accelerationMps2!!, 1e-9)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package com.hawhamburg.micr0bu
|
||||
|
||||
import com.hawhamburg.micr0bu.domain.denm.DenmParser
|
||||
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
|
||||
|
||||
/**
|
||||
* Tests [DenmParser] against the CiT One Use Case API's documented DENM schema.
|
||||
*
|
||||
* The payload below is the worked example from `CI-CiT-MQTT_API_Documentation-v6-20250221.pdf`,
|
||||
* listing 2.6 (section 2.2.4, "Processed DENM"), reproduced field-for-field. That document is the
|
||||
* contract for this topic, so it is the right thing to pin against - the previous parser was
|
||||
* written before the schema was checked and silently dropped every real DENM for two independent
|
||||
* reasons: the station id is `originatingStationId` (not `stationId`), and `eventPosition` is
|
||||
* itself a GeoJSON Point rather than an object containing one.
|
||||
*/
|
||||
class DenmParserMqttTest {
|
||||
|
||||
/** Listing 2.6 from the API documentation, with the doc's inline comments removed. */
|
||||
private val documentedDenm = """
|
||||
{
|
||||
"type": "v2x-denm",
|
||||
"originatingStationId": 1345267,
|
||||
"sequenceNumber": 1,
|
||||
"detectionTime": "2021-05-11T12:01:02+00:00",
|
||||
"referenceTime": "2021-05-11T12:01:02+00:00",
|
||||
"eventPosition": { "type": "Point", "coordinates": [9.9800230, 53.5560783, 15] },
|
||||
"relevanceTrafficDirection": "upstreamTraffic",
|
||||
"stationType": "roadSideUnit",
|
||||
"causeCode": "trafficCondition",
|
||||
"subCauseCode": 0
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
@Test
|
||||
fun `parses the documented DENM payload`() {
|
||||
val denm = DenmParser.parse(documentedDenm, timestamp = 1_787_000_000_000L)
|
||||
assertNotNull("the API's own documented payload must parse", denm)
|
||||
denm!!
|
||||
|
||||
assertEquals(1_345_267L, denm.stationId)
|
||||
assertEquals(1, denm.sequenceNumber)
|
||||
|
||||
// GeoJSON is [longitude, latitude, altitude] - getting this order wrong puts a Hamburg
|
||||
// hazard in Somalia, and both values are plausible-looking numbers either way.
|
||||
assertEquals(53.5560783, denm.latitude, 1e-7)
|
||||
assertEquals(9.9800230, denm.longitude, 1e-7)
|
||||
|
||||
assertEquals(1, denm.causeCode) // trafficCondition
|
||||
assertEquals(0, denm.subCauseCode)
|
||||
assertEquals(15, denm.stationType) // roadSideUnit is 15, not 12 - the enum has a gap
|
||||
assertFalse(denm.isTermination)
|
||||
|
||||
// 2021-05-11T12:01:02Z
|
||||
assertEquals(1_620_734_462_000L, denm.detectionTimeMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `termination is signalled by the key being present`() {
|
||||
val terminated = documentedDenm.replace(
|
||||
"\"sequenceNumber\": 1,",
|
||||
"\"sequenceNumber\": 1,\n \"termination\": true,",
|
||||
)
|
||||
val denm = DenmParser.parse(terminated)
|
||||
assertNotNull(denm)
|
||||
assertTrue(denm!!.isTermination)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a termination shares the dedup key of the event it ends`() {
|
||||
val active = DenmParser.parse(documentedDenm)!!
|
||||
val terminated = DenmParser.parse(
|
||||
documentedDenm.replace(
|
||||
"\"sequenceNumber\": 1,",
|
||||
"\"sequenceNumber\": 1,\n \"termination\": true,",
|
||||
)
|
||||
)!!
|
||||
// Without this, a cancelled hazard would be filtered out while the active pin it was
|
||||
// meant to cancel stayed on the map forever.
|
||||
assertEquals(active.dedupKey, terminated.dedupKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cause code names follow the ETSI spelling the API uses`() {
|
||||
// ETSI's CauseCodeType really does spell it with three n's, and the API follows.
|
||||
val aqua = documentedDenm.replace("\"trafficCondition\"", "\"aquaplannning\"")
|
||||
assertEquals(7, DenmParser.parse(aqua)!!.causeCode)
|
||||
|
||||
val stationary = documentedDenm.replace("\"trafficCondition\"", "\"stationaryVehicle\"")
|
||||
assertEquals(94, DenmParser.parse(stationary)!!.causeCode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a payload with no usable position is rejected rather than placed at null island`() {
|
||||
val noPosition = documentedDenm.replace(
|
||||
"\"eventPosition\": { \"type\": \"Point\", \"coordinates\": [9.9800230, 53.5560783, 15] },",
|
||||
"",
|
||||
)
|
||||
assertNull(DenmParser.parse(noPosition))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `integer causeCode and stationId spellings still parse`() {
|
||||
// The air path and any future firmware-side JSON produce integers; those must keep working.
|
||||
val numeric = """
|
||||
{"stationId": 42, "causeCode": 94, "subCauseCode": 1,
|
||||
"eventPosition": {"type": "Point", "coordinates": [10.0, 53.5]}}
|
||||
""".trimIndent()
|
||||
val denm = DenmParser.parse(numeric)
|
||||
assertNotNull(denm)
|
||||
assertEquals(42L, denm!!.stationId)
|
||||
assertEquals(94, denm.causeCode)
|
||||
assertEquals(1, denm.subCauseCode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.hawhamburg.micr0bu
|
||||
|
||||
import com.hawhamburg.micr0bu.domain.asn1.CamUperCodec
|
||||
import com.hawhamburg.micr0bu.domain.cam.StationType
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* A roadside unit's CAM must decode, not be dropped.
|
||||
*
|
||||
* ETSI's `HighFrequencyContainer` is a CHOICE, and an RSU picks `rsuContainerHighFrequency`
|
||||
* instead of `basicVehicleContainerHighFrequency`. That container holds no kinematics at all -
|
||||
* only an optional protected-zone list - so an earlier version of the decoder bailed on it and
|
||||
* every RSU CAM was silently discarded. The bench RSU sends CAM and SPATEM from the same station
|
||||
* id, so dropping its CAM meant the one station a rider most wants to see never appeared.
|
||||
*
|
||||
* The fixture is a real 26-byte RSU CAM taken live from the OBU's `v2x/rx/cam` topic; the
|
||||
* expected values are asn1tools' decoding of those same bytes using the ETSI modules in the
|
||||
* `C-ITS-Parser` checkout.
|
||||
*/
|
||||
class RsuCamDecodeTest {
|
||||
|
||||
private val rsuCam = "020239b9de898b8b00fab215af6e286477c0c20c200033fa4e80"
|
||||
|
||||
private fun String.hexToBytes(): ByteArray =
|
||||
chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
|
||||
@Test
|
||||
fun `decodes a roadside unit CAM for position and station type`() {
|
||||
val cam = CamUperCodec.decode(rsuCam.hexToBytes(), receivedAtEpochMs = 1_787_100_000_000L)
|
||||
assertNotNull("an RSU CAM must not be dropped", cam)
|
||||
cam!!
|
||||
|
||||
assertEquals(968_482_441L, cam.stationId)
|
||||
assertEquals(StationType.ROAD_SIDE_UNIT, cam.stationType)
|
||||
assertEquals(15, cam.stationType) // the enumeration jumps 11 -> 15; 12 would be wrong
|
||||
|
||||
assertEquals(53.5544955, cam.latitude, 1e-7)
|
||||
assertEquals(10.0225470, cam.longitude, 1e-7)
|
||||
|
||||
// An RSU has no kinematics to report. Zero is a placeholder, which is only safe because
|
||||
// CamUseCaseRepository keeps RSU CAMs out of UseCaseDetectionEngine - otherwise this
|
||||
// would read as a permanently stopped vehicle and raise a standing false alert.
|
||||
assertEquals(0.0, cam.speedMps, 0.0)
|
||||
assertEquals(0.0, cam.headingDeg, 0.0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user