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) ────────────────────────────────
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)
}
@@ -16,11 +16,13 @@ import com.hawhamburg.micr0bu.data.transport.UsbSerialState
import com.hawhamburg.micr0bu.data.transport.UsbSerialTransport
import com.hawhamburg.micr0bu.domain.asn1.DenmUperCodec
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.CamParser
import com.hawhamburg.micr0bu.domain.cam.ObuGnssParser
import com.hawhamburg.micr0bu.domain.cam.StationType
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.UseCaseDetectionEngine
import com.hawhamburg.micr0bu.domain.usecase.UseCaseType
@@ -126,6 +128,14 @@ class CamUseCaseRepository @Inject constructor(
*/
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)
/**
* 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) {
BtpPort.CAM -> handleCamFromSerial(v2x)
BtpPort.DENM -> handleDenmFromSerial(v2x)
BtpPort.SPATEM -> handleSpatFromSerial(v2x)
// The firmware only forwards ports it was told to accept, so anything else
// means the two sides have drifted out of sync.
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} " +
"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
// 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)
_processedCam.tryEmit(cam)
}
@@ -347,6 +369,36 @@ class CamUseCaseRepository @Inject constructor(
_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 =
take(limit).joinToString(" ") { "%02x".format(it) } + if (size > limit) " ..." else ""
}
@@ -99,6 +99,13 @@ object Crc16CcittFalse {
object BtpPort {
const val CAM = 2001
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 {
/** 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. */
private const val ENCODE_BUFFER_BYTES = 96
@@ -144,7 +147,17 @@ object CamUperCodec {
?.let { (it * 100.0).roundToInt().coerceIn(-32766, 32766) }
?: YAW_RATE_UNAVAILABLE
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
bw.putBits(0, 1)
@@ -217,9 +230,34 @@ object CamUperCodec {
br.getBits(20) // altitudeValue
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 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
// fields: accelerationControl, lanePosition, steeringWheelAngle, lateralAcceleration,
@@ -266,7 +304,7 @@ object CamUperCodec {
br.getBits(2) // curvatureCalculationMode root index
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
// 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 {
const val CYCLIST = 2
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
}
val geoJson = obj.optJSONObject("position")
val coords = geoJson?.optJSONArray("coordinates")
// A GeoJSON Point may be nested under "position", or `obj` may BE the Point itself - the
// 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) {
// GeoJSON coordinate order is [longitude, latitude, altitude?]
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
* 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
* `v2x-uca/output/json/denm` topic. The ESP32-C5 path receives none — the firmware's
* `gn_unwrap.c` accepts BTP-B destination port 2001 (CAM) only and drops port 2002 (DENM) before
* anything is forwarded over the serial link. See that file's header comment.
* **Availability:** both hardware paths. On the CiT One path DENM arrives as processed JSON on
* the Use Case API's `v2x-uca/output/json/denm` topic ([DenmParser]); on the ESP32-C5 path it is
* decoded from over-the-air GeoBroadcast traffic on BTP-B port 2002
* ([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(
/** 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:
* 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
* API doesn't supply it.
* same DENM, so this pair is what dedup must key on. Both transports supply it - the Use Case
* API as a `sequenceNumber` key - so the fallback below is for malformed payloads only.
*/
val sequenceNumber: Int? = null,
@@ -64,9 +66,10 @@ data class DenmEvent(
val timestamp: Long,
) {
/**
* 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
* expose a sequence number.
* Stable identity for map/list dedup. Prefers ETSI's actionID (`stationId` + `sequenceNumber`),
* which both transports carry; falls back to station + cause only when a payload omits the
* 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
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
* `v2x-uca/output/json/denm`.
*
* Same field-name tolerance approach as [com.hawhamburg.micr0bu.domain.cam.CamParser] — confirmed
* spellings first, plausible alternatives as fallbacks via [JsonFieldReader] — because the exact
* schema hasn't been pinned against real OBU payloads yet. 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.
* Field names follow `CI-CiT-MQTT_API_Documentation-v6-20250221.pdf` section 2.2.4 / listing 2.6,
* which is the contract for this topic; `DenmParserMqttTest` pins this parser to that worked
* example. Alternative spellings are still accepted via [JsonFieldReader] as fallbacks.
*
* 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 {
@@ -92,7 +96,10 @@ object DenmParser {
*/
private val CAUSE_CODE_BY_NAME = mapOf(
"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_AnimalOnTheRoad" to 11, "humanPresenceOnTheRoad" to 12,
"wrongWayDriving" to 14, "rescueAndRecoveryWorkInProgress" to 15,
@@ -116,6 +123,29 @@ object DenmParser {
*/
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? {
val obj = runCatching { JSONObject(json) }.getOrNull() ?: return null
@@ -127,9 +157,16 @@ object DenmParser {
?.let { JsonFieldReader.firstLatLon(it) }
?: 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 {
JsonFieldReader.firstLong(it, "stationId", "stationID", "station_id")
JsonFieldReader.firstLong(
it, "originatingStationId", "originatingStationID", "stationId", "stationID", "station_id",
)
}
?: return null
@@ -144,12 +181,25 @@ object DenmParser {
val subCauseCode = JsonFieldReader.firstInt(obj, "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(
stationId = stationId,
sequenceNumber = JsonFieldReader.firstInt(obj, "sequenceNumber", "sequence_number"),
latitude = lat,
longitude = lon,
causeCode = causeCode,
subCauseCode = subCauseCode,
stationType = stationType,
isTermination = isTermination,
detectionTimeMs = parseRfc3339(obj.optString("detectionTime").takeIf { it.isNotBlank() })
?: JsonFieldReader.firstLong(obj, "detectionTime", "detection_time"),
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.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -84,8 +85,12 @@ import java.text.SimpleDateFormat
import java.util.Date
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)
@@ -130,6 +135,7 @@ fun MqttTopicViewerScreen(
val camSendFailures by viewModel.camSendFailures.collectAsState()
val espLinkStatus by viewModel.espLinkStatus.collectAsState()
val denmEvents by viewModel.denmEvents.collectAsState()
val spatIntersections by viewModel.spatIntersections.collectAsState()
// Sort: sys/ topics first (heartbeat/health), then alphabetical
val sortedTopics = topicMessages.keys.sortedWith(
@@ -226,6 +232,7 @@ fun MqttTopicViewerScreen(
showCamPinger = isEsp32,
isEsp32 = isEsp32,
denmEvents = denmEvents,
spatIntersections = spatIntersections,
usbSerialState = usbSerialState,
camPingerActive = camPingerActive,
camPingerSentCount = camPingerSentCount,
@@ -265,6 +272,7 @@ private fun TopicListPane(
showCamPinger: Boolean = false,
isEsp32: Boolean = false,
denmEvents: List<com.hawhamburg.micr0bu.domain.denm.DenmEvent> = emptyList(),
spatIntersections: List<com.hawhamburg.micr0bu.domain.spat.SpatIntersection> = emptyList(),
usbSerialState: UsbSerialState = UsbSerialState.DISCONNECTED,
camPingerActive: Boolean = false,
camPingerSentCount: Int = 0,
@@ -321,30 +329,41 @@ private fun TopicListPane(
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f))
}
// ── List / Map toggle — the raw topic list stays available either way (Section 13
// asks for the map "in addition to", not instead of, the topic list). ──────────────
// ── List / Topics / Map toggle ────────────────────────────────────────────────────
// 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(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedButton(
onClick = { viewMode = TopicViewMode.LIST },
colors = ButtonDefaults.outlinedButtonColors(
containerColor = if (viewMode == TopicViewMode.LIST) MaterialTheme.colorScheme.primaryContainer else Color.Transparent,
contentColor = if (viewMode == TopicViewMode.LIST) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
),
) { Text(stringResource(R.string.mqtt_view_list)) }
OutlinedButton(
onClick = { viewMode = TopicViewMode.MAP },
colors = ButtonDefaults.outlinedButtonColors(
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)) }
ViewModeButton(
label = stringResource(R.string.mqtt_view_list),
selected = viewMode == TopicViewMode.LIST,
) { viewMode = TopicViewMode.LIST }
if (!isEsp32) {
ViewModeButton(
label = stringResource(R.string.mqtt_view_topics),
selected = viewMode == TopicViewMode.TOPICS,
) { viewMode = TopicViewMode.TOPICS }
}
ViewModeButton(
label = stringResource(R.string.mqtt_view_map),
selected = viewMode == TopicViewMode.MAP,
) { viewMode = TopicViewMode.MAP }
}
// ── Topic rows / received CAMs / live map ─────────────────────────────
if (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(
own = ownCamPosition,
remotes = remoteCamPositions,
@@ -352,15 +371,13 @@ private fun TopicListPane(
denms = denmEvents,
modifier = Modifier.fillMaxSize(),
)
} else if (isEsp32) {
// 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.
} else if (shownMode == TopicViewMode.LIST) {
ReceivedCamPane(
own = ownCamPosition,
remotes = remoteCamPositions,
alerts = useCaseAlerts,
denms = denmEvents,
spats = spatIntersections,
modifier = Modifier.fillMaxSize(),
)
} else if (topics.isEmpty()) {
@@ -421,9 +438,10 @@ private fun ReceivedCamPane(
remotes: Map<Long, com.hawhamburg.micr0bu.domain.cam.Cam>,
alerts: List<UseCaseAlert>,
denms: List<com.hawhamburg.micr0bu.domain.denm.DenmEvent>,
spats: List<com.hawhamburg.micr0bu.domain.spat.SpatIntersection>,
modifier: Modifier = Modifier,
) {
if (remotes.isEmpty() && denms.isEmpty()) {
if (remotes.isEmpty() && denms.isEmpty() && spats.isEmpty()) {
Box(modifier = modifier, contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
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 {
PaneSectionHeader(
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
private fun PaneSectionHeader(text: String) {
Column {
@@ -17,13 +17,17 @@ import com.hawhamburg.micr0bu.data.transport.UsbSerialState
import com.hawhamburg.micr0bu.data.transport.UsbSerialTransport
import com.hawhamburg.micr0bu.domain.denm.DenmEvent
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.usecase.UseCaseAlert
import com.hawhamburg.micr0bu.domain.usecase.UseCaseType
import com.hawhamburg.micr0bu.service.CamPinger
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.runningFold
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@@ -155,17 +159,77 @@ class MqttViewModel @Inject constructor(
camUseCaseRepository.airDenm
.runningFold(emptyMap<String, DenmEvent>()) { acc, denm -> acc + (denm.dedupKey to denm) }
.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)
.filterNot { it.isTermination } // the hazard is over - stop drawing it
.associateBy { it.dedupKey } // last write wins = most recent per hazard
.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 }
}.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 {
/** Use Case API topic carrying received DENMs (CiT One path only). */
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 ─────────────────────────────────────────────────────
+8 -1
View File
@@ -183,6 +183,7 @@
<string name="mqtt_auto_scroll">Automatisch scrollen</string>
<string name="mqtt_no_topics">Noch keine Nachrichten</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_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>
@@ -207,7 +208,7 @@
<!-- 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_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_kinematics">%1$.1f km/h · Kurs %2$.0f°</string>
<string name="v2x_cam_rx_distance">%1$.0f m</string>
@@ -293,4 +294,10 @@
<!-- Phase A: Trip Review screen -->
<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>
+8 -1
View File
@@ -184,6 +184,7 @@
<string name="mqtt_auto_scroll">Auto-scroll to latest</string>
<string name="mqtt_no_topics">No messages yet</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_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>
@@ -208,7 +209,7 @@
<!-- 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_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_kinematics">%1$.1f km/h · heading %2$.0f°</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_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 -->
<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>
@@ -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"
hilt = "2.56.2"
junit = "4.13.2"
json = "20240303"
hiltNavigationCompose = "1.2.0"
datastore = "1.1.1"
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" }
usb-serial-android = { group = "com.github.mik3y", name = "usb-serial-for-android", version.ref = "usbSerial" }
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" }
[plugins]
+6 -1
View File
@@ -33,6 +33,10 @@
#define BTP_DEST_PORT_CAM (2001) // ETSI TS 103 248
#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};
@@ -138,7 +142,8 @@ bool gn_unwrap_its(const uint8_t *frame, int frame_len, gn_rx_t *out)
return false;
}
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;
}
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.
//
// ---- 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
// 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).
//
// 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 -------------------------------------------------------------------
// 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