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:
Ashin Walpola
2026-08-20 15:47:14 +02:00
parent 0ccb867228
commit a5ad3dcc5d
20 changed files with 1159 additions and 52 deletions
@@ -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)
}
}