Two documents, wiki-style so they import cleanly: short titled sections, tables over prose where the content is comparative, and cross-references between sections rather than a narrative that has to be read start to end. MicrOBU-User-Guide.docx is for riders. Features, setup for both hardware paths, what each screen shows, what the five alerts mean in plain language, troubleshooting. No ASN.1, no BTP ports, no bit widths anywhere in it. The alert descriptions and the link states are taken from values/strings.xml so the guide and the interface use the same words. MicrOBU-Technical-Documentation.docx is for supervisors and stakeholders. Architecture, the message path end to end, the serial protocol, the codecs and how they are verified, the full requirements matrix, the bench results, and the decisions. Section 15 is fourteen decisions written as chosen / alternative / reasoning / cost accepted, because the alternative is the part a supervisor asks about and it was previously recorded only in commit messages. Nothing is re-derived. Every measurement is cited from 05-obu-bench-test-2026-08-25.md, 04-transmit-setup.md, asn1/README.md or the commit history. Where a claim has no evidence it is marked as unverified rather than asserted: - 11.6 Phase A success criteria is stated as not met, in its own subsection. The bench proves reception; it cannot prove the use case with two moving stations because nothing on the bench moves. This is the central claim of the project and it needs a real ride. - The tx_custom.c bypass is described as the least defensible component in the system and load bearing, with the reverse-engineered struct layouts and the skipped sanity checking spelled out. - The 5900 MHz transmit story is marked a mitigation for a hypothesis, not a diagnosis, and the isolation test that would settle it is named as not run. - The detection thresholds are presented as untuned engineering estimates in both documents, since presenting them as validated is the easiest and most damaging overstatement available here. Twenty image placeholders, none of them filled. Each is a shaded block carrying a caption and a "Must show" line. For the three screenshots that exist the line names the bench session and section they came from; for the four diagrams that do not exist yet it is a full drawing spec, so the two hardware paths, the message path, the frame layout and the verification loop can be drawn from the document without re-reading the source. references.bib collects the standards as BibTeX for a later publication: EN 302 637-2/3, TS 103 301, SAE J2735, TS 102 894-2, EN 302 636-4-1 and -5-1, TS 103 248, TS 103 097, IEEE 802.11 OCB, plus the C2C-CC white paper and the vendored parser provenance. Also tracks two documents the new ones cite that had never been committed: docs/01-requirements-traceability.md and 04-transmit-setup.md. Section 18.3 lists them as repository sources, which would have been a dangling reference otherwise. Not included, deliberately: the two consider it PDFs cited in section 18.2. They are third-party vendor documentation and redistributing them is a licensing decision, not a documentation one. Still missing: the German user guide. values-de/strings.xml already fixes the terminology for it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
15 KiB
Step 4 - Build, flash, and validate the HLN-SV DENM transmitter
Radio-config fix (2026-07-15) - read this first
Symptom: nothing on air. Proof it was a radio-config problem, not a
frame-format problem: the 2026-07-15 sniffer capture (recordings/ its5_20260715_131448.pcap) contains 13,584 ITS-G5 frames on 5.9 GHz but
zero from our source MAC 02:00:00:00:00:01. A sniffer records a
station's frames even if the payload is malformed, so the frame was never
leaving the radio - the DENM/GeoNet/802.11 encoding was never the issue.
Three stacking causes, all in main.c's Wi-Fi init, now fixed:
- Plain STA, no promiscuous, power-save on. ESP-IDF only actually emits
raw frames when the MAC is in promiscuous mode or associated to an AP, and
default STA power-save sleeps the radio between beacons and drops outbound
frames. The working sniffer runs promiscuous - the OBU didn't. Fixed:
esp_wifi_set_ps(WIFI_PS_NONE)+esp_wifi_set_promiscuous(true)afteresp_wifi_start(). - Never entered 5 GHz band mode. The stuck
esp_wifi_get_channel() primary=1was the dual-band C5 still in 2.4 GHz band mode, soesp_wifi_set_channel(<5G channel>)failed silently and the HMAC TX path keyed the 2.4 GHz PHY on ch1 - inaudible to a 5.9 GHz sniffer. Fixed:esp_wifi_set_band_mode(WIFI_BAND_MODE_5G_ONLY)before start. Channel prime changed from 140 (5700 MHz) to 177 (5885 MHz) - see cause 3. - 5900 MHz is out of the C5's spec range. The datasheet 5 GHz range is
5180-5885 MHz; our target 5900 MHz (ITS-G5 G5-CCH) is 15 MHz above it.
RX tolerates 15 MHz over (that's why the sniffer works at 5900); TX may be
PA-calibration-gated at an uncalibrated frequency. Priming the driver to
channel 177 (5885, the top legal channel) keeps TX on the closest real
power table before
phy_change_channel(5900,...)nudges the LO.
If it STILL shows nothing after these fixes - the 5900 MHz isolation test
Causes 1 and 2 are high-confidence. Cause 3 is the remaining unknown, so isolate it before touching anything else. Temporarily change both ends to a channel inside the C5's spec range and see if the OBU appears:
- In
main.c, change the target frequency from5900to5885in bothphy_change_channel()calls (boot + per-TX), matching the channel-177 prime. - Put your sniffer on 5885 MHz too.
- Ground GPIO4 and watch the dashboard/PCAP for source MAC
02:00:00:00:00:01.
If the OBU now appears at 5885 but not at 5900: TX genuinely can't key the PA at the out-of-spec 5900 MHz, and no amount of frame-format work will change that - you'd need a chip whose 5 GHz range covers the ITS band, or to accept operating one channel down. If it appears at neither, the problem is still in the TX-enable path (recheck that the three calls above returned ESP_OK in the boot log), not the frequency.
Also note: nothing transmits until GPIO4 is grounded (active-low hazard input). If you were bench-testing without grounding GPIO4, the TX path was never even entered - ground it (jumper GPIO4 to GND) before concluding anything.
Profile: HLN-SV (aftermarket stationary recovery vehicle) - causeCode 94 (stationaryVehicle), subCauseCode 0, transmits only while the hazard-light GPIO reads active. No location/alacarte containers, no optional fields.
Code lives in obu-firmware/main/: main.c (entry, PHY hack, GPIO, TX loop),
denm.c/.h (ASN.1 UPER encoding), geonet.c/.h (GeoNetworking + BTP-B
wrapping), dot11p.c/.h (802.11 OCB frame assembly), tx_custom.c/.h (the
actual raw-frame transmit path - see "The TX bypass" below).
The TX bypass (tx_custom.c)
esp_wifi_80211_tx() - ESP-IDF's public raw-frame API - rejects QoS Data
frames outright (esp_err 258 / "unsupport QoS frame type"), and there's no
supported way to override that from application code (a linker-level symbol
override attempt, main/wifi_patches.c, is kept around only for history -
confirmed not to work).
tx_custom.c was pulled from
opentrafficmap/its-g5-receiver-firmware_txenabled
- a TX-enabled fork of the exact receiver firmware (V2X2MAP) already used on
the RX side of this project, same authors, same chip. Instead of trying to
disable the gate, it skips the code path that contains it entirely: it calls
ic_ebuf_alloc()andieee80211_post_hmac_tx()- undocumented internal driver functions - directly, submitting straight to the MAC.main.cnow callsesp_wifi_80211_tx_custom()instead ofesp_wifi_80211_tx(), with the one proven-working parameter set (WIFI_PHY_MODE_11A,WIFI_PHY_RATE_12M,WIFI_BAND_5G,WIFI_BW20) copied from the only call site in the upstream repo (mqtt.cpp, triggered by an incoming MQTT message there - ours is triggered by the GPIO4 hazard-light logic instead).
What this unlocks: dot11p.c is back to real QoS Data frames (subtype 8)
since the gate that forced the non-QoS downgrade no longer applies. Frame
size is now 117 bytes (was 115 with non-QoS Data's 2-byte-shorter header) -
expect sent (117 bytes) in the log now, not 115.
What's NOT yet independently verified - things worth checking as you go:
- Whether
ieee80211_post_hmac_tx,ic_ebuf_alloc,ic_get_default_sched,g_osi_funcs_p, andg_wifi_global_lockactually exist as symbols in your IDF version'slibnet80211.a/libpp.afor esp32c5 - we only previously confirmedieee80211_raw_frame_sanity_checkexists vianm, not these. If the build fails to link withundefined reference, this is the first thing to check - samenmapproach as thephy_11p_setsection below, just against these symbol names. tx_custom.c's internal struct layouts (x_eb_txdesc_t,x_ebuf_t) are reverse-engineered from the closed WiFi driver, pinned only by asizeof()assert - that catches a total-size mismatch across IDF versions but not a field-order mismatch that happens to keep the same total size. If your IDF version differs meaningfully from whatever the upstream repo has pinned (check theiresp-idfgit submodule commit vs.idf.py --version), this could compile and link cleanly but write to the wrong internal offsets. Worth keeping in mind as a possible explanation if you get a crash/hang right when TX fires rather than a clean error.- Same "skips ALL sanity checking" risk as the old override attempt: a
malformed frame from a bug anywhere in
denm.c/geonet.c/dot11p.ccould now behave worse (crash, silent corruption) than a clean rejection. esp_wifi_set_channel(140, ...)was added inmain.cright before thephy_11p_set/phy_change_channelpair, copied from upstream'scmd_sniffer.c(their own comment on it: "not sure if strictly needed"). Motivation: our ownesp_wifi_get_channel()diagnostic was reporting a stuckprimary=1regardless of what the PHY hack was told, consistent with the driver's channel bookkeeping never being touched by anything it tracks. This is a "worth trying," not a confirmed fix - watch whetheresp_wifi_get_channel()'s reported value changes at all now.
Confidence levels - read this before debugging blind
Updated after pulling the actual specs (EN 302 636-4-1, EN 302 636-5-1) and the real ASN.1 modules from forge.etsi.org (EN 302 637-3, TS 102 894-2 CDD) - this isn't guesswork anymore for the parts listed as "verified" below.
- GeoNetworking Basic/Common/SHB headers, BTP-B header (
geonet.c): verified field-by-field against EN 302 636-4-1. This caught three real bugs in the previous version: wrong header type (was encoded as GeoUnicast, HT=2 - now correctly TSB/SINGLE_HOP, HT=5/HST=0), wrong payload-length calculation (was including the 24-byte extended header, which it shouldn't), and GN_ADDR being an arbitrary byte string instead of its actual structure (M-flag + 5-bit station type + reserved + 48-bit MID = the same link-layer address used in the 802.11 header). - BTP-B header (
geonet.c): verified against EN 302 636-5-1 - unchanged from before, structure was already correct. - 802.11 header, LLC/SNAP (
dot11p.c): back to real QoS Data (subtype 8, 26-byte header with a QoS Control field), matching actual ITS-G5 hardware. This required abandoningesp_wifi_80211_tx()entirely in favor ofesp_wifi_80211_tx_custom()(tx_custom.c) - see "The TX bypass" above for the full story and the list of things about it that aren't independently verified yet for our exact toolchain. - DENM ASN.1 UPER payload (
denm.c): verified against the real ASN.1 modules (DENM-PDU-Descriptions.asn, ITS-Container.asn). This caught real bugs too:ManagementContainer,SituationContainer, and the innerCauseCodeSEQUENCE are all declared with a trailing...(extensible), each of which needs its own leading extension bit that the previous version omitted entirely;SituationContainer's optional-presence bits were encoded in the wrong position (at the end instead of the start); and three field widths were wrong (latitude is 31 bits not 32, the two position-confidence fields and orientation are 12 bits not 16, altitude value is 20 bits not 24). All fixed now, with the exact ASN.1 type and constraint range cited in comments next to each field. - Known-missing, by design, not bugs:
detectionTime/referenceTime/GN timestamp are hardcoded to 0 (no RTC/NTP wired up - will decode as 2004-01-01), andlatitude/longitudeare hardcoded to 0 (no GNSS wired up). Both are called out withTODOcomments in the source. - Privacy pseudonym: the source MAC/GN_ADDR MID is a fixed placeholder, not rotated. Fine for bench testing; real stacks rotate this every 5-15 min.
- Unsecured (no IEEE 1609.2 signing) - matches "no additional parameters"/easiest, and your sniffer already handles unsecured frames fine (that's how it decodes RSU SPATEM/MAPEM today).
- Still a deliberate simplification, not a bug: single-hop broadcast (TSB/SINGLE_HOP) instead of GeoBroadcast. Real DENM dissemination typically uses GeoBroadcast so RSUs/OBUs can forward it across an area - upgrading to that needs a sequence number + circular geo-area fields in the GN extended header that this skeleton doesn't build. Fine for a single-vehicle beacon; revisit if you need multi-hop forwarding.
Build
. $env:IDF_PATH\export.ps1 # or use the "ESP-IDF PowerShell" Start Menu shortcut instead
cd C:\Users\Ashin\Documents\micrOBU_workspace\v2x-obu-esp32c5\obu-firmware
idf.py set-target esp32c5
idf.py build
If the linker fails on phy_11p_set / phy_change_channel
These are undocumented, reverse-engineered symbols pulled straight from
libphy.a - not a public API, so exact names/signatures can shift between
ESP-IDF versions. If you get undefined reference, check what's actually
exported for your IDF version:
riscv32-esp-elf-nm $env:IDF_PATH\components\esp_phy\lib\esp32c5\libphy.a | Select-String -Pattern "11p|change_channel"
Adjust the extern declarations at the top of main.c to match whatever you
find.
Flash
idf.py -p COM5 -b 921600 flash monitor
You should see in the log:
OCB mode requested @ 5900 MHz - HLN-SV DENM beacon armed, waiting on GPIO4
Nothing transmits yet - GPIO4 is pulled up (inactive) until you ground it.
Wire up the hazard-light input
For bench testing: a jumper wire or push button between GPIO4 and GND is enough (active-low - grounding it = "hazard lights on" = beacon active). For the real thing later: tap whatever signal your recovery vehicle's hazard switch drives (through a level shifter / opto-isolator if it's 12V vehicle wiring - don't feed vehicle voltage directly into a GPIO).
Event lifecycle (added after cross-checking the Cohda UCA MQTT schema)
The Cohda "Use Case App" schema (v2x-uca/output/json/denm) documents a
termination key: "present if the DENM is cancelled or negated." The
firmware now implements this properly:
actionID(station ID + sequence number) is assigned once per event, on the rising edge of the hazard-light GPIO, and stays constant across every repeat of that same event - it does not increment every second like an earlier version of this code did.- On the falling edge (hazard lights go off), exactly one DENM is sent with
termination = isCancellation, referencing that same actionID, then transmission stops until the next rising edge.
Expect your sniffer to show the same station+sequence number repeating for the duration of the event, then one final frame with a termination flag.
Validate against your own sniffer
This is the important part, since a few of the payload bit widths are best-effort: with the transmitter running (GPIO4 grounded) and your Phase 1 sniffer board powered on nearby, check the V2X2MAP dashboard.
Expect to see:
- A new station appear, sending DENM (and only DENM - this skeleton doesn't send CAM).
causeCodedecoding to stationaryVehicle,subCauseCode0.stationTypedecoding to passengerCar.
If instead you get "unknown message type," garbage station type, or the dashboard just doesn't show anything: capture a PCAP from the dashboard's record button and open it in Wireshark - compare byte-by-byte against a real captured DENM (like the one from your Cohda OBU) to see exactly where the two diverge. That's a much faster debug loop than staring at the C code.
Validate against the real RSU
Your own sniffer receiving the frame only tells you the RF/PHY side works - it doesn't tell you whether a standards-strict stack (Cohda) will accept it. With the transmitter running and both your sniffer and the Cohda-based RSU mqtt monitor watching:
- If both see it, and it decodes as a real DENM (not "Type ?"): done, move on to the "once this round-trips cleanly" list below.
- If your sniffer sees it but doesn't classify it, or the RSU doesn't see it at all: worth first ruling out the receiver's own reliability (checked separately - the receiver has shown signs of hanging independent of the transmitter) before concluding anything about frame format.
- If neither sees it: still worth re-checking
esp_wifi_get_channel()'s reported value (logged before every TX) - if it's still stuck regardless of the newesp_wifi_set_channel(140, ...)call, that's a stronger signal the PHY genuinely isn't moving to 5900MHz, independent of frame format.
Note the receiver firmware (V2X2MAP) never faces the frame-type problem this
project spent a while on: receiving never calls esp_wifi_80211_tx(), so
there was never a gate on that side to work around. The phy_11p_set/
phy_change_channel PHY setup in main.c already mirrors what the receiver
does for getting onto 5.9GHz OCB mode.
Once this round-trips cleanly
Next reasonable steps, in rough order: wire in real GNSS (replaces the 0/0 lat-long and the GN timestamp), wire in SNTP or GNSS-derived UTC time (replaces detectionTime/referenceTime), then decide whether you actually need GeoBroadcast/multi-hop forwarding instead of SHB, then - only if you need it - look at IEEE 1609.2 signing.