Show roadside units in the station list; log ESP32 drop counters
Bench test on 2026-08-25 against live RSU and CiT One traffic found that 611 RSU CAMs decoded correctly and none of them were ever displayed. Excluding RSUs from UseCaseDetectionEngine - correct, since a permanently stationary station at a fixed point trips the stopped-vehicle use case for as long as it is in range - also removed them from the map and station list, because remotePositions is the engine's own map. RSUs are now tracked in a separate rsuStations flow and merged with the engine's road users for display only. Expiry is clock-driven for the same reason as hazards and signals: an RSU going out of range simply stops transmitting, and no further emission would arrive to recompute the list. Cleared on link-down alongside engine.reset(), so a stale RSU cannot outlive an unplug. The kinematics line is suppressed for them. An RSU's CAM uses rsuContainerHighFrequency, which carries no kinematics at all, so the zeroes in the model are placeholders - printing "0.0 km/h - heading 0" would assert a stationary vehicle pointing due north. Also logs the ESP32's STATUS heartbeat counters whenever one changes. They previously reached only the CAM Pinger card, so a bench run captured through logcat had no record of whether the firmware dropped anything. Logged on change rather than per beat: the interesting event is a drop appearing, and a once-per-second line would bury it. Test report in 05-obu-bench-test-2026-08-25.md.
This commit is contained in:
@@ -324,7 +324,7 @@ class MainActivity : AppCompatActivity() {
|
||||
// hiltViewModel() — that would create a separate instance scoped to
|
||||
// this NavBackStackEntry, whose onCleared() (fired the moment you
|
||||
// navigate away) would disconnect the shared UsbSerialTransport out
|
||||
// from under every other screen still using it.
|
||||
// from under every other screen still using.
|
||||
ConnectionSetupScreen(viewModel = mqttViewModel)
|
||||
}
|
||||
composable(Screen.Map.route) {
|
||||
|
||||
@@ -128,6 +128,18 @@ class CamUseCaseRepository @Inject constructor(
|
||||
*/
|
||||
val processedCam: SharedFlow<Cam> = _processedCam.asSharedFlow()
|
||||
|
||||
private val _rsuStations = MutableStateFlow<Map<Long, Cam>>(emptyMap())
|
||||
/**
|
||||
* Latest CAM per roadside unit heard over the air.
|
||||
*
|
||||
* Separate from [remotePositions] because an RSU is infrastructure, not a road user: it has no
|
||||
* kinematics, sits at a fixed point forever, and would trip the stopped-vehicle and
|
||||
* intersection-movement use cases for as long as it is in range. It still belongs on the map
|
||||
* and in the station list, which is what this flow is for. Consumers should apply their own
|
||||
* staleness window - nothing prunes this map except a link drop.
|
||||
*/
|
||||
val rsuStations: StateFlow<Map<Long, Cam>> = _rsuStations.asStateFlow()
|
||||
|
||||
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
|
||||
@@ -217,6 +229,7 @@ class CamUseCaseRepository @Inject constructor(
|
||||
// resetting here would wipe perfectly good MQTT-derived state.
|
||||
if (currentHardware == ObuHardware.ESP32_C5 && state != UsbSerialState.CONNECTED) {
|
||||
engine.reset()
|
||||
_rsuStations.value = emptyMap()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -335,6 +348,12 @@ class CamUseCaseRepository @Inject constructor(
|
||||
// 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) {
|
||||
// Tracked here rather than in the engine, so an RSU still shows on the map and in the
|
||||
// station list without being evaluated for alerts. Keeping it out of the engine
|
||||
// entirely - as the first version of this did - also removed it from the display,
|
||||
// because remotePositions is the engine's map: 611 RSU CAMs decoded during the
|
||||
// 2026-08-25 bench run and none of them were ever shown.
|
||||
_rsuStations.value = _rsuStations.value + (cam.stationId to cam)
|
||||
_processedCam.tryEmit(cam)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -318,7 +318,26 @@ class UsbSerialTransport @Inject constructor(
|
||||
val frames = decoder.onBytes(data)
|
||||
frames.forEach { frame ->
|
||||
if (frame.type == SerialFrameType.STATUS) {
|
||||
EspLinkStatus.parse(frame.payload)?.let { _linkStatus.value = it }
|
||||
EspLinkStatus.parse(frame.payload)?.let { status ->
|
||||
// Logged only when a counter moves, not on every 1 Hz beat: the
|
||||
// interesting event is a drop appearing, and a per-second line
|
||||
// would bury it. Without this the firmware's own drop counters are
|
||||
// visible only on the CAM Pinger card, so a bench run captured
|
||||
// through logcat has no record of whether anything was dropped.
|
||||
val prev = _linkStatus.value
|
||||
if (prev == null ||
|
||||
prev.oversizeDrops != status.oversizeDrops ||
|
||||
prev.txFailures != status.txFailures ||
|
||||
prev.rxCrcErrors != status.rxCrcErrors ||
|
||||
prev.status != status.status
|
||||
) {
|
||||
Log.i(TAG, "ESP32 counters: status=${status.status} " +
|
||||
"oversizeDrops=${status.oversizeDrops} " +
|
||||
"txFailures=${status.txFailures} " +
|
||||
"rxCrcErrors=${status.rxCrcErrors}")
|
||||
}
|
||||
_linkStatus.value = status
|
||||
}
|
||||
}
|
||||
_incomingFrames.tryEmit(frame)
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@ import com.hawhamburg.micr0bu.data.transport.EspLinkStatus
|
||||
import com.hawhamburg.micr0bu.data.transport.ObuHardware
|
||||
import com.hawhamburg.micr0bu.data.transport.UsbSerialState
|
||||
import com.hawhamburg.micr0bu.domain.cam.CamParser
|
||||
import com.hawhamburg.micr0bu.domain.cam.StationType
|
||||
import com.hawhamburg.micr0bu.domain.denm.DenmParser
|
||||
import com.hawhamburg.micr0bu.domain.denm.DenmUseCase
|
||||
import com.hawhamburg.micr0bu.domain.usecase.AlertLevel
|
||||
@@ -127,7 +128,8 @@ fun MqttTopicViewerScreen(
|
||||
val ownStationId by viewModel.ownStationId.collectAsState()
|
||||
val obuHardware by viewModel.obuHardware.collectAsState()
|
||||
val ownCamPosition by viewModel.ownCamPosition.collectAsState()
|
||||
val remoteCamPositions by viewModel.remoteCamPositions.collectAsState()
|
||||
// Engine road users PLUS roadside units - the engine deliberately does not track RSUs.
|
||||
val remoteCamPositions by viewModel.stationsInRange.collectAsState()
|
||||
val usbSerialState by viewModel.usbSerialState.collectAsState()
|
||||
val camPingerActive by viewModel.camPingerActive.collectAsState()
|
||||
val camPingerSentCount by viewModel.camPingerSentCount.collectAsState()
|
||||
@@ -710,7 +712,12 @@ private fun ReceivedCamRow(
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
text = stringResource(
|
||||
// An RSU's CAM carries no kinematics at all (rsuContainerHighFrequency), so the
|
||||
// zeroes in the model are placeholders, not measurements. Printing "0.0 km/h -
|
||||
// heading 0" would assert a stationary vehicle pointing due north.
|
||||
text = if (cam.stationType == StationType.ROAD_SIDE_UNIT) {
|
||||
stringResource(R.string.v2x_cam_rx_no_kinematics)
|
||||
} else stringResource(
|
||||
R.string.v2x_cam_rx_kinematics,
|
||||
cam.speedMps * 3.6,
|
||||
cam.headingDeg,
|
||||
|
||||
@@ -230,6 +230,10 @@ class MqttViewModel @Inject constructor(
|
||||
*/
|
||||
const val SPAT_TTL_MS = 15_000L
|
||||
const val SPAT_EXPIRY_TICK_MS = 2_000L
|
||||
|
||||
/** RSU CAMs arrive at ~2 Hz, same as any other station, so the same window applies. */
|
||||
const val RSU_TTL_MS = 15_000L
|
||||
const val RSU_EXPIRY_TICK_MS = 2_000L
|
||||
}
|
||||
|
||||
// ── DENM transmission ─────────────────────────────────────────────────────
|
||||
@@ -262,6 +266,24 @@ class MqttViewModel @Inject constructor(
|
||||
/** Latest known CAM per tracked remote road user, for the live map view (Section 13). */
|
||||
val remoteCamPositions: StateFlow<Map<Long, com.hawhamburg.micr0bu.domain.cam.Cam>> = camUseCaseRepository.remotePositions
|
||||
|
||||
/**
|
||||
* Every station to draw: road users from the detection engine, plus roadside units, which are
|
||||
* tracked outside it (see [com.hawhamburg.micr0bu.data.cam.CamUseCaseRepository.rsuStations]).
|
||||
*
|
||||
* The engine prunes its own stale entries; nothing prunes the RSU map, so the staleness window
|
||||
* is applied here. As with hazards and signals, expiry has to be clock-driven - an RSU that
|
||||
* goes out of range simply stops transmitting, and no further emission would arrive to
|
||||
* recompute the list.
|
||||
*/
|
||||
val stationsInRange: StateFlow<Map<Long, com.hawhamburg.micr0bu.domain.cam.Cam>> = combine(
|
||||
camUseCaseRepository.remotePositions,
|
||||
camUseCaseRepository.rsuStations,
|
||||
tickerFlow(RSU_EXPIRY_TICK_MS),
|
||||
) { roadUsers, rsus, _ ->
|
||||
val now = System.currentTimeMillis()
|
||||
roadUsers + rsus.filterValues { now - it.timestamp <= RSU_TTL_MS }
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, emptyMap())
|
||||
|
||||
/** True if [stationId] is the ego OBU's own — used for OWN/REMOTE badges in the raw message list. */
|
||||
fun isOwnStationId(stationId: Long): Boolean = camUseCaseRepository.isOwnStationId(stationId)
|
||||
|
||||
|
||||
@@ -214,6 +214,8 @@
|
||||
<string name="v2x_cam_rx_distance">%1$.0f m</string>
|
||||
<string name="v2x_cam_rx_distance_unknown">- m</string>
|
||||
<string name="v2x_cam_rx_rssi">%1$d dBm</string>
|
||||
<string name="v2x_cam_rx_none_stations">Keine CAMs empfangen</string>
|
||||
<string name="v2x_cam_rx_no_kinematics">Straßenseiteneinheit - keine Kinematik</string>
|
||||
|
||||
<!-- DENM-Kartenmarker -->
|
||||
<string name="v2x_map_denm_labeled">Gefahr: Ursache %1$d/%2$d (Station %3$d)</string>
|
||||
|
||||
@@ -216,6 +216,7 @@
|
||||
<string name="v2x_cam_rx_distance_unknown">- m</string>
|
||||
<string name="v2x_cam_rx_rssi">%1$d dBm</string>
|
||||
<string name="v2x_cam_rx_none_stations">No CAMs received</string>
|
||||
<string name="v2x_cam_rx_no_kinematics">roadside unit - no kinematics reported</string>
|
||||
|
||||
<!-- Received-DENM list (ESP32-C5 path) -->
|
||||
<string name="v2x_denm_rx_count">%1$d active hazard(s) - latest DENM per event</string>
|
||||
|
||||
Reference in New Issue
Block a user