diff --git a/05-obu-bench-test-2026-08-25.md b/05-obu-bench-test-2026-08-25.md new file mode 100644 index 0000000..b332b57 --- /dev/null +++ b/05-obu-bench-test-2026-08-25.md @@ -0,0 +1,244 @@ +# ESP32-C5 OBU firmware — bench test against live ITS-G5 traffic + +**Date:** 2026-08-25 +**Firmware:** `obu-firmware` @ commit `b2b57fa`, flashed to COM3 (CH343 UART bridge, 921600 baud) +**App:** `app-debug.apk`, installed 14:33:45, Pixel 9 Pro on the C5's native USB-C port +**Verdict:** the receive path works against all three live message types with zero failures. +Two blocking items remain before real-world use, both known and both outside what this bench can +exercise. See [Readiness](#readiness). + +## Test environment + +| role | device | notes | +|---|---|---| +| Device under test | ESP32-C5 OBU, COM3 | our firmware; phone attached to its native USB port | +| Traffic source | RSU, broker `192.168.3.202` | transmits SPATEM + CAM | +| Traffic source | CiT One OBU, broker `192.168.3.201` | transmits DENM + CAM | +| Independent witness | both brokers' `v2x/rx/*` topics | each hears the *other* device | + +Both brokers publish raw UPER (protobuf-wrapped, field 3), so their counts are directly comparable +with what the OBU forwarded. That is what makes this a cross-check rather than a self-report: the +OBU's output is measured against two receivers that share none of its code. + +Station IDs observed today (`3983312873` RSU, `3257224191` CiT One) differ from those seen this +morning (`968482441`, `2880458775`). **Station IDs rotate**, so nothing may treat one as a durable +identity for a physical unit. + +## T1 — Message type coverage and rate + +305-second continuous capture, phone logcat. + +| type | frames | rate | stations | +|---|---|---|---| +| CAM | 1244 | 4.08/s | 2 | +| SPATEM | 978 | 3.20/s | 1 | +| DENM | 646 | 2.12/s | 3 | +| **total** | **2868** | **9.40/s** | | + +Per station, with received signal strength: + +| type | station | frames | RSSI min/median/max | +|---|---|---|---| +| SPATEM | 3983312873 | 978 | −65 / −60 / −48 dBm | +| CAM | 3257224191 | 633 | −58 / −52 / −48 dBm | +| DENM | 3257224191 | 616 | −65 / −58 / −48 dBm | +| CAM | 3983312873 | 611 | −65 / −62 / −60 dBm | +| DENM | 2908440021 | 20 | −64 / −61 / −53 dBm | +| DENM | 1220851972 | 10 | −63 / −52 / −49 dBm | + +All three types decoded concurrently, from both transmitters plus two additional DENM sources that +happened to be on air. **PASS.** + +## T2 — Decode integrity + +Over the same window: + +| check | result | +|---|---| +| Decode failures (CAM / DENM / SPATEM) | **0** | +| Unexpected BTP ports from firmware | **0** | +| USB I/O errors | **0** | +| Device detach events | **0** | +| Fatal exceptions | **0** | + +Zero decode failures across 2868 frames. Since the decoders return null rather than guessing +whenever an extension bit or unsupported optional appears, a zero here means every frame matched +the bit layouts exactly — not that failures were being swallowed. **PASS.** + +## T3 — Cross-check against independent receivers + +60-second simultaneous capture from both brokers, counting the same UPER the OBU sees. + +| stream | independent witness | our OBU | +|---|---|---| +| RSU SPATEM (3983312873) | 2.00/s | 3.20/s | +| RSU CAM (3983312873) | 2.00/s | 2.00/s | +| CiT One CAM (3257224191) | 2.05/s | 2.08/s | +| CiT One DENM (3257224191) | 1.00/s | 2.02/s | + +CAM matches on both transmitters. SPATEM and DENM read high — explained in T4, not a defect. + +## T4 — Duplicate transmission (finding, not a fault) + +The DENM and SPATEM discrepancies above are **real duplicate transmissions**, not double-counting +in our firmware. Inter-arrival analysis of the captured stream: + +| type | gaps < 150 ms | median of those | identical content, different RSSI | +|---|---|---|---| +| SPATEM | 50% (485/977) | 7 ms | **480 / 485** | +| DENM | 50% (308/615) | 5 ms | **282 / 308** | +| CAM | 2% (11/632) | 100 ms | 1 / 11 | + +Each SPATEM and DENM goes out **twice, ~5–7 ms apart, with different RSSI** — two antennas. CAM is +sent once. This also reconciles the broker figures: the CiT One reports one copy on `v2x/rx/*` and +the other on `v2x/rx-red/*` ("red" = redundant), and only the primary was counted. + +Our firmware is a promiscuous receiver, so forwarding both copies is correct behaviour. The app +deduplicates downstream — DENM on ETSI actionID, SPATEM on intersection key — so the UI shows one +entry per event. The cost is serial bandwidth: **38% of the bytes carried are duplicate copies.** + +## T5 — Serial link load + +Measured over the 305 s window, using UPER sizes taken from the brokers: + +- **9.40 frames/s, ~1550 B/s (12.4 kbit/s)** +- Largest frame: DENM at 402 B UPER → 416 B payload (81% of the 512 B cap, 96 B headroom) +- On the wire that frame is 423 B, 41% of the 1024 B TX ring +- Suppressing duplicate copies would cut this to ~961 B/s (7.7 kbit/s) + +No frame in this session exceeded the payload cap. + +## T6 — Firmware drop counters + +Read directly off the app at 14:45, after the capture: + +``` +ESP32: tx fail 0 · oversize 0 · crc err 0 +``` + +These are free-running totals **since firmware boot**, so all three being zero covers the whole +session, not just the test window — no oversize drops, no `esp_wifi_80211_tx` failures, no CRC +errors at any point since the C5 was flashed. **PASS.** + +The app also now logs these counters whenever one changes (added for this test; they previously +reached only the UI), so a future bench run captured through logcat records drops as they happen. + +## T7 — End-to-end UI verification + +Screenshot at 14:45 confirms the full chain reaches the display: + +| element | shown | +|---|---| +| Link state | CONNECTED | +| Hazard (DENM) | `stationaryVehicle · station 3257224191`, 30 m, 1000 m radius, −63 dBm | +| Signals (SPATEM) | `Intersection -1/23 · station 3983312873`, SG1 red / SG2 amber, −63 dBm | +| Station (CAM) | `Station 3257224191 · Car`, 1.4 km/h, heading 19°, 25 m, −50 dBm | + +Signal-group colouring, the DENM relevance radius from the GeoNetworking header, and per-station +RSSI all render correctly. + +### Finding: the RSU's CAM decodes but is never displayed + +The station list reads **"1 station(s) in range"** — only the CiT One (`3257224191`). The RSU +(`3983312873`) is absent, despite **611 of its CAMs decoding successfully** during the capture. + +Cause: RSU CAMs are deliberately excluded from `UseCaseDetectionEngine` (a permanently stationary +station at a fixed point otherwise trips the stopped-vehicle use case continuously) — but +`remoteCamPositions`, which feeds both the station list and the map, is populated *by that engine*. +So the exclusion removes them from the display as well as from detection. + +The RSU is not entirely invisible: it appears in the SPAT section as the intersection's station. But +its CAM-reported position is dropped on the floor. This is a defect introduced with the RSU CAM +decode fix earlier today, not a firmware problem — the firmware forwarded all 611 correctly. + +**Fixed and re-verified the same session.** RSU CAMs are now tracked in a separate +`rsuStations` flow in the repository, merged with the engine's road users for display only, with a +15 s staleness window and a clear on link-down. Screenshot at 15:01 confirms: + +``` +2 station(s) in range - latest CAM per station + Station 2199514753 · Car 0.8 km/h · heading 192° 28 m -52 dBm + Station 440624502 · Roadside unit roadside unit - no kinematics reported + 46 m -61 dBm +``` + +The kinematics line is suppressed for RSUs: their CAM carries none, so the zeroes in the model are +placeholders and printing "0.0 km/h · heading 0" would assert a stationary vehicle facing north. +The same station also drives the SPAT row, so the two views agree. + +## T8 — Station ID rotation + +Station IDs rotated **twice within one session**: + +| time | RSU | CiT One | +|---|---|---| +| ~09:00 | 968482441 | 2880458775 | +| 14:45 | 3983312873 | 3257224191 | +| 15:01 | 440624502 | 2199514753 | + +That is a rotation inside 16 minutes. Consequences for the app, none of them currently handled: + +- **DENM dedup keys on ETSI actionID**, which contains the originating station ID. A hazard that + outlives a rotation will appear as a second, independent pin rather than an update of the first. + Both then persist until the 60 s TTL expires them. +- **The station list and map key on station ID**, so a rotation shows the same physical vehicle + twice for up to the 15 s window. +- **SPATEM is unaffected**, because it dedups on the intersection reference (`region/id`), which is + a property of the junction rather than the sender. That is the right key and it survives rotation. + +Nothing here is a firmware issue, and pseudonym rotation is the intended privacy behaviour of the +transmitters. But any future logic that assumes a station ID identifies a physical unit over time +will be wrong. + +## Readiness + +### Working + +- All three received message types decode correctly from live over-the-air traffic +- Concurrent multi-station, multi-type reception with no interference between streams +- Sustained 5-minute run with no link drop, no I/O error, no crash +- RSSI plausible and discriminating between transmitters (−48 to −65 dBm at bench distance) +- No frame exceeded the serial payload cap under this traffic mix + +### Blocking for real-world use + +1. **Serial payload cap (512 B).** The bench RSU sends 58-byte SPATEMs, but the 2026-03-18 drive + measured real road RSUs at 555 B median and 1243 B max — **roughly 70% would be dropped as + oversize**. Raising `SERIAL_LINK_MAX_PAYLOAD` and `RX_FRAME_MAX_LEN` to ~1536 is required, and + forces item 2. +2. **`rx_item_t` on the WiFi driver's callback stack** (`main.c`). At the current 800 B it is a + latent risk on a ~3.5 KB stack; at 1536 B it is a guaranteed overflow. Must be moved off the + stack as part of the same change. + +### Defect found and fixed during this test + +- **RSU CAM positions were decoded but never displayed** (see T7). App-side; the firmware forwarded + all 611 correctly. Fixed and re-verified in the same session. + +### Open, found by this test + +- **Station ID rotation** (see T8) fragments DENM and CAM identity across a rotation. Not yet + handled. + +### Untested here + +- **Link recovery** — unplug/replug and USB permission re-grant were not exercised; needs physical + intervention. +- **Sustained load at road rates.** This bench ran at 9.4 frames/s. The drive data implies 24–32 + frames/s with frames 3× larger, where the TX-mutex interaction (400 ms worst-case hold vs the + 1 Hz heartbeat and the phone's 3-beat dead-link timeout) becomes the thing to watch. +- **The link's actual ceiling**, which has never been saturated and so is unmeasured. +- **MAPEM** — nothing on air is transmitting it; no decoder written. +- **Secured messages** — 75 frames with GN `NextHeader=2` appeared in earlier pcaps; these are + rejected by design. The bench runs with `ItsGnSecurity = 0`. + +### Recommendation + +Ready for continued bench and short-range field work as it stands. **Not ready for a road drive +past real RSUs** until items 1 and 2 land, because the failure there is silent: oversize SPATEMs are +counted and dropped, so the symptom is "the intersection never appears" rather than an error. + +## Reproducing + +Capture: `adb logcat -d` filtered on `CamUseCaseRepo` while subscribed to `v2x/rx/#` on both +brokers. Decode cross-checks use `asn1tools` with the modules in `asn1/` — see `asn1/README.md`. diff --git a/app/src/main/java/com/hawhamburg/micr0bu/MainActivity.kt b/app/src/main/java/com/hawhamburg/micr0bu/MainActivity.kt index 77d8ce9..48d4d0b 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/MainActivity.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/MainActivity.kt @@ -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) { diff --git a/app/src/main/java/com/hawhamburg/micr0bu/data/cam/CamUseCaseRepository.kt b/app/src/main/java/com/hawhamburg/micr0bu/data/cam/CamUseCaseRepository.kt index cb972ad..bc70ec6 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/data/cam/CamUseCaseRepository.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/data/cam/CamUseCaseRepository.kt @@ -128,6 +128,18 @@ class CamUseCaseRepository @Inject constructor( */ val processedCam: SharedFlow = _processedCam.asSharedFlow() + private val _rsuStations = MutableStateFlow>(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> = _rsuStations.asStateFlow() + private val _airSpat = MutableSharedFlow(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 } diff --git a/app/src/main/java/com/hawhamburg/micr0bu/data/transport/UsbSerialTransport.kt b/app/src/main/java/com/hawhamburg/micr0bu/data/transport/UsbSerialTransport.kt index b10cd63..042b3bc 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/data/transport/UsbSerialTransport.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/data/transport/UsbSerialTransport.kt @@ -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) } diff --git a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/MqttTopicViewerScreen.kt b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/MqttTopicViewerScreen.kt index 1a5e588..d414ebd 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/MqttTopicViewerScreen.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/MqttTopicViewerScreen.kt @@ -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, diff --git a/app/src/main/java/com/hawhamburg/micr0bu/viewmodel/MqttViewModel.kt b/app/src/main/java/com/hawhamburg/micr0bu/viewmodel/MqttViewModel.kt index 0b4f0e3..2e7b9ce 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/viewmodel/MqttViewModel.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/viewmodel/MqttViewModel.kt @@ -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> = 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> = 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) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 92f6e99..0e65240 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -214,6 +214,8 @@ %1$.0f m - m %1$d dBm + Keine CAMs empfangen + Straßenseiteneinheit - keine Kinematik Gefahr: Ursache %1$d/%2$d (Station %3$d) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 326a7dd..f249648 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -216,6 +216,7 @@ - m %1$d dBm No CAMs received + roadside unit - no kinematics reported %1$d active hazard(s) - latest DENM per event