Decode raw v2x/rx on the CiT One path, and stop tracking our own CAM pings
The Use Case app's v2x-uca/output/json topics are a rate-limited and lossy view: traffic the OBU's radio actually heard, the ESP32's CAM pinger among it, never reached the app. The raw v2x/rx topics carry everything, as RecvV2XMessage protobuf with the ITS-G5 PDU in one bytes field (CI-CiT MQTT API section 2.4). RecvV2xMessage is a minimal protobuf wire-format reader for the three fields needed: btpHeader type and destination port, the GeoNetworking destination-area radius, and the payload. Hand-written for the same reason the ASN.1 codecs are, rather than adding protoc and the protobuf Gradle plugin and vendoring a third-party .proto into this repository. Field numbers are pinned by a byte fixture written out by hand from the encoding rules, not generated by our own encoder. Raw payloads now travel as bytes rather than String. The previous UTF-8 round trip replaced every byte that is not valid UTF-8, leaving a payload that still looked plausible in a log and decoded to nothing. CAM, DENM and SPATEM from both transports now meet in shared handlers, so everything downstream is transport-agnostic. SPATEM works on the CiT One path for the first time, and DENM gains its relevance radius there. Where both sources describe the same event the decoded one wins: remote CAMs from the processed topic are suppressed while the raw topic is live, and DENMs dedup on ETSI's actionID with the decoded list last. The processed topics stay subscribed as a fallback for an OBU whose configuration does not publish the raw ones. Two defects found while testing this: CamPinger transmits under a fixed bench station id, deliberately distinct from the persisted one, but the self-heard filter only knew the persisted id. Every ping therefore came back through the ESP32's promiscuous receive as a remote road user sitting exactly on top of the ego position, moving at the ego's own speed and heading, and was handed to the detection engine as a collision partner for itself. The rule now lives in OwnStationIds, covers both ids, and has tests, so a third transmit path cannot reintroduce the same gap quietly. Self-heard frames are now counted and reported on the pinger card instead of being discarded. That round trip is the only direct evidence the serial link, the ESP32's transmit path and its receive path all work, which is what the bench pinger exists to demonstrate. Also: the stationType warning banner no longer shows in ESP32-C5 mode. It reads a value from the CiT One's obu_gnss topic, which that hardware never publishes, so it stayed on screen reporting on an OBU that was no longer in use.
This commit is contained in:
@@ -101,7 +101,24 @@ class MqttViewModel @Inject constructor(
|
||||
/** False while the pinger runs without a GNSS fix — it has no position to build a CAM from. */
|
||||
val camPingerHasFix: StateFlow<Boolean> = camPinger.hasFix
|
||||
|
||||
fun startCamPinger() = camPinger.start()
|
||||
/**
|
||||
* Own transmissions heard back off the air, null until one is.
|
||||
*
|
||||
* This is the pinger's actual proof of life. [camPingerSentCount] only says frames were
|
||||
* handed to the ESP32; this says they went out and came back, which is the round trip the
|
||||
* bench test is there to demonstrate. See
|
||||
* [com.hawhamburg.micr0bu.domain.cam.OwnTxLoopback].
|
||||
*/
|
||||
val ownTxLoopback: StateFlow<com.hawhamburg.micr0bu.domain.cam.OwnTxLoopback?> =
|
||||
camUseCaseRepository.ownTxLoopback
|
||||
|
||||
fun startCamPinger() {
|
||||
// Reset first, so the tally counts this run rather than accumulating across runs and
|
||||
// making the comparison against sent count meaningless.
|
||||
camUseCaseRepository.resetOwnTxLoopback()
|
||||
camPinger.start()
|
||||
}
|
||||
|
||||
fun stopCamPinger() = camPinger.stop()
|
||||
|
||||
// ── Prefs ─────────────────────────────────────────────────────────────────
|
||||
@@ -127,13 +144,27 @@ class MqttViewModel @Inject constructor(
|
||||
val obuStationType: StateFlow<Int?> = _obuStationType.asStateFlow()
|
||||
|
||||
/**
|
||||
* True when the OBU has reported a stationType other than 2 (cyclist).
|
||||
* True when the CiT One has reported a stationType other than 2 (cyclist).
|
||||
* Triggers a persistent warning banner — an incorrect stationType means this OBU will
|
||||
* not be detected as a VRU at equipped intersections.
|
||||
*
|
||||
* Suppressed in ESP32-C5 mode. The value behind it comes from the CiT One's
|
||||
* `v2x/rx/obu_gnss` topic, which the ESP32-C5 does not publish, so a warning raised before a
|
||||
* mode switch would otherwise stay on screen reporting on an OBU that is no longer in use.
|
||||
* There is nothing for it to warn about on that path either: the phone builds its own CAM
|
||||
* ([com.hawhamburg.micr0bu.domain.cam.PhoneCamBuilder]), which sets stationType to cyclist
|
||||
* locally rather than reading it back from an OBU.
|
||||
*
|
||||
* The underlying [obuStationType] is deliberately not cleared on the switch. It remains the
|
||||
* last thing that OBU actually said, and obu_gnss refreshes it at ~4 Hz on returning to the
|
||||
* CiT One path, so the warning re-evaluates against fresh data within a fraction of a second.
|
||||
*/
|
||||
val obuStationTypeWarning: StateFlow<Boolean> = _obuStationType
|
||||
.map { it != null && it != 2 }
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
|
||||
val obuStationTypeWarning: StateFlow<Boolean> = combine(
|
||||
_obuStationType,
|
||||
repo.obuHardware,
|
||||
) { stationType, hardware ->
|
||||
hardware == ObuHardware.CIT_ONE && stationType != null && stationType != 2
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, false)
|
||||
|
||||
// ── DENM reception (live map hazard pins) ─────────────────────────────────
|
||||
|
||||
@@ -141,11 +172,16 @@ class MqttViewModel @Inject constructor(
|
||||
* Hazards received from other stations, newest first, deduped by [DenmEvent.dedupKey] so a
|
||||
* repeating DENM about the same hazard stays one pin instead of stacking up.
|
||||
*
|
||||
* Two sources, merged: the CiT One path's `v2x-uca/output/json/denm` MQTT topic (parsed by
|
||||
* [DenmParser]), and the ESP32-C5 path's over-the-air DENMs (GeoBroadcast, BTP port 2002,
|
||||
* decoded by [com.hawhamburg.micr0bu.domain.asn1.DenmUperCodec]). Only one is ever active at a
|
||||
* time since the hardware selection decides the transport, so merging costs nothing and keeps
|
||||
* the UI transport-agnostic.
|
||||
* Two sources, merged: the CiT One Use Case app's `v2x-uca/output/json/denm` MQTT topic
|
||||
* (parsed by [DenmParser]), and UPER decoded by
|
||||
* [com.hawhamburg.micr0bu.domain.asn1.DenmUperCodec] from whichever raw path is live, the
|
||||
* ESP32-C5 serial link or the CiT One's `v2x/rx/denm` protobuf topic.
|
||||
*
|
||||
* Where both describe the same hazard, the decoded one wins. Both key on ETSI's actionID, so
|
||||
* the `associateBy` below collapses them to one entry, and the decoded list is concatenated
|
||||
* second so it is the one that survives. That is the intended preference: the Use Case app
|
||||
* rate-limits and drops messages, and reduces what it does publish to the fields it cared
|
||||
* about, so it can only ever be a lossier account of the same event.
|
||||
*
|
||||
* Events carrying `termination` are filtered out rather than shown — the hazard is over.
|
||||
*/
|
||||
@@ -156,7 +192,7 @@ class MqttViewModel @Inject constructor(
|
||||
},
|
||||
// Air DENMs accumulate here rather than being a snapshot: the serial path delivers one
|
||||
// event at a time, so runningFold keeps the set of hazards heard so far.
|
||||
camUseCaseRepository.airDenm
|
||||
camUseCaseRepository.decodedDenm
|
||||
.runningFold(emptyMap<String, DenmEvent>()) { acc, denm -> acc + (denm.dedupKey to denm) }
|
||||
.map { it.values.toList() },
|
||||
// Expiry has to be driven by a clock, not by arrivals. Both upstream flows only re-emit
|
||||
@@ -164,11 +200,11 @@ class MqttViewModel @Inject constructor(
|
||||
// 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, _ ->
|
||||
) { fromUseCaseApp, fromDecoder, _ ->
|
||||
val now = System.currentTimeMillis()
|
||||
(fromMqtt + fromAir)
|
||||
(fromUseCaseApp + fromDecoder)
|
||||
.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, so the decoded one is kept
|
||||
.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".
|
||||
@@ -179,15 +215,16 @@ class MqttViewModel @Inject constructor(
|
||||
/**
|
||||
* 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.
|
||||
* Both hardware paths: SPATEM arrives over the air on BTP port 2004 via the ESP32-C5 serial
|
||||
* link, or on the CiT One's `v2x/rx/spatem` protobuf topic. The CiT One's processed
|
||||
* `v2x-uca/output/json/spat` topic is not used, since the raw topic carries every repetition.
|
||||
*
|
||||
* 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
|
||||
camUseCaseRepository.decodedSpat
|
||||
.runningFold(emptyMap<String, SpatIntersection>()) { acc, spat ->
|
||||
acc + spat.intersections.associate { i ->
|
||||
i.key to SpatIntersection(i, spat.stationId, spat.rssiDbm, spat.timestamp)
|
||||
|
||||
Reference in New Issue
Block a user