Add the user guide and technical documentation as Word documents
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.
This commit is contained in:
@@ -0,0 +1,280 @@
|
||||
# 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:
|
||||
|
||||
1. **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)` after
|
||||
`esp_wifi_start()`.
|
||||
2. **Never entered 5 GHz band mode.** The stuck `esp_wifi_get_channel()
|
||||
primary=1` was the dual-band C5 still in 2.4 GHz band mode, so
|
||||
`esp_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.
|
||||
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 from `5900` to `5885` in *both*
|
||||
`phy_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](https://codeberg.org/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()` and `ieee80211_post_hmac_tx()` - undocumented internal
|
||||
driver functions - directly, submitting straight to the MAC. `main.c` now
|
||||
calls `esp_wifi_80211_tx_custom()` instead of `esp_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`, and `g_wifi_global_lock` actually exist as symbols in
|
||||
*your* IDF version's `libnet80211.a`/`libpp.a` for esp32c5 - we only
|
||||
previously confirmed `ieee80211_raw_frame_sanity_check` exists via `nm`,
|
||||
not these. If the build fails to link with `undefined reference`, this is
|
||||
the first thing to check - same `nm` approach as the `phy_11p_set` section
|
||||
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 a `sizeof()`
|
||||
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 their `esp-idf` git 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.c` could
|
||||
now behave worse (crash, silent corruption) than a clean rejection.
|
||||
- `esp_wifi_set_channel(140, ...)` was added in `main.c` right before the
|
||||
`phy_11p_set`/`phy_change_channel` pair, copied from upstream's
|
||||
`cmd_sniffer.c` (their own comment on it: "not sure if strictly needed").
|
||||
Motivation: our own `esp_wifi_get_channel()` diagnostic was reporting a
|
||||
stuck `primary=1` regardless 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 whether
|
||||
`esp_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 abandoning `esp_wifi_80211_tx()` entirely in favor of
|
||||
`esp_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 inner
|
||||
`CauseCode` SEQUENCE 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), and `latitude`/`longitude` are hardcoded to 0 (no GNSS wired
|
||||
up). Both are called out with `TODO` comments 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
|
||||
|
||||
```powershell
|
||||
. $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:
|
||||
|
||||
```powershell
|
||||
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
|
||||
|
||||
```powershell
|
||||
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).
|
||||
- `causeCode` decoding to **stationaryVehicle**, `subCauseCode` **0**.
|
||||
- `stationType` decoding 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 new `esp_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.
|
||||
Reference in New Issue
Block a user