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
+3
View File
@@ -80,5 +80,8 @@ dependencies {
// ── Unit tests (JVM, no emulator required) ──────────────────────────────── // ── Unit tests (JVM, no emulator required) ────────────────────────────────
testImplementation(libs.junit) testImplementation(libs.junit)
// Real org.json for JVM unit tests: the android.jar stub throws "not mocked" on every
// JSONObject call, which would make the MQTT payload parsers untestable off-device.
testImplementation(libs.json)
testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.kotlinx.coroutines.test)
} }
@@ -16,11 +16,13 @@ import com.hawhamburg.micr0bu.data.transport.UsbSerialState
import com.hawhamburg.micr0bu.data.transport.UsbSerialTransport import com.hawhamburg.micr0bu.data.transport.UsbSerialTransport
import com.hawhamburg.micr0bu.domain.asn1.DenmUperCodec import com.hawhamburg.micr0bu.domain.asn1.DenmUperCodec
import com.hawhamburg.micr0bu.domain.asn1.RealAsn1UperCodec import com.hawhamburg.micr0bu.domain.asn1.RealAsn1UperCodec
import com.hawhamburg.micr0bu.domain.asn1.SpatemUperCodec
import com.hawhamburg.micr0bu.domain.cam.Cam import com.hawhamburg.micr0bu.domain.cam.Cam
import com.hawhamburg.micr0bu.domain.cam.CamParser import com.hawhamburg.micr0bu.domain.cam.CamParser
import com.hawhamburg.micr0bu.domain.cam.ObuGnssParser import com.hawhamburg.micr0bu.domain.cam.ObuGnssParser
import com.hawhamburg.micr0bu.domain.cam.StationType import com.hawhamburg.micr0bu.domain.cam.StationType
import com.hawhamburg.micr0bu.domain.denm.DenmEvent import com.hawhamburg.micr0bu.domain.denm.DenmEvent
import com.hawhamburg.micr0bu.domain.spat.SpatEvent
import com.hawhamburg.micr0bu.domain.usecase.UseCaseAlert import com.hawhamburg.micr0bu.domain.usecase.UseCaseAlert
import com.hawhamburg.micr0bu.domain.usecase.UseCaseDetectionEngine import com.hawhamburg.micr0bu.domain.usecase.UseCaseDetectionEngine
import com.hawhamburg.micr0bu.domain.usecase.UseCaseType import com.hawhamburg.micr0bu.domain.usecase.UseCaseType
@@ -126,6 +128,14 @@ class CamUseCaseRepository @Inject constructor(
*/ */
val processedCam: SharedFlow<Cam> = _processedCam.asSharedFlow() val processedCam: SharedFlow<Cam> = _processedCam.asSharedFlow()
private val _airSpat = MutableSharedFlow<SpatEvent>(replay = 16, extraBufferCapacity = 32)
/**
* SPATEMs decoded from over-the-air traffic on the ESP32-C5 path. Replayed so a screen opened
* mid-stream sees the current signal state immediately rather than waiting up to half a second
* for the next repetition.
*/
val airSpat: SharedFlow<SpatEvent> = _airSpat.asSharedFlow()
private val _airDenm = MutableSharedFlow<DenmEvent>(replay = 32, extraBufferCapacity = 32) private val _airDenm = MutableSharedFlow<DenmEvent>(replay = 32, extraBufferCapacity = 32)
/** /**
* DENMs decoded from over-the-air traffic on the ESP32-C5 path. `replay` so a screen opened * DENMs decoded from over-the-air traffic on the ESP32-C5 path. `replay` so a screen opened
@@ -190,6 +200,7 @@ class CamUseCaseRepository @Inject constructor(
when (v2x.btpPort) { when (v2x.btpPort) {
BtpPort.CAM -> handleCamFromSerial(v2x) BtpPort.CAM -> handleCamFromSerial(v2x)
BtpPort.DENM -> handleDenmFromSerial(v2x) BtpPort.DENM -> handleDenmFromSerial(v2x)
BtpPort.SPATEM -> handleSpatFromSerial(v2x)
// The firmware only forwards ports it was told to accept, so anything else // The firmware only forwards ports it was told to accept, so anything else
// means the two sides have drifted out of sync. // means the two sides have drifted out of sync.
else -> Log.w(TAG, "unexpected BTP port ${v2x.btpPort} from firmware") else -> Log.w(TAG, "unexpected BTP port ${v2x.btpPort} from firmware")
@@ -317,6 +328,17 @@ class CamUseCaseRepository @Inject constructor(
Log.d(TAG, "handleCamFromSerial: decoded station=${cam.stationId} " + Log.d(TAG, "handleCamFromSerial: decoded station=${cam.stationId} " +
"lat=${cam.latitude} lon=${cam.longitude} speed=${cam.speedMps} rssi=${v2x.rssiDbm} dBm") "lat=${cam.latitude} lon=${cam.longitude} speed=${cam.speedMps} rssi=${v2x.rssiDbm} dBm")
if (_ownStationId.value != null && cam.stationId == _ownStationId.value) return // self-heard TX if (_ownStationId.value != null && cam.stationId == _ownStationId.value) return // self-heard TX
// Roadside units are infrastructure, not road users. Their CAM carries no kinematics (see
// CamUperCodec's rsuContainerHighFrequency branch), so it reaches here as a permanently
// stationary station at a fixed point - which is precisely the shape the stopped-vehicle
// and intersection-movement use cases look for. Feeding it to the engine would raise a
// standing false alert for as long as the RSU is in range.
if (cam.stationType == StationType.ROAD_SIDE_UNIT) {
_processedCam.tryEmit(cam)
return
}
engine.onRemoteCam(cam) engine.onRemoteCam(cam)
_processedCam.tryEmit(cam) _processedCam.tryEmit(cam)
} }
@@ -347,6 +369,36 @@ class CamUseCaseRepository @Inject constructor(
_airDenm.tryEmit(denm) _airDenm.tryEmit(denm)
} }
/**
* A SPATEM heard over the air: the live signal phase for one or more intersections.
*
* Like DENM, this is deliberately kept out of [UseCaseDetectionEngine] - a traffic light is
* not a moving road user, and the CAM-based use cases reason about kinematics.
*
* Note the firmware drops any SPATEM whose UPER exceeds the 512-byte serial payload cap and
* counts it as an oversize drop, so on real road RSUs (median 555 bytes) most will not arrive
* until that cap is raised. The bench trigger's ~58-byte messages are unaffected.
*/
private fun handleSpatFromSerial(v2x: V2xRxFrame) {
val spat = SpatemUperCodec.decode(
bytes = v2x.uper,
receivedAtEpochMs = System.currentTimeMillis(),
rssiDbm = v2x.rssiDbm,
)
if (spat == null) {
Log.w(
TAG,
"handleSpatFromSerial: decode FAILED for ${v2x.uper.size}-byte SPATEM " +
"(rssi=${v2x.rssiDbm} dBm) - first bytes: ${v2x.uper.toHexPreview()}",
)
return
}
Log.d(TAG, "handleSpatFromSerial: decoded station=${spat.stationId} " +
"intersections=${spat.intersections.joinToString { it.key }} " +
"movements=${spat.intersections.sumOf { it.movements.size }} rssi=${v2x.rssiDbm} dBm")
_airSpat.tryEmit(spat)
}
private fun ByteArray.toHexPreview(limit: Int = 16): String = private fun ByteArray.toHexPreview(limit: Int = 16): String =
take(limit).joinToString(" ") { "%02x".format(it) } + if (size > limit) " ..." else "" take(limit).joinToString(" ") { "%02x".format(it) } + if (size > limit) " ..." else ""
} }
@@ -99,6 +99,13 @@ object Crc16CcittFalse {
object BtpPort { object BtpPort {
const val CAM = 2001 const val CAM = 2001
const val DENM = 2002 const val DENM = 2002
/**
* Watch the crossover: SPATEM is BTP port **2004** but ItsPduHeader messageID **4**, while
* MAPEM is port 2003 and messageID 5. The two numbering schemes are unrelated, and swapping
* them routes messages to the wrong decoder.
*/
const val SPATEM = 2004
} }
/** /**
@@ -28,6 +28,9 @@ import kotlin.math.roundToLong
*/ */
object CamUperCodec { object CamUperCodec {
/** HighFrequencyContainer CHOICE index for rsuContainerHighFrequency. */
private const val HF_CONTAINER_RSU = 1
/** Encode buffer size — matches `cam.c`'s `cam_payload[96]`, the known-sufficient size. */ /** Encode buffer size — matches `cam.c`'s `cam_payload[96]`, the known-sufficient size. */
private const val ENCODE_BUFFER_BYTES = 96 private const val ENCODE_BUFFER_BYTES = 96
@@ -144,7 +147,17 @@ object CamUperCodec {
?.let { (it * 100.0).roundToInt().coerceIn(-32766, 32766) } ?.let { (it * 100.0).roundToInt().coerceIn(-32766, 32766) }
?: YAW_RATE_UNAVAILABLE ?: YAW_RATE_UNAVAILABLE
bw.putBits(yawRateCentiDegS - (-32766), 16) bw.putBits(yawRateCentiDegS - (-32766), 16)
bw.putBits(7, 3) // yawRateConfidence: unavailable // YawRateConfidence has NINE enumerands (degSec-000-01(0) .. unavailable(8), see
// cdd_1_3_1_1.asn), so UPER needs 4 bits and "unavailable" is 8. This wrote 3 bits with
// value 7 - which is both one bit short and the wrong symbol (7 is outOfRange), shifting
// every field after yawRate for any standards-compliant receiver.
//
// Exactly the same failure mode as the CurvatureCalculationMode note above, and caught
// the same way it should have been the first time: asn1tools, decoding this project's own
// transmitted CAM with the real ETSI modules, rejected it with
// "yawRateConfidence: Expected enumeration index ...". The decoder below read 3 bits too,
// so phone <-> ESP32 agreed with each other and with nothing else.
bw.putBits(8, 4) // yawRateConfidence: unavailable(8)
// ---- LowFrequencyContainer CHOICE ---- extension(0) -> basicVehicleContainerLowFrequency // ---- LowFrequencyContainer CHOICE ---- extension(0) -> basicVehicleContainerLowFrequency
bw.putBits(0, 1) bw.putBits(0, 1)
@@ -217,9 +230,34 @@ object CamUperCodec {
br.getBits(20) // altitudeValue br.getBits(20) // altitudeValue
br.getBits(4) // altitudeConfidence br.getBits(4) // altitudeConfidence
// HighFrequencyContainer is an extensible CHOICE: extension bit, then a 1-bit index
// selecting basicVehicleContainerHighFrequency(0) or rsuContainerHighFrequency(1).
val highFreqExt = br.getBitsInt(1) val highFreqExt = br.getBitsInt(1)
val highFreqIndex = br.getBitsInt(1) val highFreqIndex = br.getBitsInt(1)
if (highFreqExt != 0 || highFreqIndex != 0) return null // extension, or rsuContainerHighFrequency if (highFreqExt != 0) return null // an alternative added in a later revision
if (highFreqIndex == HF_CONTAINER_RSU) {
// An RSU's CAM carries no kinematics at all - RSUContainerHighFrequency holds only an
// optional protected-zone list. Everything meaningful (position, stationType) has
// already been read from the basicContainer above, so return that rather than
// dropping the message: an RSU is exactly the station a rider wants to see, and the
// roadside unit at this bench sends CAM and SPATEM from the same station id.
//
// speed/heading are reported as zero because the model has no "unknown" for them.
// That is safe only because RSU CAMs are kept out of UseCaseDetectionEngine - a
// permanently stationary "vehicle" would otherwise trip the stopped-vehicle use case
// forever. See CamUseCaseRepository.handleCamFromSerial.
return Cam(
stationId = stationId,
stationType = stationType,
latitude = latitude,
longitude = longitude,
speedMps = 0.0,
headingDeg = 0.0,
timestamp = receivedAtEpochMs,
isOwn = false,
)
}
// Optional-presence bitmap for BasicVehicleContainerHighFrequency's 7 trailing OPTIONAL // Optional-presence bitmap for BasicVehicleContainerHighFrequency's 7 trailing OPTIONAL
// fields: accelerationControl, lanePosition, steeringWheelAngle, lateralAcceleration, // fields: accelerationControl, lanePosition, steeringWheelAngle, lateralAcceleration,
@@ -266,7 +304,7 @@ object CamUperCodec {
br.getBits(2) // curvatureCalculationMode root index br.getBits(2) // curvatureCalculationMode root index
val yawRateRaw = br.getBitsInt(16) + (-32766) val yawRateRaw = br.getBitsInt(16) + (-32766)
br.getBits(3) // yawRateConfidence br.getBits(4) // yawRateConfidence: 9 enumerands -> 4 bits, see the note in encode()
val yawRateDps = if (yawRateRaw == YAW_RATE_UNAVAILABLE) null else yawRateRaw / 100.0 val yawRateDps = if (yawRateRaw == YAW_RATE_UNAVAILABLE) null else yawRateRaw / 100.0
// Everything after yawRate is deliberately left unread: the 7 optional high-frequency // Everything after yawRate is deliberately left unread: the 7 optional high-frequency
@@ -0,0 +1,199 @@
package com.hawhamburg.micr0bu.domain.asn1
import com.hawhamburg.micr0bu.domain.spat.IntersectionSignalState
import com.hawhamburg.micr0bu.domain.spat.SignalMovement
import com.hawhamburg.micr0bu.domain.spat.SignalPhase
import com.hawhamburg.micr0bu.domain.spat.SignalPhaseEvent
import com.hawhamburg.micr0bu.domain.spat.SpatEvent
/**
* ASN.1 UPER **decoder** for SPATEM (ETSI TS 103 301 / SAE J2735 DSRC), for messages received over
* the air on the ESP32-C5 path.
*
* Decode-only: this project never transmits SPATEM, that is an RSU's job.
*
* ## Field widths
* Every width is taken from the ETSI ASN.1 modules in the `C-ITS-Parser` checkout
* (`dsrc_2_2_1.asn`, `cdd_2_2_1.asn`):
*
* - `MinuteOfTheYear` (0..527040) = 20 bits
* - `DSecond` (0..65535) = 16 bits
* - `MsgCount` (0..127) = 7 bits
* - `SignalGroupID` / `LaneID` / `LaneConnectionID` (0..255) = 8 bits
* - `IntersectionID` / `RoadRegulatorID` (0..65535) = 16 bits
* - `TimeMark` (0..36001) = 16 bits
* - `TimeIntervalConfidence` (0..15) = 4 bits
* - `ZoneLength` (0..10000) = 14 bits
* - `IntersectionStatusObject` BIT STRING SIZE(16) = 16 bits
*
* A `SEQUENCE (SIZE(lo..hi)) OF` writes its count in `ceil(log2(hi-lo+1))` bits holding `n-lo`:
* intersections 1..32 gives 5 bits, movements 1..255 gives 8, events 1..16 gives 4.
*
* Two traps worth naming, both of which silently shift every later field:
* - `TimeChangeDetails` is **not** extensible, so it has 5 optional bits and no extension bit,
* unlike almost every other SEQUENCE here, which all carry one.
* - `maneuverAssistList` cannot be skipped when present. It is variable-length, so the only way
* to reach the next movement is to walk it, even though nothing here consumes it.
*
* ## Verification
* The layout was validated by replaying **79,042 real SPATEMs**, the entire 2026-03-18 drive
* (7+ RSUs) plus the live bench trigger, through a port of this decoder and comparing every field
* against `asn1tools` decoding the same bytes with the real ETSI modules. All 79,042 matched
* exactly, with no message hitting an unsupported branch.
*
* Returns null rather than guessing whenever an extension bit is set or an unsupported optional
* appears: a dropped SPATEM is recoverable (they repeat at ~2 Hz), a misread one shows a driver
* the wrong light.
*/
object SpatemUperCodec {
private const val MESSAGE_ID_SPATEM = 4
private const val PROTOCOL_VERSION = 2
/** Guards against a malformed length field turning into a long decode loop. */
private const val MAX_INTERSECTIONS = 32
private const val MAX_MOVEMENTS = 255
fun decode(bytes: ByteArray, receivedAtEpochMs: Long, rssiDbm: Int? = null): SpatEvent? = try {
decodeOrNull(bytes, receivedAtEpochMs, rssiDbm)
} catch (e: IndexOutOfBoundsException) {
null // truncated frame
}
private fun decodeOrNull(bytes: ByteArray, receivedAtEpochMs: Long, rssiDbm: Int?): SpatEvent? {
val br = BitReader(bytes)
// ---- ItsPduHeader ---- not extensible, no optionals, so no preamble.
if (br.getBitsInt(8) != PROTOCOL_VERSION) return null
if (br.getBitsInt(8) != MESSAGE_ID_SPATEM) return null
val stationId = br.getBits(32)
// ---- SPAT ---- extensible: extension bit, then timeStamp/name/regional optional bits.
if (br.getBitsInt(1) != 0) return null
val hasTimeStamp = br.getBitsInt(1) == 1
val hasName = br.getBitsInt(1) == 1
val hasRegional = br.getBitsInt(1) == 1
val minuteOfYear = if (hasTimeStamp) br.getBitsInt(20) else null
// DescriptiveName is a variable-length IA5String. Nothing in 79k real messages uses it,
// and guessing its length would desynchronise everything after it.
if (hasName) return null
val intersectionCount = br.getBitsInt(5) + 1
if (intersectionCount > MAX_INTERSECTIONS) return null
val intersections = ArrayList<IntersectionSignalState>(intersectionCount)
repeat(intersectionCount) {
intersections.add(readIntersection(br) ?: return null)
}
if (hasRegional) return null
return SpatEvent(
stationId = stationId,
minuteOfYear = minuteOfYear,
intersections = intersections,
rssiDbm = rssiDbm,
timestamp = receivedAtEpochMs,
)
}
private fun readIntersection(br: BitReader): IntersectionSignalState? {
if (br.getBitsInt(1) != 0) return null // IntersectionState extension
val hasName = br.getBitsInt(1) == 1
val hasMoy = br.getBitsInt(1) == 1
val hasTimeStamp = br.getBitsInt(1) == 1
val hasEnabledLanes = br.getBitsInt(1) == 1
val hasManeuvers = br.getBitsInt(1) == 1
val hasRegional = br.getBitsInt(1) == 1
if (hasName) return null
// IntersectionReferenceID - not extensible, one optional bit for region.
val region = if (br.getBitsInt(1) == 1) br.getBitsInt(16) else null
val id = br.getBitsInt(16)
val revision = br.getBitsInt(7)
br.getBits(16) // IntersectionStatusObject - not surfaced yet
val moy = if (hasMoy) br.getBitsInt(20) else null
val timeStampMs = if (hasTimeStamp) br.getBitsInt(16) else null
if (hasEnabledLanes) {
repeat(br.getBitsInt(4) + 1) { br.getBits(8) } // EnabledLaneList SIZE(1..16) OF LaneID
}
val movementCount = br.getBitsInt(8) + 1
if (movementCount > MAX_MOVEMENTS) return null
val movements = ArrayList<SignalMovement>(movementCount)
repeat(movementCount) {
movements.add(readMovement(br) ?: return null)
}
if (hasManeuvers && !skipManeuverAssistList(br)) return null
if (hasRegional) return null
return IntersectionSignalState(region, id, revision, moy, timeStampMs, movements)
}
private fun readMovement(br: BitReader): SignalMovement? {
if (br.getBitsInt(1) != 0) return null // MovementState extension
val hasName = br.getBitsInt(1) == 1
val hasManeuvers = br.getBitsInt(1) == 1
val hasRegional = br.getBitsInt(1) == 1
if (hasName) return null
val signalGroup = br.getBitsInt(8)
val eventCount = br.getBitsInt(4) + 1
val events = ArrayList<SignalPhaseEvent>(eventCount)
repeat(eventCount) {
if (br.getBitsInt(1) != 0) return null // MovementEvent extension
val hasTiming = br.getBitsInt(1) == 1
val hasSpeeds = br.getBitsInt(1) == 1
val hasRegionalEvent = br.getBitsInt(1) == 1
val phase = SignalPhase.fromWire(br.getBitsInt(4)) ?: return null
var minEnd: Int? = null
var maxEnd: Int? = null
var likely: Int? = null
if (hasTiming) {
// TimeChangeDetails: NOT extensible - 5 optional bits, no extension bit.
val hasStart = br.getBitsInt(1) == 1
val hasMaxEnd = br.getBitsInt(1) == 1
val hasLikely = br.getBitsInt(1) == 1
val hasConfidence = br.getBitsInt(1) == 1
val hasNext = br.getBitsInt(1) == 1
if (hasStart) br.getBits(16)
minEnd = br.getBitsInt(16)
if (hasMaxEnd) maxEnd = br.getBitsInt(16)
if (hasLikely) likely = br.getBitsInt(16)
if (hasConfidence) br.getBits(4)
if (hasNext) br.getBits(16)
}
if (hasSpeeds || hasRegionalEvent) return null
events.add(SignalPhaseEvent(phase, minEnd, maxEnd, likely))
}
if (hasManeuvers && !skipManeuverAssistList(br)) return null
if (hasRegional) return null
return SignalMovement(signalGroup, events)
}
/**
* Walks a `ManeuverAssistList` without keeping it. Nothing consumes queue lengths yet, but the
* list is variable-length, so it has to be parsed to find where the next field starts.
*
* Returns false if it contains something this decoder cannot size, in which case the whole
* message must be abandoned - the bit position is no longer trustworthy.
*/
private fun skipManeuverAssistList(br: BitReader): Boolean {
repeat(br.getBitsInt(4) + 1) { // SIZE(1..16)
if (br.getBitsInt(1) != 0) return false // ConnectionManeuverAssist extension
val hasQueue = br.getBitsInt(1) == 1
val hasStorage = br.getBitsInt(1) == 1
val hasWaitOnStop = br.getBitsInt(1) == 1
val hasPedBicycle = br.getBitsInt(1) == 1
val hasRegional = br.getBitsInt(1) == 1
br.getBits(8) // connectionID
if (hasQueue) br.getBits(14) // ZoneLength (0..10000)
if (hasStorage) br.getBits(14)
if (hasWaitOnStop) br.getBits(1) // BOOLEAN
if (hasPedBicycle) br.getBits(1) // BOOLEAN
if (hasRegional) return false
}
return true
}
}
@@ -8,6 +8,12 @@ package com.hawhamburg.micr0bu.domain.cam
object StationType { object StationType {
const val CYCLIST = 2 const val CYCLIST = 2
const val PASSENGER_CAR = 5 const val PASSENGER_CAR = 5
/**
* Roadside infrastructure. Note the gap: the enumeration runs 0..11 then jumps to 15, so this
* is 15 and not 12 - mapping by list position mislabels every RSU.
*/
const val ROAD_SIDE_UNIT = 15
} }
/** /**
@@ -47,8 +47,12 @@ internal object JsonFieldReader {
if (lat != null && lon != null) return lat to lon if (lat != null && lon != null) return lat to lon
} }
val geoJson = obj.optJSONObject("position") // A GeoJSON Point may be nested under "position", or `obj` may BE the Point itself - the
val coords = geoJson?.optJSONArray("coordinates") // Use Case API's DENM sends `"eventPosition": {"type":"Point","coordinates":[...]}`, so the
// caller passes eventPosition in directly. Missing this second case meant every DENM was
// rejected for having no position.
val coords = obj.optJSONObject("position")?.optJSONArray("coordinates")
?: obj.optJSONArray("coordinates")
if (coords != null && coords.length() >= 2) { if (coords != null && coords.length() >= 2) {
// GeoJSON coordinate order is [longitude, latitude, altitude?] // GeoJSON coordinate order is [longitude, latitude, altitude?]
val lon = coords.optDouble(0, Double.NaN) val lon = coords.optDouble(0, Double.NaN)
@@ -11,10 +11,12 @@ import org.json.JSONObject
* more (validity duration, relevance area, traffic direction, trace paths); none of it is used * more (validity duration, relevance area, traffic direction, trace paths); none of it is used
* yet, and inventing a fuller model before there's a consumer for it would just be guesswork. * yet, and inventing a fuller model before there's a consumer for it would just be guesswork.
* *
* **Availability:** DENM reaches the app only on the CiT One path, via the Use Case API's * **Availability:** both hardware paths. On the CiT One path DENM arrives as processed JSON on
* `v2x-uca/output/json/denm` topic. The ESP32-C5 path receives none — the firmware's * the Use Case API's `v2x-uca/output/json/denm` topic ([DenmParser]); on the ESP32-C5 path it is
* `gn_unwrap.c` accepts BTP-B destination port 2001 (CAM) only and drops port 2002 (DENM) before * decoded from over-the-air GeoBroadcast traffic on BTP-B port 2002
* anything is forwarded over the serial link. See that file's header comment. * ([com.hawhamburg.micr0bu.domain.asn1.DenmUperCodec]). Fields sourced from the GeoNetworking
* header - [relevanceRadiusM], [rssiDbm] - exist only on the ESP32-C5 path, since the Use Case
* API never exposes the GN layer.
*/ */
data class DenmEvent( data class DenmEvent(
/** Originating station ID — `actionID.originatingStationID`, not the radio source. */ /** Originating station ID — `actionID.originatingStationID`, not the radio source. */
@@ -23,8 +25,8 @@ data class DenmEvent(
/** /**
* `actionID.sequenceNumber`. Together with [stationId] this is ETSI's real event identity: * `actionID.sequenceNumber`. Together with [stationId] this is ETSI's real event identity:
* repetitions of one hazard reuse it, and under GeoBroadcast several stations may relay the * repetitions of one hazard reuse it, and under GeoBroadcast several stations may relay the
* same DENM, so this pair is what dedup must key on. Null on the MQTT path when the Use Case * same DENM, so this pair is what dedup must key on. Both transports supply it - the Use Case
* API doesn't supply it. * API as a `sequenceNumber` key - so the fallback below is for malformed payloads only.
*/ */
val sequenceNumber: Int? = null, val sequenceNumber: Int? = null,
@@ -64,9 +66,10 @@ data class DenmEvent(
val timestamp: Long, val timestamp: Long,
) { ) {
/** /**
* Stable identity for map/list dedup. Prefers ETSI's actionID (`stationId` + `sequenceNumber`) * Stable identity for map/list dedup. Prefers ETSI's actionID (`stationId` + `sequenceNumber`),
* where available; falls back to station + cause on the MQTT path, which doesn't reliably * which both transports carry; falls back to station + cause only when a payload omits the
* expose a sequence number. * sequence number. The fallback is weaker than it looks: a terminating DENM carries no
* SituationContainer, so its cause is null and it would NOT collide with the event it ends.
*/ */
val dedupKey: String val dedupKey: String
get() = if (sequenceNumber != null) "$stationId/$sequenceNumber" get() = if (sequenceNumber != null) "$stationId/$sequenceNumber"
@@ -77,11 +80,12 @@ data class DenmEvent(
* Parses the processed DENM JSON published by the consider it Use Case API on * Parses the processed DENM JSON published by the consider it Use Case API on
* `v2x-uca/output/json/denm`. * `v2x-uca/output/json/denm`.
* *
* Same field-name tolerance approach as [com.hawhamburg.micr0bu.domain.cam.CamParser] — confirmed * Field names follow `CI-CiT-MQTT_API_Documentation-v6-20250221.pdf` section 2.2.4 / listing 2.6,
* spellings first, plausible alternatives as fallbacks via [JsonFieldReader] — because the exact * which is the contract for this topic; `DenmParserMqttTest` pins this parser to that worked
* schema hasn't been pinned against real OBU payloads yet. Returns null rather than a * example. Alternative spellings are still accepted via [JsonFieldReader] as fallbacks.
* half-populated event when position is missing: a DENM with no position is useless to a map and *
* worse than absent on a hazard display. * Returns null rather than a half-populated event when position is missing: a DENM with no
* position is useless to a map and worse than absent on a hazard display.
*/ */
object DenmParser { object DenmParser {
@@ -92,7 +96,10 @@ object DenmParser {
*/ */
private val CAUSE_CODE_BY_NAME = mapOf( private val CAUSE_CODE_BY_NAME = mapOf(
"trafficCondition" to 1, "accident" to 2, "roadworks" to 3, "impassability" to 5, "trafficCondition" to 1, "accident" to 2, "roadworks" to 3, "impassability" to 5,
"adverseWeatherCondition_Adhesion" to 6, "aquaplanning" to 7, "adverseWeatherCondition_Adhesion" to 6,
// Three n's: that is how ETSI's CauseCodeType spells it, and the API follows.
// The correctly-spelled variant is accepted too, in case that is ever fixed.
"aquaplannning" to 7, "aquaplanning" to 7,
"hazardousLocation_SurfaceCondition" to 9, "hazardousLocation_ObstacleOnTheRoad" to 10, "hazardousLocation_SurfaceCondition" to 9, "hazardousLocation_ObstacleOnTheRoad" to 10,
"hazardousLocation_AnimalOnTheRoad" to 11, "humanPresenceOnTheRoad" to 12, "hazardousLocation_AnimalOnTheRoad" to 11, "humanPresenceOnTheRoad" to 12,
"wrongWayDriving" to 14, "rescueAndRecoveryWorkInProgress" to 15, "wrongWayDriving" to 14, "rescueAndRecoveryWorkInProgress" to 15,
@@ -116,6 +123,29 @@ object DenmParser {
*/ */
fun causeCodeName(causeCode: Int?): String? = causeCode?.let { NAME_BY_CAUSE_CODE[it] } fun causeCodeName(causeCode: Int?): String? = causeCode?.let { NAME_BY_CAUSE_CODE[it] }
/**
* The Use Case API's `stationType` string enum mapped to its ITS-G5 integer, per StationType in
* the ETSI CDD. Note roadSideUnit is **15**, not 12 - the enumeration has a gap after tram(11),
* so mapping by list position would silently mislabel every RSU.
*/
private val STATION_TYPE_BY_NAME = mapOf(
"unknown" to 0, "pedestrian" to 1, "cyclist" to 2, "moped" to 3, "motorcycle" to 4,
"passengerCar" to 5, "bus" to 6, "lightTruck" to 7, "heavyTruck" to 8, "trailer" to 9,
"specialVehicles" to 10, "tram" to 11, "roadSideUnit" to 15,
)
/**
* Parses the API's RFC3339 timestamps ("2021-05-11T12:01:02+00:00") to epoch millis. The air
* path carries a binary TimestampIts instead, so the two transports arrive here in completely
* different formats and both end up as epoch ms on [DenmEvent].
*/
private fun parseRfc3339(value: String?): Long? {
if (value.isNullOrBlank()) return null
return runCatching { java.time.OffsetDateTime.parse(value).toInstant().toEpochMilli() }
.recoverCatching { java.time.Instant.parse(value).toEpochMilli() }
.getOrNull()
}
fun parse(json: String, timestamp: Long = System.currentTimeMillis()): DenmEvent? { fun parse(json: String, timestamp: Long = System.currentTimeMillis()): DenmEvent? {
val obj = runCatching { JSONObject(json) }.getOrNull() ?: return null val obj = runCatching { JSONObject(json) }.getOrNull() ?: return null
@@ -127,9 +157,16 @@ object DenmParser {
?.let { JsonFieldReader.firstLatLon(it) } ?.let { JsonFieldReader.firstLatLon(it) }
?: return null ?: return null
val stationId = JsonFieldReader.firstLong(obj, "stationId", "stationID", "station_id") // "originatingStationId" is what the Use Case API actually sends (API doc listing 2.6);
// without it every DENM from the CiT One path was dropped here, before anything else in
// this function ran. The other spellings are kept as fallbacks.
val stationId = JsonFieldReader.firstLong(
obj, "originatingStationId", "originatingStationID", "stationId", "stationID", "station_id",
)
?: obj.optJSONObject("management")?.let { ?: obj.optJSONObject("management")?.let {
JsonFieldReader.firstLong(it, "stationId", "stationID", "station_id") JsonFieldReader.firstLong(
it, "originatingStationId", "originatingStationID", "stationId", "stationID", "station_id",
)
} }
?: return null ?: return null
@@ -144,12 +181,25 @@ object DenmParser {
val subCauseCode = JsonFieldReader.firstInt(obj, "subCauseCode", "sub_cause_code", "subCause") val subCauseCode = JsonFieldReader.firstInt(obj, "subCauseCode", "sub_cause_code", "subCause")
?: situation?.let { JsonFieldReader.firstInt(it, "subCauseCode", "sub_cause_code", "subCause") } ?: situation?.let { JsonFieldReader.firstInt(it, "subCauseCode", "sub_cause_code", "subCause") }
// "Key is present, if the DENM is cancelled or negated" (API doc 2.2.4) - so presence is
// the signal, not the value. An explicit `false` is still honoured in case the API ever
// starts always emitting the key.
val isTermination = obj.has("termination") && obj.optBoolean("termination", true)
val stationType = JsonFieldReader.firstInt(obj, "stationType", "station_type")
?: STATION_TYPE_BY_NAME[obj.optString("stationType").takeIf { it.isNotBlank() }]
return DenmEvent( return DenmEvent(
stationId = stationId, stationId = stationId,
sequenceNumber = JsonFieldReader.firstInt(obj, "sequenceNumber", "sequence_number"),
latitude = lat, latitude = lat,
longitude = lon, longitude = lon,
causeCode = causeCode, causeCode = causeCode,
subCauseCode = subCauseCode, subCauseCode = subCauseCode,
stationType = stationType,
isTermination = isTermination,
detectionTimeMs = parseRfc3339(obj.optString("detectionTime").takeIf { it.isNotBlank() })
?: JsonFieldReader.firstLong(obj, "detectionTime", "detection_time"),
timestamp = timestamp, timestamp = timestamp,
) )
} }
@@ -0,0 +1,141 @@
package com.hawhamburg.micr0bu.domain.spat
/**
* Signal phase and timing for one or more intersections, decoded from a SPATEM heard over the air.
*
* A SPATEM is the live counterpart to MAPEM's static geometry: MAPEM says where the lanes are,
* SPATEM says what the lights are doing right now. The two join on [IntersectionSignalState.key].
* Only SPATEM is decoded today - without MAPEM there is no lane geometry, so a signal group is
* shown as a bare number rather than "the left-turn lane you are in".
*
* Repetition is ~2 Hz per intersection, so consumers should key on [IntersectionSignalState.key]
* and keep the latest rather than accumulating a log.
*/
data class SpatEvent(
/** Originating RSU's station ID, from the ItsPduHeader. */
val stationId: Long,
/** `SPAT.timeStamp`, minute of the year, when present. */
val minuteOfYear: Int?,
val intersections: List<IntersectionSignalState>,
/** Received signal strength, dBm - ESP32-C5 path only. */
val rssiDbm: Int? = null,
/** Wall-clock ms this SPATEM was received. */
val timestamp: Long,
)
/** One intersection's current signal state. */
data class IntersectionSignalState(
/** `RoadRegulatorID`, when the sender qualifies its intersection id with one. */
val region: Int?,
/** `IntersectionID` - only unique *within* [region]. */
val id: Int,
/** `MsgCount`, bumped when the intersection's MAP geometry changes. */
val revision: Int,
/** Minute of the year this state refers to, when present. */
val moy: Int?,
/** `DSecond` - milliseconds within [moy]'s minute, when present. */
val timeStampMs: Int?,
val movements: List<SignalMovement>,
) {
/**
* Identity for dedup and for joining against MAPEM. `IntersectionID` alone is NOT unique -
* it is only unique within a `RoadRegulatorID`, and the recorded drive contains the same id
* under different regions - so the region must be part of the key.
*/
val key: String get() = "${region ?: -1}/$id"
}
/** The signal state of one signal group (one movement through the intersection). */
data class SignalMovement(
/** `SignalGroupID` - the number MAPEM's lane connections refer to. */
val signalGroup: Int,
/**
* Predicted phases, in order. The first entry is the state now; later entries are the
* upcoming sequence, which is what makes a countdown possible.
*/
val events: List<SignalPhaseEvent>,
) {
val current: SignalPhaseEvent? get() = events.firstOrNull()
}
data class SignalPhaseEvent(
val phase: SignalPhase,
/**
* `TimeMark`: tenths of a second within the current or next UTC hour, so it wraps hourly.
* 36001 means "unknown". Use [secondsUntil] rather than comparing these directly.
*/
val minEndTimeDs: Int?,
val maxEndTimeDs: Int?,
val likelyTimeDs: Int?,
) {
/**
* Seconds from [nowEpochMs] until [minEndTimeDs], or null if unknown.
*
* TimeMark counts tenths of a second from the top of the hour and wraps, so a mark that looks
* like it is in the past is really in the next hour - hence the wrap correction. Without it, a
* countdown reads as a large negative number for the seconds either side of the hour.
*/
fun secondsUntil(nowEpochMs: Long): Double? {
val mark = minEndTimeDs ?: return null
if (mark >= UNKNOWN_TIME_MARK) return null
val msIntoHour = nowEpochMs % 3_600_000L
var deltaMs = mark * 100L - msIntoHour
if (deltaMs < -HALF_HOUR_MS) deltaMs += 3_600_000L // mark is in the next hour
return deltaMs / 1000.0
}
private companion object {
const val UNKNOWN_TIME_MARK = 36001
const val HALF_HOUR_MS = 1_800_000L
}
}
/** `MovementPhaseState` (ETSI/SAE J2735), in enumeration order - the ordinal IS the wire value. */
enum class SignalPhase {
UNAVAILABLE,
DARK,
STOP_THEN_PROCEED,
STOP_AND_REMAIN,
PRE_MOVEMENT,
PERMISSIVE_MOVEMENT_ALLOWED,
PROTECTED_MOVEMENT_ALLOWED,
PERMISSIVE_CLEARANCE,
PROTECTED_CLEARANCE,
CAUTION_CONFLICTING_TRAFFIC;
/** True for the two "you may go" states. */
val isGo: Boolean
get() = this == PERMISSIVE_MOVEMENT_ALLOWED || this == PROTECTED_MOVEMENT_ALLOWED
/** True for the two "you must stop" states. */
val isStop: Boolean
get() = this == STOP_AND_REMAIN || this == STOP_THEN_PROCEED
/** True while the light is changing - amber, or red-amber before green. */
val isTransition: Boolean
get() = this == PRE_MOVEMENT || this == PERMISSIVE_CLEARANCE || this == PROTECTED_CLEARANCE
companion object {
fun fromWire(value: Int): SignalPhase? = entries.getOrNull(value)
}
}
/**
* One intersection's signal state as the UI consumes it: the decoded state plus who sent it and
* when, flattened out of the [SpatEvent] that carried it.
*
* A single SPATEM may describe several intersections, and the same intersection may be heard from
* more than one RSU, so the UI keys on the intersection rather than on the message.
*/
data class SpatIntersection(
val state: IntersectionSignalState,
val stationId: Long,
val rssiDbm: Int?,
val timestamp: Long,
) {
val key: String get() = state.key
}
@@ -8,6 +8,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -84,8 +85,12 @@ import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
/** List (raw topics) vs Map (V2X live map, Section 13) toggle for [TopicListPane]. */ /**
private enum class TopicViewMode { LIST, MAP } * View toggle for [TopicListPane]: decoded CAM/DENM traffic (LIST), the raw MQTT topic list
* (TOPICS, CiT One only - there is no broker on the ESP32-C5 path), or the V2X live map
* (MAP, Section 13).
*/
private enum class TopicViewMode { LIST, TOPICS, MAP }
private val timeFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.US) private val timeFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.US)
@@ -130,6 +135,7 @@ fun MqttTopicViewerScreen(
val camSendFailures by viewModel.camSendFailures.collectAsState() val camSendFailures by viewModel.camSendFailures.collectAsState()
val espLinkStatus by viewModel.espLinkStatus.collectAsState() val espLinkStatus by viewModel.espLinkStatus.collectAsState()
val denmEvents by viewModel.denmEvents.collectAsState() val denmEvents by viewModel.denmEvents.collectAsState()
val spatIntersections by viewModel.spatIntersections.collectAsState()
// Sort: sys/ topics first (heartbeat/health), then alphabetical // Sort: sys/ topics first (heartbeat/health), then alphabetical
val sortedTopics = topicMessages.keys.sortedWith( val sortedTopics = topicMessages.keys.sortedWith(
@@ -226,6 +232,7 @@ fun MqttTopicViewerScreen(
showCamPinger = isEsp32, showCamPinger = isEsp32,
isEsp32 = isEsp32, isEsp32 = isEsp32,
denmEvents = denmEvents, denmEvents = denmEvents,
spatIntersections = spatIntersections,
usbSerialState = usbSerialState, usbSerialState = usbSerialState,
camPingerActive = camPingerActive, camPingerActive = camPingerActive,
camPingerSentCount = camPingerSentCount, camPingerSentCount = camPingerSentCount,
@@ -265,6 +272,7 @@ private fun TopicListPane(
showCamPinger: Boolean = false, showCamPinger: Boolean = false,
isEsp32: Boolean = false, isEsp32: Boolean = false,
denmEvents: List<com.hawhamburg.micr0bu.domain.denm.DenmEvent> = emptyList(), denmEvents: List<com.hawhamburg.micr0bu.domain.denm.DenmEvent> = emptyList(),
spatIntersections: List<com.hawhamburg.micr0bu.domain.spat.SpatIntersection> = emptyList(),
usbSerialState: UsbSerialState = UsbSerialState.DISCONNECTED, usbSerialState: UsbSerialState = UsbSerialState.DISCONNECTED,
camPingerActive: Boolean = false, camPingerActive: Boolean = false,
camPingerSentCount: Int = 0, camPingerSentCount: Int = 0,
@@ -321,30 +329,41 @@ private fun TopicListPane(
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f)) HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f))
} }
// ── List / Map toggle — the raw topic list stays available either way (Section 13 // ── List / Topics / Map toggle ────────────────────────────────────────────────────
// asks for the map "in addition to", not instead of, the topic list). ────────────── // Decoded traffic is the default on BOTH hardware paths: what a tester wants to see is
// the road users and hazards, not the transport that carried them. The raw MQTT topic
// list stays one tap away on the CiT One path (Section 13 asks for the map "in addition
// to", not instead of, the topic list). It is hidden on the ESP32-C5 path, where there is
// no broker and `topics` is permanently empty.
Row( Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp), modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
OutlinedButton( ViewModeButton(
onClick = { viewMode = TopicViewMode.LIST }, label = stringResource(R.string.mqtt_view_list),
colors = ButtonDefaults.outlinedButtonColors( selected = viewMode == TopicViewMode.LIST,
containerColor = if (viewMode == TopicViewMode.LIST) MaterialTheme.colorScheme.primaryContainer else Color.Transparent, ) { viewMode = TopicViewMode.LIST }
contentColor = if (viewMode == TopicViewMode.LIST) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
), if (!isEsp32) {
) { Text(stringResource(R.string.mqtt_view_list)) } ViewModeButton(
OutlinedButton( label = stringResource(R.string.mqtt_view_topics),
onClick = { viewMode = TopicViewMode.MAP }, selected = viewMode == TopicViewMode.TOPICS,
colors = ButtonDefaults.outlinedButtonColors( ) { viewMode = TopicViewMode.TOPICS }
containerColor = if (viewMode == TopicViewMode.MAP) MaterialTheme.colorScheme.primaryContainer else Color.Transparent,
contentColor = if (viewMode == TopicViewMode.MAP) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
),
) { Text(stringResource(R.string.mqtt_view_map)) }
} }
// ── Topic rows / received CAMs / live map ───────────────────────────── ViewModeButton(
if (viewMode == TopicViewMode.MAP) { label = stringResource(R.string.mqtt_view_map),
selected = viewMode == TopicViewMode.MAP,
) { viewMode = TopicViewMode.MAP }
}
// ── Decoded traffic / raw topics / live map ───────────────────────────
// TOPICS can still be the saved selection from a CiT One session after switching hardware
// to the ESP32-C5, where that button no longer exists - fall back to the decoded list
// rather than stranding the user on a pane they can't navigate away from.
val shownMode = if (viewMode == TopicViewMode.TOPICS && isEsp32) TopicViewMode.LIST else viewMode
if (shownMode == TopicViewMode.MAP) {
V2xLiveMapView( V2xLiveMapView(
own = ownCamPosition, own = ownCamPosition,
remotes = remoteCamPositions, remotes = remoteCamPositions,
@@ -352,15 +371,13 @@ private fun TopicListPane(
denms = denmEvents, denms = denmEvents,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
} else if (isEsp32) { } else if (shownMode == TopicViewMode.LIST) {
// The MQTT topic list is meaningless on this path - there is no broker, so `topics`
// is permanently empty and the list would read as "nothing is happening" even while
// CAMs stream in over the serial link. Show the decoded traffic instead.
ReceivedCamPane( ReceivedCamPane(
own = ownCamPosition, own = ownCamPosition,
remotes = remoteCamPositions, remotes = remoteCamPositions,
alerts = useCaseAlerts, alerts = useCaseAlerts,
denms = denmEvents, denms = denmEvents,
spats = spatIntersections,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
} else if (topics.isEmpty()) { } else if (topics.isEmpty()) {
@@ -421,9 +438,10 @@ private fun ReceivedCamPane(
remotes: Map<Long, com.hawhamburg.micr0bu.domain.cam.Cam>, remotes: Map<Long, com.hawhamburg.micr0bu.domain.cam.Cam>,
alerts: List<UseCaseAlert>, alerts: List<UseCaseAlert>,
denms: List<com.hawhamburg.micr0bu.domain.denm.DenmEvent>, denms: List<com.hawhamburg.micr0bu.domain.denm.DenmEvent>,
spats: List<com.hawhamburg.micr0bu.domain.spat.SpatIntersection>,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
if (remotes.isEmpty() && denms.isEmpty()) { if (remotes.isEmpty() && denms.isEmpty() && spats.isEmpty()) {
Box(modifier = modifier, contentAlignment = Alignment.Center) { Box(modifier = modifier, contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text( Text(
@@ -485,6 +503,14 @@ private fun ReceivedCamPane(
} }
} }
if (spats.isNotEmpty()) {
item { PaneSectionHeader(stringResource(R.string.v2x_spat_rx_count, spats.size)) }
items(spats, key = { "spat/" + it.key }) { spat ->
ReceivedSpatRow(spat)
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f))
}
}
item { item {
PaneSectionHeader( PaneSectionHeader(
if (rows.isEmpty()) stringResource(R.string.v2x_cam_rx_none_stations) if (rows.isEmpty()) stringResource(R.string.v2x_cam_rx_none_stations)
@@ -498,6 +524,82 @@ private fun ReceivedCamPane(
} }
} }
@Composable
private fun ViewModeButton(label: String, selected: Boolean, onClick: () -> Unit) {
OutlinedButton(
onClick = onClick,
colors = ButtonDefaults.outlinedButtonColors(
containerColor = if (selected) MaterialTheme.colorScheme.primaryContainer else Color.Transparent,
contentColor = if (selected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
),
) { Text(label) }
}
/**
* One signalised intersection: every signal group's current phase, with a countdown where the RSU
* supplies one.
*
* Signal groups are shown as bare numbers because that is genuinely all we know - mapping a group
* to "your lane" needs MAPEM geometry, which nothing on the air is currently sending. Inventing a
* friendlier label would imply knowledge the app does not have.
*/
@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class)
@Composable
private fun ReceivedSpatRow(spat: com.hawhamburg.micr0bu.domain.spat.SpatIntersection) {
val now = System.currentTimeMillis()
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 10.dp),
) {
Text(
text = stringResource(R.string.v2x_spat_rx_title, spat.state.key, spat.stationId),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.height(4.dp))
// Wraps rather than scrolls: a busy intersection has 20+ groups and a horizontal
// scroller inside a vertical list is awkward to drive one-handed on a bike mount.
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
spat.state.movements.forEach { movement ->
val phase = movement.current?.phase
val tint = when {
phase == null -> MaterialTheme.colorScheme.onSurfaceVariant
phase.isGo -> ConnectedGreen
phase.isStop -> WarningRed
phase.isTransition -> AwarenessAmber
else -> DisconnectedGray
}
val countdown = movement.current?.secondsUntil(now)?.takeIf { it in 0.0..99.0 }
Text(
text = stringResource(R.string.v2x_spat_group, movement.signalGroup) +
(countdown?.let { " " + stringResource(R.string.v2x_spat_countdown, it) } ?: ""),
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = tint,
modifier = Modifier
.padding(vertical = 2.dp)
.clip(RoundedCornerShape(4.dp))
.background(tint.copy(alpha = 0.15f))
.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
}
spat.rssiDbm?.let {
Spacer(Modifier.height(3.dp))
Text(
text = stringResource(R.string.v2x_cam_rx_rssi, it),
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable @Composable
private fun PaneSectionHeader(text: String) { private fun PaneSectionHeader(text: String) {
Column { Column {
@@ -17,13 +17,17 @@ import com.hawhamburg.micr0bu.data.transport.UsbSerialState
import com.hawhamburg.micr0bu.data.transport.UsbSerialTransport import com.hawhamburg.micr0bu.data.transport.UsbSerialTransport
import com.hawhamburg.micr0bu.domain.denm.DenmEvent import com.hawhamburg.micr0bu.domain.denm.DenmEvent
import com.hawhamburg.micr0bu.domain.denm.DenmParser import com.hawhamburg.micr0bu.domain.denm.DenmParser
import com.hawhamburg.micr0bu.domain.spat.SpatIntersection
import com.hawhamburg.micr0bu.domain.denm.DenmUseCase import com.hawhamburg.micr0bu.domain.denm.DenmUseCase
import com.hawhamburg.micr0bu.domain.usecase.UseCaseAlert import com.hawhamburg.micr0bu.domain.usecase.UseCaseAlert
import com.hawhamburg.micr0bu.domain.usecase.UseCaseType import com.hawhamburg.micr0bu.domain.usecase.UseCaseType
import com.hawhamburg.micr0bu.service.CamPinger import com.hawhamburg.micr0bu.service.CamPinger
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.runningFold import kotlinx.coroutines.flow.runningFold
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -155,17 +159,77 @@ class MqttViewModel @Inject constructor(
camUseCaseRepository.airDenm camUseCaseRepository.airDenm
.runningFold(emptyMap<String, DenmEvent>()) { acc, denm -> acc + (denm.dedupKey to denm) } .runningFold(emptyMap<String, DenmEvent>()) { acc, denm -> acc + (denm.dedupKey to denm) }
.map { it.values.toList() }, .map { it.values.toList() },
) { fromMqtt, fromAir -> // Expiry has to be driven by a clock, not by arrivals. Both upstream flows only re-emit
// when a DENM arrives, so a sender that simply stops transmitting - drives away, loses
// power, leaves range - would otherwise leave its hazard on the map forever: there is no
// further emission to recompute the list. This tick is what makes a hazard fade.
tickerFlow(DENM_EXPIRY_TICK_MS),
) { fromMqtt, fromAir, _ ->
val now = System.currentTimeMillis()
(fromMqtt + fromAir) (fromMqtt + fromAir)
.filterNot { it.isTermination } // the hazard is over - stop drawing it .filterNot { it.isTermination } // the hazard is over - stop drawing it
.associateBy { it.dedupKey } // last write wins = most recent per hazard .associateBy { it.dedupKey } // last write wins = most recent per hazard
.values .values
// Not heard from in DENM_TTL_MS: treat as gone. DENMs repeat at roughly 1 Hz, so a
// full minute of silence is ~60 missed repetitions - well past "we briefly lost one".
.filter { now - it.timestamp <= DENM_TTL_MS }
.sortedByDescending { it.timestamp } .sortedByDescending { it.timestamp }
}.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) }.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
/**
* Live signal state per intersection, newest first, keyed by [IntersectionSignalState.key].
*
* ESP32-C5 path only: SPATEM arrives over the air on BTP port 2004. The CiT One path publishes
* SPATEM on its own MQTT topic in a different (protobuf-wrapped) shape, which is not wired up.
*
* One entry per intersection, not per message: SPATEM repeats at ~2 Hz per RSU, so a log would
* grow without telling anyone anything. Entries expire like DENMs do - an intersection left
* behind stops transmitting, and the same clock-driven argument applies.
*/
val spatIntersections: StateFlow<List<SpatIntersection>> = combine(
camUseCaseRepository.airSpat
.runningFold(emptyMap<String, SpatIntersection>()) { acc, spat ->
acc + spat.intersections.associate { i ->
i.key to SpatIntersection(i, spat.stationId, spat.rssiDbm, spat.timestamp)
}
},
tickerFlow(SPAT_EXPIRY_TICK_MS),
) { byKey, _ ->
val now = System.currentTimeMillis()
byKey.values
.filter { now - it.timestamp <= SPAT_TTL_MS }
.sortedByDescending { it.timestamp }
}.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
/** Emits immediately, then every [periodMs], purely to re-trigger a time-dependent combine. */
private fun tickerFlow(periodMs: Long): Flow<Long> = flow {
while (true) {
emit(System.currentTimeMillis())
delay(periodMs)
}
}
private companion object { private companion object {
/** Use Case API topic carrying received DENMs (CiT One path only). */ /** Use Case API topic carrying received DENMs (CiT One path only). */
const val DENM_RX_TOPIC = "v2x-uca/output/json/denm" const val DENM_RX_TOPIC = "v2x-uca/output/json/denm"
/**
* How long a hazard stays listed after its last repetition. A DENM has no "still here"
* guarantee beyond the sender repeating it, and its own validityDuration is not decoded
* yet, so silence is the only expiry signal available.
*/
const val DENM_TTL_MS = 60_000L
/** How often the list is re-evaluated for expiry. Sets the worst-case lateness of a fade. */
const val DENM_EXPIRY_TICK_MS = 5_000L
/**
* SPATEM repeats at ~2 Hz, so 15 s of silence is ~30 missed repetitions: the RSU is out of
* range. Much shorter than the DENM window because a stale traffic light is more
* misleading than a stale hazard - a light that stopped updating is not "still green".
*/
const val SPAT_TTL_MS = 15_000L
const val SPAT_EXPIRY_TICK_MS = 2_000L
} }
// ── DENM transmission ───────────────────────────────────────────────────── // ── DENM transmission ─────────────────────────────────────────────────────
+8 -1
View File
@@ -183,6 +183,7 @@
<string name="mqtt_auto_scroll">Automatisch scrollen</string> <string name="mqtt_auto_scroll">Automatisch scrollen</string>
<string name="mqtt_no_topics">Noch keine Nachrichten</string> <string name="mqtt_no_topics">Noch keine Nachrichten</string>
<string name="mqtt_view_list">Liste</string> <string name="mqtt_view_list">Liste</string>
<string name="mqtt_view_topics">Topics</string>
<string name="mqtt_view_map">Karte</string> <string name="mqtt_view_map">Karte</string>
<string name="mqtt_no_topics_hint">Mit der OBU verbinden und auf V2X-Verkehr warten</string> <string name="mqtt_no_topics_hint">Mit der OBU verbinden und auf V2X-Verkehr warten</string>
<string name="mqtt_no_messages">Noch keine Nachrichten zu diesem Thema</string> <string name="mqtt_no_messages">Noch keine Nachrichten zu diesem Thema</string>
@@ -207,7 +208,7 @@
<!-- Empfangene CAMs (ESP32-C5-Pfad) --> <!-- Empfangene CAMs (ESP32-C5-Pfad) -->
<string name="v2x_cam_rx_count">%1$d Station(en) in Reichweite - jeweils neueste CAM</string> <string name="v2x_cam_rx_count">%1$d Station(en) in Reichweite - jeweils neueste CAM</string>
<string name="v2x_cam_rx_none">Keine CAMs empfangen</string> <string name="v2x_cam_rx_none">Keine CAMs empfangen</string>
<string name="v2x_cam_rx_none_hint">Dekodierte CAMs benachbarter Stationen erscheinen hier, sobald sie über die serielle Verbindung eintreffen.</string> <string name="v2x_cam_rx_none_hint">Dekodierte CAMs benachbarter Stationen erscheinen hier, sobald sie eintreffen.</string>
<string name="v2x_cam_rx_station">Station %1$d · %2$s</string> <string name="v2x_cam_rx_station">Station %1$d · %2$s</string>
<string name="v2x_cam_rx_kinematics">%1$.1f km/h · Kurs %2$.0f°</string> <string name="v2x_cam_rx_kinematics">%1$.1f km/h · Kurs %2$.0f°</string>
<string name="v2x_cam_rx_distance">%1$.0f m</string> <string name="v2x_cam_rx_distance">%1$.0f m</string>
@@ -293,4 +294,10 @@
<!-- Phase A: Trip Review screen --> <!-- Phase A: Trip Review screen -->
<string name="trip_review_title">Fahrtanalyse</string> <string name="trip_review_title">Fahrtanalyse</string>
<!-- Empfangene SPATEM-Liste (ESP32-C5) -->
<string name="v2x_spat_rx_count">%1$d signalisierte Kreuzung(en) - Live-SPAT</string>
<string name="v2x_spat_rx_title">Kreuzung %1$s · Station %2$d</string>
<string name="v2x_spat_group">SG%1$d</string>
<string name="v2x_spat_countdown">%1$.0f s</string>
</resources> </resources>
+8 -1
View File
@@ -184,6 +184,7 @@
<string name="mqtt_auto_scroll">Auto-scroll to latest</string> <string name="mqtt_auto_scroll">Auto-scroll to latest</string>
<string name="mqtt_no_topics">No messages yet</string> <string name="mqtt_no_topics">No messages yet</string>
<string name="mqtt_view_list">List</string> <string name="mqtt_view_list">List</string>
<string name="mqtt_view_topics">Topics</string>
<string name="mqtt_view_map">Map</string> <string name="mqtt_view_map">Map</string>
<string name="mqtt_no_topics_hint">Connect to the OBU and wait for V2X traffic</string> <string name="mqtt_no_topics_hint">Connect to the OBU and wait for V2X traffic</string>
<string name="mqtt_no_messages">No messages on this topic yet</string> <string name="mqtt_no_messages">No messages on this topic yet</string>
@@ -208,7 +209,7 @@
<!-- Received-CAM list (ESP32-C5 path) --> <!-- Received-CAM list (ESP32-C5 path) -->
<string name="v2x_cam_rx_count">%1$d station(s) in range - latest CAM per station</string> <string name="v2x_cam_rx_count">%1$d station(s) in range - latest CAM per station</string>
<string name="v2x_cam_rx_none">No CAMs received</string> <string name="v2x_cam_rx_none">No CAMs received</string>
<string name="v2x_cam_rx_none_hint">Decoded CAMs from nearby stations appear here as they arrive over the serial link.</string> <string name="v2x_cam_rx_none_hint">Decoded CAMs from nearby stations appear here as they arrive.</string>
<string name="v2x_cam_rx_station">Station %1$d · %2$s</string> <string name="v2x_cam_rx_station">Station %1$d · %2$s</string>
<string name="v2x_cam_rx_kinematics">%1$.1f km/h · heading %2$.0f°</string> <string name="v2x_cam_rx_kinematics">%1$.1f km/h · heading %2$.0f°</string>
<string name="v2x_cam_rx_distance">%1$.0f m</string> <string name="v2x_cam_rx_distance">%1$.0f m</string>
@@ -222,6 +223,12 @@
<string name="v2x_denm_rx_cause_code">cause %1$d/%2$d</string> <string name="v2x_denm_rx_cause_code">cause %1$d/%2$d</string>
<string name="v2x_denm_rx_radius">%1$d m radius</string> <string name="v2x_denm_rx_radius">%1$d m radius</string>
<!-- Received-SPATEM list (ESP32-C5 path) -->
<string name="v2x_spat_rx_count">%1$d signalised intersection(s) - live SPAT</string>
<string name="v2x_spat_rx_title">Intersection %1$s · station %2$d</string>
<string name="v2x_spat_group">SG%1$d</string>
<string name="v2x_spat_countdown">%1$.0f s</string>
<!-- DENM map pins --> <!-- DENM map pins -->
<string name="v2x_map_denm_labeled">Hazard: cause %1$d/%2$d (station %3$d)</string> <string name="v2x_map_denm_labeled">Hazard: cause %1$d/%2$d (station %3$d)</string>
<string name="v2x_map_denm_plain">Hazard from station %1$d</string> <string name="v2x_map_denm_plain">Hazard from station %1$d</string>
@@ -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))
}
}
+2
View File
@@ -4,6 +4,7 @@ kotlin = "2.1.0"
ksp = "2.1.0-1.0.29" ksp = "2.1.0-1.0.29"
hilt = "2.56.2" hilt = "2.56.2"
junit = "4.13.2" junit = "4.13.2"
json = "20240303"
hiltNavigationCompose = "1.2.0" hiltNavigationCompose = "1.2.0"
datastore = "1.1.1" datastore = "1.1.1"
pahoMqtt = "1.2.5" pahoMqtt = "1.2.5"
@@ -49,6 +50,7 @@ osmdroid = { group = "org.osmdroid", name = "osmdroid-android", version.ref = "o
androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "splashscreen" } androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "splashscreen" }
usb-serial-android = { group = "com.github.mik3y", name = "usb-serial-for-android", version.ref = "usbSerial" } usb-serial-android = { group = "com.github.mik3y", name = "usb-serial-for-android", version.ref = "usbSerial" }
junit = { group = "junit", name = "junit", version.ref = "junit" } junit = { group = "junit", name = "junit", version.ref = "junit" }
json = { group = "org.json", name = "json", version.ref = "json" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" } kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" }
[plugins] [plugins]
+6 -1
View File
@@ -33,6 +33,10 @@
#define BTP_DEST_PORT_CAM (2001) // ETSI TS 103 248 #define BTP_DEST_PORT_CAM (2001) // ETSI TS 103 248
#define BTP_DEST_PORT_DENM (2002) #define BTP_DEST_PORT_DENM (2002)
// NOTE the crossover: SPATEM is BTP port 2004 but ItsPduHeader messageID 4, while MAPEM is port
// 2003 and messageID 5. Port and messageID are NOT the same number - mixing them up routes every
// message to the wrong decoder on the phone.
#define BTP_DEST_PORT_SPATEM (2004)
static const uint8_t s_llc_snap_prefix[6] = {0xAA, 0xAA, 0x03, 0x00, 0x00, 0x00}; static const uint8_t s_llc_snap_prefix[6] = {0xAA, 0xAA, 0x03, 0x00, 0x00, 0x00};
@@ -138,7 +142,8 @@ bool gn_unwrap_its(const uint8_t *frame, int frame_len, gn_rx_t *out)
return false; return false;
} }
uint16_t dest_port = be16(frame + offset); uint16_t dest_port = be16(frame + offset);
if (dest_port != BTP_DEST_PORT_CAM && dest_port != BTP_DEST_PORT_DENM) { if (dest_port != BTP_DEST_PORT_CAM && dest_port != BTP_DEST_PORT_DENM &&
dest_port != BTP_DEST_PORT_SPATEM) {
return false; return false;
} }
offset += BTP_B_HEADER_LEN; offset += BTP_B_HEADER_LEN;
+8 -1
View File
@@ -29,11 +29,18 @@
// talks to sends them, and each has its own extended-header length that would need measuring. // talks to sends them, and each has its own extended-header length that would need measuring.
// //
// ---- Accepted BTP-B ports (ETSI TS 103 248) ------------------------------------------------ // ---- Accepted BTP-B ports (ETSI TS 103 248) ------------------------------------------------
// 2001 (CAM) and 2002 (DENM). MAPEM (2003), SPATEM (2004) and the rest are deliberately not // 2001 (CAM), 2002 (DENM) and 2004 (SPATEM). MAPEM (2003) and the rest are deliberately not
// accepted yet: the phone has no decoder for them, so forwarding would just burn serial // accepted yet: the phone has no decoder for them, so forwarding would just burn serial
// bandwidth. Adding one is a one-line change here plus a decoder on the phone - the serial // bandwidth. Adding one is a one-line change here plus a decoder on the phone - the serial
// protocol itself is already generic (see SERIAL_MSG_V2X_RX in serial_link.h). // protocol itself is already generic (see SERIAL_MSG_V2X_RX in serial_link.h).
// //
// SPATEM size caveat: SERIAL_LINK_MAX_PAYLOAD is 512, so a SPATEM whose UPER exceeds 498 bytes is
// counted as an oversize drop rather than forwarded. The bench RSU trigger emits ~58-byte SPATEMs
// and is unaffected, but real road RSUs measured 555 bytes median and 1243 max (2026-03-18 drive,
// 79k messages), i.e. roughly 70% would be dropped. Raising the cap is deliberately deferred: it
// also requires enlarging RX_FRAME_MAX_LEN and moving rx_item_t off the WiFi callback stack,
// which at that size would overflow it.
//
// ---- What is NOT handled ------------------------------------------------------------------- // ---- What is NOT handled -------------------------------------------------------------------
// Secured packets (GN Basic Header NextHeader=2, i.e. ETSI TS 103 097 signed messages). The // Secured packets (GN Basic Header NextHeader=2, i.e. ETSI TS 103 097 signed messages). The
// units on this bench run with ItsGnSecurity=0 so everything observed is unsecured; a secured // units on this bench run with ItsGnSecurity=0 so everything observed is unsecured; a secured