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,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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user