# 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`.