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:
@@ -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 ─────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user