Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73d4477f1e | ||
|
|
277c6b20f4 | ||
|
|
0f06cdb339 | ||
|
|
75d6d3b85c | ||
|
|
1baae2c5f6 | ||
|
|
8871708a98 | ||
|
|
d7043bb04d | ||
|
|
e908f7fae1 | ||
|
|
8f397eea20 | ||
|
|
cc395771ea |
@@ -27,6 +27,9 @@ secrets.properties
|
||||
/.idea/appInsightsSettings.xml
|
||||
/.idea/studiobot.xml
|
||||
|
||||
# Python bytecode caches, e.g. from importing obu-firmware/test/host/check_replay.py.
|
||||
__pycache__/
|
||||
|
||||
# ESP-IDF rewrites sdkconfig on every build and keeps the previous one here.
|
||||
# sdkconfig.defaults is the real, intentional configuration; these two are output.
|
||||
sdkconfig.old
|
||||
@@ -38,7 +41,11 @@ sdkconfig.old
|
||||
# Third-party working copies kept beside the project, not part of it. The ASN.1
|
||||
# modules this project actually needs are vendored under asn1/ instead.
|
||||
/C-ITS-Parser/
|
||||
/vanetza/
|
||||
|
||||
# Office lock files. Word/Excel create these beside a document while it is open
|
||||
# and remove them on close, so they are transient and machine-local.
|
||||
~$*
|
||||
|
||||
# Captures are large data files, not source (see capture/README.md).
|
||||
/capture/recordings/
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
# TODO
|
||||
|
||||
Engineering to-do list. The reviewer-facing open items live in
|
||||
`docs/01-requirements-traceability.md` ("Open items"); this file is the working list behind them.
|
||||
|
||||
## Waiting on hardware
|
||||
|
||||
### Over-the-air check of the GN lifetime fix (added 2026-09-11)
|
||||
|
||||
`geonet.c` now writes GN lifetime `0x05` (1 s) instead of `0x83`, which decoded to 3200 s. Changed
|
||||
in both `obu-firmware` and `obu-cam-transmistter`. Both still build (IDF 6.1 / 5.5.4), and the
|
||||
compiled `geonet_wrap_shb` stores the new byte. Confirmed on air 2026-09-14. Nothing else
|
||||
reads this byte (`gn_unwrap.c` ignores it, the app never sees GN headers), so the app does not
|
||||
need updating alongside the firmware.
|
||||
|
||||
Needs: the phone with the app, the OBU ESP32-C5, and a **second** ESP32-C5 running
|
||||
`its-g5-receiver-firmware` to capture with.
|
||||
|
||||
- [x] Flash `obu-firmware` (done 2026-09-14 on COM3; flash backed up first to
|
||||
`Documents/micrOBU_workspace/firmware-backups/COM3-2026-09-14-before-secured-rx.bin`).
|
||||
- [x] Capture with the receiver (COM8) into `its-g5-receiver-firmware/recordings/`.
|
||||
- [x] `pcap_gn_tally.py` on capture_20260914_132126.pcap: our station sends SHB, port 2001,
|
||||
lifetime `0x05`, same as both bench stations. It was `0x83` in the August captures.
|
||||
- [x] Real-station CAMs/DENMs/SPATEM still reach the app (logcat: `handleCamUper`,
|
||||
`handleDenmUper`, `handleSpatUper` all decoding, 2026-09-14).
|
||||
- [x] Our own CAMs decode on air: 397 frames from station 999999 decode with asn1tools and
|
||||
re-encode byte-identically.
|
||||
- [ ] Confirm the CAM Pinger card's `tx fail` / oversize / CRC counters are 0 (needs a look at the
|
||||
phone; not readable from the PC).
|
||||
|
||||
Partial check possible with one board and no phone: flash it, `idf.py -p COMx monitor`, and look
|
||||
for `OCB @ 5900 MHz - TX/RX armed`. That proves the new build boots and brings the radio up, not
|
||||
that it transmits correctly.
|
||||
|
||||
### obu-cam-transmistter yawRateConfidence fix (added 2026-09-11)
|
||||
|
||||
Its `cam.c` (compiled into that firmware) wrote `yawRateConfidence` as 3 bits / 7 instead of
|
||||
4 bits / unavailable(8), the bug the app fixed on 2026-08-20. Fixed in it and in obu-firmware's
|
||||
reference copy; asn1tools now decodes the CAM and re-encodes it byte-identically, and it builds on
|
||||
IDF 5.5.4. Since 2026-09-14 the spare board on COM10 runs it as a bench beacon:
|
||||
|
||||
- [x] Done 2026-09-14: flashed on COM10 and captured on COM8. All 72 CAMs from station
|
||||
195936478 (0x0BADC0DE) decode with asn1tools and re-encode byte-identically, so the
|
||||
4-bit yawRateConfidence is right on air. COM10 now runs this beacon rather than
|
||||
obu-firmware - reflash it if the spare is needed as an OBU again.
|
||||
|
||||
### Signed-message reception and exact payloads (added 2026-09-11)
|
||||
|
||||
obu-firmware's `gn_unwrap.c` now unwraps TS 103 097 signed packets (signature not verified,
|
||||
reported as V2X_RX flags bit1) and cuts every message to the length its header declares, dropping
|
||||
the 8 bytes the chip's RX appends to each frame, which were forwarded to the phone until now.
|
||||
Verified on the host (`obu-firmware/test/host`: chain, replay of all recordings against asn1tools,
|
||||
50M-iteration fuzz) and flashed to the production OBU on 2026-09-14. The remaining gap is signed
|
||||
traffic to receive: real vehicles or
|
||||
RSUs, since the bench CiT One sends unsigned. A second ESP32 running the receiver firmware is
|
||||
optional, but shows what was on air at the time.
|
||||
|
||||
- [x] Flash obu-firmware (done 2026-09-14, COM3).
|
||||
- [x] Unsigned bench traffic still decodes in the app, with messages now cut to their declared
|
||||
length (CAM, DENM and SPATEM all decoding in logcat after the flash).
|
||||
- [ ] Near signed traffic: signed CAMs/DENMs appear in the app, and a simultaneous capture shows
|
||||
them on air (`pcap_gn_tally.py` lists them as `secured`). NOT possible at this bench: the
|
||||
CiT One transmits unsigned (`ItsGnSecurity = 0`) and nothing else here signs. Needs a drive
|
||||
past real RSUs, the CiT One switched to signed mode if its API allows, or a replay firmware
|
||||
on a spare board that re-transmits the recorded signed frames.
|
||||
- [ ] The heartbeat's oversize counter still counts over-long messages. Not exercised at the
|
||||
bench: the SPATEMs here are ~340 bytes on air, far below the cap.
|
||||
|
||||
## Set up host testing
|
||||
|
||||
- [x] Install MSYS2 UCRT64 gcc (done 2026-09-11: gcc 16.2.0, GNU Make 4.4.1; chosen over WSL,
|
||||
vanetza is not going to be built). Setup and the PATH gotcha: `obu-firmware/test/host/README.md`.
|
||||
- [x] Host round-trip test `obu-firmware/test/host/test_chain.c` (`geonet_wrap_shb` ->
|
||||
`dot11p_build_frame` -> `gn_unwrap_its`, byte-checked against the standard). Done
|
||||
2026-09-11: 491 checks, 0 failed. Run `make` in that folder before flashing any firmware fix.
|
||||
- [x] Replay of the recorded captures (`test_replay.c` + `check_replay.py`, independent asn1tools
|
||||
check). Done 2026-09-11: C and Python agree on all 15 145 records.
|
||||
- [x] Mutation fuzzer `fuzz_gn_unwrap.c`, inputs against a no-access guard page. Done 2026-09-11:
|
||||
50 000 000 iterations, no crash. `make` runs a 2 000 000-iteration pass every time.
|
||||
|
||||
## Firmware ideas from the vanetza review (2026-09-11, not started)
|
||||
|
||||
Suggested order after the host tests exist:
|
||||
|
||||
- [x] **Read secured packets (GN NextHeader=2) without verifying them.** Done 2026-09-11 in
|
||||
`gn_unwrap.c`, host-verified; flagged to the phone as V2X_RX flags bit1. On-air check under
|
||||
"Waiting on hardware".
|
||||
- [ ] **Forward the full GeoBroadcast area**: shape (circle/rectangle/ellipse), DistanceB, angle,
|
||||
appended to the V2X_RX prefix behind a capability bit. Port vanetza's `geonet/areas.cpp`
|
||||
`inside_or_at_border` to the app, which currently treats every area as a circle.
|
||||
- [ ] **RX filtering before the serial link**: duplicate detection for GBC (last 8 sequence numbers
|
||||
per source, as vanetza does), drop our own frames, reject GN version != 1.
|
||||
- [ ] **Read the DCC-MCO field** (the 4 "reserved" bytes of an SHB header): neighbours' channel
|
||||
busy ratio for free.
|
||||
- [ ] **Minimum TX gap in firmware** as a DCC safety net (vanetza reactive table: 60 ms relaxed ...
|
||||
460 ms restrictive), with a CBR estimate in the heartbeat.
|
||||
- [ ] **Generic V2X_TX message** (BTP port, SHB/GBC, traffic class, lifetime, area) so the phone can
|
||||
send DENM and VAM without reflashing. Consider QoS Data frames: vanetza's Cohda receive path
|
||||
drops non-QoS ones.
|
||||
|
||||
Dropped: building vanetza as a GN/BTP oracle. Real captures (`pcap_gn_tally.py`), the host
|
||||
round-trip test and `asn1tools` for UPER cover what it would have checked.
|
||||
|
||||
## Follow-ups found 2026-09-14
|
||||
|
||||
- [x] **Capture tooling moved into this repo** (`capture/`), with the CR-insertion fix. The
|
||||
sniffer's console inserts a CR before every LF, which also hits every 0x0a byte of the binary
|
||||
pcap stream, shifting pcap record headers and frames. A 787 KB capture parsed cleanly for
|
||||
only 82 of ~2000 records, and DENMs showed up on nonsense BTP ports. `undo_crlf()` reverses
|
||||
it on the raw stream before framing; afterwards a capture parsed to EOF and DENMs read as
|
||||
port 2002. **Every capture taken before 2026-09-14 is truncated at its first corrupted
|
||||
record** - re-measure anything derived from them.
|
||||
- [ ] `capture/dump_pcap.py` reads the same console and still needs the same treatment.
|
||||
- [ ] **Do not open COM3's console while the phone is attached.** Opening it toggles DTR/RTS on the
|
||||
CH343 and resets the OBU, which drops the phone's USB link and needs a manual Connect.
|
||||
|
||||
## Follow-ups found 2026-09-11
|
||||
|
||||
- [ ] **App: show the signed flag.** `V2xRxFrame.parse` in `SerialFrame.kt` only reads bit0 of
|
||||
the flags byte; read bit1 (signed, not verified) and show it where messages are listed.
|
||||
- [ ] **Messages that do not decode with asn1tools.** In the recordings, 56 from the CiT One
|
||||
(`aa:f8:76:7d:bd:ad`: 54 CAMs of 245 bytes, 2 DENMs of 402 bytes) and one 218-byte CAM from
|
||||
`6e:94:03:1b:05:26` fail against `cam_1_4_1`/`denm_1_3_1` + `cdd_1_3_1_1`, with or without the
|
||||
old trailing bytes. A newer module version on the sender, or a sender bug; check what the
|
||||
app's decoders make of them (`check_replay.py` lists the records).
|
||||
@@ -0,0 +1,102 @@
|
||||
# Sniffer board and capture tooling
|
||||
|
||||
How to put an ESP32-C5 on the ITS-G5 channel as a passive sniffer, pull its captures onto this
|
||||
PC, and check what is on air. The sniffer firmware itself is the third-party
|
||||
`its-g5-receiver-firmware` checkout beside this repo; only the tooling and these notes are ours.
|
||||
|
||||
| File | What it does |
|
||||
|---|---|
|
||||
| `live_capture.py` | Streams the device's captures into a growing `.pcap` while it runs. The usual choice. |
|
||||
| `dump_pcap.py` | Pulls one capture out of the device's in-memory buffer after the fact. |
|
||||
| `../obu-firmware/test/pcap_gn_tally.py` | Tallies GeoNetworking headers per station over a `.pcap`. |
|
||||
|
||||
One-time: `pip install pyserial` (present in Python 3.11 on the bench PC, so `py -3.11` works).
|
||||
|
||||
## Which port
|
||||
|
||||
The sniffer firmware's console, and with it the pcap stream, goes out **UART0** - the board's
|
||||
USB-bridge port (a CH343, its own COM number), not the native USB-C port. A board with only one
|
||||
USB-C port cannot be used as a sniffer for this reason. On the bench this has been COM5 and, after
|
||||
a re-enumeration, COM8.
|
||||
|
||||
## Live capture (preferred)
|
||||
|
||||
```powershell
|
||||
cd capture
|
||||
py -3.11 live_capture.py COM8
|
||||
```
|
||||
|
||||
It writes `recordings/capture_<timestamp>.pcap` next to itself, flushing after every packet, so
|
||||
the file can be read while it grows. Stop it with Ctrl+C. Use `-o <dir>` to write elsewhere;
|
||||
`recordings/` is gitignored, since captures are large and are data rather than source. Captures
|
||||
taken before 2026-09-14 are still in `its-g5-receiver-firmware/recordings/`; the host tests read
|
||||
both directories.
|
||||
|
||||
### The CR insertion, and why captures used to be corrupt
|
||||
|
||||
ESP-IDF's newlib console converts LF to CRLF on its way out, and that applies to every `0x0a` byte
|
||||
of the **binary** pcap stream, not only to log text. Each inserted CR shifts everything after it,
|
||||
so pcap record headers and captured frames alike come out corrupt, and the file stops being
|
||||
parseable at the first occurrence.
|
||||
|
||||
Measured on 2026-09-14: a 787 KB capture parsed cleanly for only 82 of about 2000 records, and
|
||||
DENMs appeared on nonsense BTP ports because their payloads contain `0x0a` often. `undo_crlf()` in
|
||||
`live_capture.py` reverses it on the raw stream before any framing, which is exact; afterwards a
|
||||
capture parsed to EOF and DENMs read as port 2002 again.
|
||||
|
||||
**Captures taken before 2026-09-14 are truncated at their first corrupted record.** Anything
|
||||
measured from them is worth re-checking. `dump_pcap.py` reads the same console and has not been
|
||||
given the same treatment yet.
|
||||
|
||||
## Checking a capture
|
||||
|
||||
```powershell
|
||||
py -3.11 ..\obu-firmware\test\pcap_gn_tally.py recordings\capture_<timestamp>.pcap
|
||||
```
|
||||
|
||||
One row per station, packet type, BTP port and GN lifetime. For the messages themselves, decode
|
||||
the payloads with `asn1tools` against the modules in `../asn1/` and re-encode them: identical bytes
|
||||
mean the message was read exactly, wrong bytes mean it was not. `obu-firmware/test/check_replay.py`
|
||||
does this over a whole capture.
|
||||
|
||||
## Flashing the sniffer firmware
|
||||
|
||||
From the receiver checkout, with its **pinned** ESP-IDF (not the global 5.5.4 install):
|
||||
|
||||
```powershell
|
||||
cd its-g5-receiver-firmware
|
||||
git submodule update --init --recursive
|
||||
.\esp-idf\install.bat
|
||||
Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass
|
||||
.\esp-idf\export.ps1
|
||||
idf.py set-target esp32c5
|
||||
idf.py -p COM8 -b 921600 flash
|
||||
```
|
||||
|
||||
Its `sdkconfig` for a bare board (nothing wired) needs SPI Ethernet off, and the pcap destination
|
||||
set to Memory, both under `idf.py menuconfig`. Wired variants have ready-made configs in that
|
||||
checkout: `sdkconfig.proto-w5500`, `sdkconfig.proto-enc28j60`, `sdkconfig.proto-spi-eppp`.
|
||||
|
||||
To reflash a board that already has a built image, without a toolchain terminal:
|
||||
|
||||
```powershell
|
||||
cd its-g5-receiver-firmware\build
|
||||
C:\Espressif\python_env\idf5.5_py3.11_env\Scripts\python.exe -m esptool --chip esp32c5 -p COM8 -b 921600 write_flash --flash_mode dio --flash_freq 80m --flash_size 16MB 0x2000 bootloader/bootloader.bin 0x8000 partition_table/partition-table.bin 0x1e000 ota_data_initial.bin 0x20000 its-g5-receiver-firmware.bin
|
||||
```
|
||||
|
||||
## Pulling a capture after the fact
|
||||
|
||||
Only for the Memory destination, and the buffer is small (`SNIFFER_PCAP_MEMORY_SIZE`, 4096 bytes
|
||||
by default) - a smoke test, not a session. In the device console (`idf.py -p COM8 monitor`, exit
|
||||
with Ctrl+T then Ctrl+X):
|
||||
|
||||
```
|
||||
sniffer -P
|
||||
sniffer --stop
|
||||
```
|
||||
|
||||
Then, with the port free:
|
||||
|
||||
```powershell
|
||||
py -3.11 dump_pcap.py COM8
|
||||
```
|
||||
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Pulls a capture off the ITS-G5 receiver's in-memory pcap buffer over the existing USB serial
|
||||
connection and saves it as a real .pcap file on this machine.
|
||||
|
||||
Requires the firmware to be built with:
|
||||
Example Configuration -> Select destination to store pcap file -> Memory
|
||||
|
||||
Usage (typical):
|
||||
1. Close idf.py monitor (only one program can hold the COM port at a time).
|
||||
2. Run a capture on the device: `sniffer -P` ... let it run ... `sniffer --stop`
|
||||
3. python dump_pcap.py COM5
|
||||
|
||||
The device has no access to this computer's filesystem, so it can't write here directly. Instead,
|
||||
`pcap --dump` streams the raw pcap bytes back over the same serial link, wrapped in plain-text
|
||||
markers ("===PCAP-DUMP-START:<len>===" ... raw bytes ... "===PCAP-DUMP-END==="). This script finds
|
||||
those markers and writes just the raw bytes out as a .pcap file.
|
||||
|
||||
Install dependency once: pip install pyserial
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import re
|
||||
import sys
|
||||
|
||||
try:
|
||||
import serial
|
||||
except ImportError:
|
||||
print("Missing dependency. Install it with: pip install pyserial", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
START_RE = re.compile(rb"===PCAP-DUMP-START:(\d+)===\n")
|
||||
END_MARKER = b"\n===PCAP-DUMP-END===\n"
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("port", help="Serial port the device is on, e.g. COM5")
|
||||
parser.add_argument("-b", "--baud", type=int, default=115200, help="Baud rate (default: 115200)")
|
||||
parser.add_argument("-o", "--outdir", default="recordings", help="Output directory (default: ./recordings)")
|
||||
parser.add_argument("-t", "--timeout", type=float, default=15.0, help="Seconds to wait for the dump to start")
|
||||
args = parser.parse_args()
|
||||
|
||||
import os
|
||||
os.makedirs(args.outdir, exist_ok=True)
|
||||
|
||||
print(f"Opening {args.port} @ {args.baud}...")
|
||||
with serial.Serial(args.port, args.baud, timeout=1) as ser:
|
||||
# Nudge the console in case there's stale input, then request the dump.
|
||||
ser.reset_input_buffer()
|
||||
ser.write(b"\r\n")
|
||||
ser.write(b"pcap -f dump --dump\r\n")
|
||||
|
||||
print("Waiting for dump to start...")
|
||||
buf = b""
|
||||
match = None
|
||||
deadline = datetime.datetime.now() + datetime.timedelta(seconds=args.timeout)
|
||||
while datetime.datetime.now() < deadline:
|
||||
chunk = ser.read(256)
|
||||
if chunk:
|
||||
buf += chunk
|
||||
match = START_RE.search(buf)
|
||||
if match:
|
||||
break
|
||||
if not match:
|
||||
print("Timed out waiting for '===PCAP-DUMP-START:...===' marker.\n"
|
||||
"Check that: the firmware is built with the Memory pcap destination, a capture was\n"
|
||||
"actually taken ('sniffer -P' then 'sniffer --stop'), and no other program (like\n"
|
||||
"idf.py monitor) is holding the serial port open.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
length = int(match.group(1))
|
||||
print(f"Dump starting, {length} bytes expected.")
|
||||
|
||||
# Anything after the marker in our buffer is already part of the payload.
|
||||
payload = buf[match.end():]
|
||||
remaining = length - len(payload)
|
||||
while remaining > 0:
|
||||
chunk = ser.read(min(remaining, 4096))
|
||||
if not chunk:
|
||||
print(f"Serial read timed out with {remaining} bytes still missing.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
payload += chunk
|
||||
remaining -= len(chunk)
|
||||
|
||||
# Drain (and sanity-check) the trailing end marker, but don't fail hard if it's not exact.
|
||||
tail = ser.read(len(END_MARKER))
|
||||
if tail != END_MARKER:
|
||||
print("Warning: end marker didn't match exactly - payload may still be fine.", file=sys.stderr)
|
||||
|
||||
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
outpath = os.path.join(args.outdir, f"capture_{timestamp}.pcap")
|
||||
with open(outpath, "wb") as f:
|
||||
f.write(payload)
|
||||
|
||||
print(f"Saved {len(payload)} bytes to {outpath}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,226 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Continuously listens on the ITS-G5 receiver's serial console and writes every captured packet into a
|
||||
live-growing .pcap file, with no console commands needed on the device side.
|
||||
|
||||
The firmware streams every packet it captures out over the same serial connection the console runs on,
|
||||
automatically, as soon as the sniffer is running (which happens on boot by default). Each packet is framed
|
||||
with plain-text markers so this script can pull the binary pcap bytes out of the stream even though
|
||||
regular log lines are interleaved with it:
|
||||
|
||||
===PCAP-LIVE-HEADER:<len>===\\n<24 raw bytes>\\n (sent once, the pcap global header)
|
||||
===PCAP-LIVE-PKT:<len>===\\n<raw bytes>\\n (sent once per captured packet)
|
||||
|
||||
Usage:
|
||||
python live_capture.py COM5
|
||||
|
||||
Runs until you press Ctrl+C. Writes to recordings/capture_<timestamp>.pcap, flushing after every packet
|
||||
so you can open the file in Wireshark while it's still being written (use "File > Open" again, or
|
||||
Wireshark's own "Follow" won't auto-refresh but re-opening will show the latest packets).
|
||||
|
||||
Install dependency once: pip install pyserial
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
|
||||
try:
|
||||
import serial
|
||||
except ImportError:
|
||||
print("Missing dependency. Install it with: pip install pyserial", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# \r? because the ESP console emits CRLF: on Windows the markers arrive as
|
||||
# "===PCAP-LIVE-PKT:310===\r\n", which never matched a bare \n and left the capture silently
|
||||
# empty while the device was streaming perfectly well.
|
||||
HEADER_RE = re.compile(rb"===PCAP-LIVE-HEADER:(\d+)===\r?\n")
|
||||
PKT_RE = re.compile(rb"===PCAP-LIVE-PKT:(\d+)===\r?\n")
|
||||
|
||||
LINKTYPE_ETHERNET = 1
|
||||
LINKTYPE_IEEE802_11_RADIOTAP = 127
|
||||
|
||||
|
||||
def mac_str(b):
|
||||
return ":".join(f"{x:02x}" for x in b)
|
||||
|
||||
|
||||
def build_default_pcap_header(link_type):
|
||||
"""Synthesizes the same 24-byte global pcap header the firmware would have sent, for when we
|
||||
connect after the device's one-time header already went out (see the race note in main())."""
|
||||
header = bytearray(24)
|
||||
header[0:4] = bytes([0xD4, 0xC3, 0xB2, 0xA1]) # magic (LE bytes of 0xA1B2C3D4)
|
||||
header[4:6] = (2).to_bytes(2, "little") # major version
|
||||
header[6:8] = (4).to_bytes(2, "little") # minor version
|
||||
header[16:20] = (0x40000).to_bytes(4, "little") # snaplen
|
||||
header[20:24] = link_type.to_bytes(4, "little")
|
||||
return bytes(header)
|
||||
|
||||
|
||||
def summarize_packet(link_type, record_bytes, index):
|
||||
"""Best-effort human-readable one-line summary of a captured packet, for live feedback.
|
||||
record_bytes is the raw 16-byte pcap record header followed by the captured frame."""
|
||||
seconds = int.from_bytes(record_bytes[0:4], "little")
|
||||
microseconds = int.from_bytes(record_bytes[4:8], "little")
|
||||
cap_len = int.from_bytes(record_bytes[8:12], "little")
|
||||
frame = record_bytes[16:]
|
||||
ts = f"{seconds}.{microseconds:06d}"
|
||||
|
||||
if link_type == LINKTYPE_IEEE802_11_RADIOTAP and len(frame) >= 24:
|
||||
radiotap_len = int.from_bytes(frame[2:4], "little")
|
||||
rssi = frame[8] - 256 if frame[8] >= 128 else frame[8]
|
||||
station_id = int.from_bytes(frame[16:24], "little")
|
||||
mac_frame = frame[radiotap_len:]
|
||||
if len(mac_frame) >= 16:
|
||||
dst = mac_str(mac_frame[4:10])
|
||||
src = mac_str(mac_frame[10:16])
|
||||
else:
|
||||
dst = src = "?"
|
||||
station = f"{station_id:012x}" if station_id else "unknown"
|
||||
return (f"#{index:<5} [{ts}] len={cap_len:<5} rssi={rssi:>4}dBm "
|
||||
f"station={station} {src} -> {dst}")
|
||||
|
||||
if link_type == LINKTYPE_ETHERNET and len(frame) >= 14:
|
||||
dst = mac_str(frame[0:6])
|
||||
src = mac_str(frame[6:12])
|
||||
ethertype = int.from_bytes(frame[12:14], "big")
|
||||
return f"#{index:<5} [{ts}] len={cap_len:<5} eth {src} -> {dst} type=0x{ethertype:04x}"
|
||||
|
||||
return f"#{index:<5} [{ts}] len={cap_len:<5} (unrecognized frame format)"
|
||||
|
||||
|
||||
def undo_crlf(chunk, state):
|
||||
"""Undo the CR the device console inserts before every LF.
|
||||
|
||||
ESP-IDF's newlib console converts LF to CRLF on its way out, and that happens to every 0x0A
|
||||
byte of the binary pcap stream too, not only to log text. Each inserted CR shifts everything
|
||||
after it, so pcap record headers and captured frames alike come out corrupt. This is the
|
||||
"byte inserted mid-frame" seen in older recordings; with DENM traffic on air it wrecks most
|
||||
of a capture (measured 2026-09-14: a 787 KB file parsed cleanly for only 82 records).
|
||||
|
||||
Dropping one CR immediately before each LF undoes it exactly, provided it is done on the raw
|
||||
stream before any framing and a trailing CR is carried across read boundaries. CR and LF are
|
||||
written as byte values here so the transformation cannot be confused with an escape.
|
||||
"""
|
||||
CR, LF = bytes([13]), bytes([10])
|
||||
if state["pending_cr"]:
|
||||
chunk = CR + chunk
|
||||
state["pending_cr"] = False
|
||||
if chunk.endswith(CR):
|
||||
chunk = chunk[:-1]
|
||||
state["pending_cr"] = True
|
||||
return chunk.replace(CR + LF, LF)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("port", help="Serial port the device is on, e.g. COM5")
|
||||
parser.add_argument("-b", "--baud", type=int, default=115200, help="Baud rate (default: 115200)")
|
||||
parser.add_argument("-o", "--outdir", default="recordings", help="Output directory (default: ./recordings)")
|
||||
args = parser.parse_args()
|
||||
|
||||
os.makedirs(args.outdir, exist_ok=True)
|
||||
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
outpath = os.path.join(args.outdir, f"capture_{timestamp}.pcap")
|
||||
|
||||
print(f"Opening {args.port} @ {args.baud}...")
|
||||
print(f"Writing live capture to {outpath}")
|
||||
print("Press Ctrl+C to stop.")
|
||||
|
||||
header_written = False
|
||||
link_type = None
|
||||
packet_count = 0
|
||||
buf = b""
|
||||
total_bytes = 0
|
||||
last_status = time.monotonic()
|
||||
printed_raw_preview = False
|
||||
|
||||
crlf_state = {"pending_cr": False}
|
||||
|
||||
with serial.Serial(args.port, args.baud, timeout=1) as ser, open(outpath, "wb") as outfile:
|
||||
def read_bytes(n):
|
||||
return undo_crlf(ser.read(n), crlf_state)
|
||||
|
||||
try:
|
||||
while True:
|
||||
chunk = read_bytes(256)
|
||||
if chunk:
|
||||
buf += chunk
|
||||
total_bytes += len(chunk)
|
||||
|
||||
now = time.monotonic()
|
||||
if now - last_status >= 2:
|
||||
last_status = now
|
||||
print(f"[diagnostic] {total_bytes} raw bytes received so far, "
|
||||
f"{packet_count} packets recognized, header_written={header_written}")
|
||||
if total_bytes > 0 and not printed_raw_preview and not header_written and not PKT_RE.search(buf):
|
||||
# We're getting bytes but none of them look like our markers - show a preview
|
||||
# so we can tell whether this is plain log text (markers just haven't shown up
|
||||
# yet), garbage (baud/port mismatch), or something else entirely.
|
||||
preview = buf[:200]
|
||||
print(f"[diagnostic] no markers matched yet - raw preview: {preview!r}")
|
||||
printed_raw_preview = True
|
||||
elif total_bytes == 0:
|
||||
print("[diagnostic] zero bytes received from the port at all - this points at "
|
||||
"the wrong COM port, another program holding the port, or a port that "
|
||||
"isn't actually wired to the console/sniffer output.")
|
||||
|
||||
# The device only sends the global header once, right when the sniffer first starts
|
||||
# (typically within a second or two of boot). If this script connects even slightly
|
||||
# late - very likely right after a fresh flash, since esptool itself resets the board -
|
||||
# that header is already gone before we ever see it. Rather than blocking forever
|
||||
# waiting for a header that's never coming, look for whichever marker shows up first.
|
||||
header_match = None if header_written else HEADER_RE.search(buf)
|
||||
pkt_match = PKT_RE.search(buf)
|
||||
|
||||
if header_match and (not pkt_match or header_match.start() < pkt_match.start()):
|
||||
length = int(header_match.group(1))
|
||||
buf = buf[header_match.end():]
|
||||
while len(buf) < length:
|
||||
buf += read_bytes(length - len(buf))
|
||||
header_bytes = buf[:length]
|
||||
outfile.write(header_bytes)
|
||||
outfile.flush()
|
||||
buf = buf[length:]
|
||||
header_written = True
|
||||
if length >= 24:
|
||||
link_type = int.from_bytes(header_bytes[20:24], "little")
|
||||
print(f"Got pcap global header (link type {link_type}) - device is streaming.\n")
|
||||
continue
|
||||
|
||||
if not header_written and pkt_match:
|
||||
link_type = LINKTYPE_IEEE802_11_RADIOTAP
|
||||
outfile.write(build_default_pcap_header(link_type))
|
||||
outfile.flush()
|
||||
header_written = True
|
||||
print("Note: missed the device's one-time pcap header (it was likely sent before "
|
||||
"this script connected, e.g. right after a flash/reset) - assuming WLAN "
|
||||
"radiotap capture and writing a default header instead.\n")
|
||||
# fall through and process pkt_match below, don't discard this packet
|
||||
|
||||
if not pkt_match:
|
||||
# Keep the buffer from growing unbounded while waiting for a marker, but don't
|
||||
# discard anything - a marker could be split across reads.
|
||||
if len(buf) > 65536:
|
||||
buf = buf[-4096:]
|
||||
continue
|
||||
|
||||
length = int(pkt_match.group(1))
|
||||
buf = buf[pkt_match.end():]
|
||||
while len(buf) < length:
|
||||
buf += read_bytes(length - len(buf))
|
||||
record_bytes = buf[:length]
|
||||
outfile.write(record_bytes)
|
||||
outfile.flush()
|
||||
buf = buf[length:]
|
||||
packet_count += 1
|
||||
print(summarize_packet(link_type, record_bytes, packet_count))
|
||||
except KeyboardInterrupt:
|
||||
print(f"\nStopped. {packet_count} packets saved to {outpath}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,508 @@
|
||||
<mxfile host="Electron" agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) draw.io/22.1.2 Chrome/114.0.5735.289 Electron/25.9.4 Safari/537.36" modified="2026-09-10T12:53:19.876Z" etag="qSD0sUq7Dcw_rxL1Y7Bl" version="22.1.2" type="device">
|
||||
<diagram id="90a13364-a465-7bf4-72fc-28e22215d7a0" name="Seite-1">
|
||||
<mxGraphModel dx="1678" dy="1125" grid="1" gridSize="10" guides="1" tooltips="1" connect="0" arrows="1" fold="1" page="1" pageScale="1.5" pageWidth="1169" pageHeight="826" background="none" math="0" shadow="0">
|
||||
<root>
|
||||
<mxCell id="0" style=";html=1;" />
|
||||
<mxCell id="1" style=";html=1;" parent="0" />
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-92" value="" style="rounded=0;whiteSpace=wrap;html=1;dashed=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="950" y="960" width="70" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="3a17f1ce550125da-2" value="Mobile Phone" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="290" y="280" width="650" height="780" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="3a17f1ce550125da-10" value="ESP32-C5" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="1040" y="540" width="580" height="520" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-4" value="<font style="">ESPAR<br>Antenna</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;" parent="1" vertex="1">
|
||||
<mxGeometry x="1150" y="320" width="110" height="90" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-11" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.5;entryY=0;entryDx=0;entryDy=0;exitX=0.499;exitY=0.954;exitDx=0;exitDy=0;exitPerimeter=0;" parent="1" source="Iy5nDPde1Y9wQlhL-YeU-10" target="3a17f1ce550125da-2" edge="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-30" value="static map data" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontColor=#B3B3B3;" parent="Iy5nDPde1Y9wQlhL-YeU-11" connectable="0" vertex="1">
|
||||
<mxGeometry x="-0.2209" y="2" relative="1" as="geometry">
|
||||
<mxPoint x="-8" y="3" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-10" value="<font style="font-size: 18px;">GeoServer</font>" style="ellipse;shape=cloud;whiteSpace=wrap;html=1;align=center;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="540" y="130" width="150" height="100" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-12" value="<font style="">PoTi</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="780" y="670" width="150" height="110" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-13" value="<font style="">GNSS</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="793" y="700" width="60" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-14" value="<font style="">IMU</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="863" y="700" width="60" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-15" value="<font style="">Time Source</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="793" y="740" width="132" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-16" value="<font style="">HMI Support</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="440" y="670" width="171" height="110" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-17" value="<font style="">Display</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="450" y="700" width="70" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-18" value="Haptic" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="530" y="700" width="70" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-19" value="<font style="">Speaker</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="489.5" y="740" width="78" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-84" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="Iy5nDPde1Y9wQlhL-YeU-21" target="Iy5nDPde1Y9wQlhL-YeU-33" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="700" y="770" />
|
||||
<mxPoint x="650" y="770" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-85" value="encoded<br>VAM" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ynnYWYYo9pkcd0MtkCud-84" vertex="1" connectable="0">
|
||||
<mxGeometry x="0.01" y="1" relative="1" as="geometry">
|
||||
<mxPoint y="-12" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-21" value="<font style="">VBS</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="440" y="500" width="410" height="160" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-22" value="<font style="">Encode</font><div><font style="">VAM</font></div>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="651.75" y="600" width="68.5" height="52" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-23" value="<font style="">Transmission</font><div><font style="">Management</font></div>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="730" y="600" width="111.5" height="52" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-24" value="GeoServer to<br>area-risk mapping" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="527" y="370" width="149" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-29" value="<font style="font-size: 20px;">VRU-Tx ITS-S Architecture of micrOBU</font>" style="text;html=1;whiteSpace=wrap;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;rounded=0;labelBorderColor=default;" parent="1" vertex="1">
|
||||
<mxGeometry x="850" y="154" width="223" height="52" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-31" value="VRU Basic Service Management" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="567.5" y="530" width="203" height="52" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-32" value="<font style="">GeoNetworking</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="685" y="947" width="150" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-33" value="<font style="">BTP-B</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="606.5" y="807" width="77" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-53" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.75;entryY=1;entryDx=0;entryDy=0;endArrow=classic;endFill=1;startArrow=classic;startFill=1;" parent="1" source="Iy5nDPde1Y9wQlhL-YeU-34" target="Iy5nDPde1Y9wQlhL-YeU-4" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="1270" y="480" />
|
||||
<mxPoint x="1232" y="480" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-147" value="RF Signal" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ynnYWYYo9pkcd0MtkCud-53" vertex="1" connectable="0">
|
||||
<mxGeometry x="0.4593" y="1" relative="1" as="geometry">
|
||||
<mxPoint x="9" y="27" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-34" value="<font style="">ITS-G5 radio<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="1230" y="568" width="140" height="32" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-38" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.25;entryY=1;entryDx=0;entryDy=0;" parent="1" source="Iy5nDPde1Y9wQlhL-YeU-39" target="Iy5nDPde1Y9wQlhL-YeU-4" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="1100" y="480" />
|
||||
<mxPoint x="1178" y="480" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-41" value="Steering Signal" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ynnYWYYo9pkcd0MtkCud-38" vertex="1" connectable="0">
|
||||
<mxGeometry x="0.2737" y="-1" relative="1" as="geometry">
|
||||
<mxPoint x="39" y="8" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-39" value="<font style="">Antenna<br>Steering<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="1060" y="746" width="80" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-40" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;innerLoopWaypoints=1;exitX=0.5;exitY=1;exitDx=0;exitDy=0;" parent="1" source="3a17f1ce550125da-10" target="3a17f1ce550125da-10" edge="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-1" value="" style="endArrow=none;html=1;rounded=0;entryX=1;entryY=0.5;entryDx=0;entryDy=0;" parent="1" edge="1">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="430" y="790" as="sourcePoint" />
|
||||
<mxPoint x="940" y="790" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-3" value="<font style="">BLE</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="520" y="900" width="330" height="150" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-7" value="<font style="">ATT/ GATT</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="685" y="927" width="150" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-8" value="<font style="">L2CAP</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="685" y="968" width="150" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-9" value="<font style="">BLE Link Layer</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="685" y="1010" width="150" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-10" value="<strong data-end="7298" data-start="7253">≤512 B GATT attribute/application message</strong><br data-end="7301" data-start="7298"><br/><em data-end="7372" data-start="7303">L2CAP / Link Layer segmentation and reassembly handled by BLE stack</em>" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" parent="1" vertex="1">
|
||||
<mxGeometry x="530" y="930" width="140" height="110" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-34" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.5;entryY=1;entryDx=0;entryDy=0;" parent="1" source="ynnYWYYo9pkcd0MtkCud-15" target="ynnYWYYo9pkcd0MtkCud-32" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="1215" y="890" />
|
||||
<mxPoint x="1215" y="890" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-151" value="TX Message" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ynnYWYYo9pkcd0MtkCud-34" vertex="1" connectable="0">
|
||||
<mxGeometry x="-0.2929" y="-2" relative="1" as="geometry">
|
||||
<mxPoint as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-116" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;fontColor=#B3B3B3;strokeColor=#B3B3B3;" parent="1" source="ynnYWYYo9pkcd0MtkCud-15" target="ynnYWYYo9pkcd0MtkCud-3" edge="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-118" value="BLE packet" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontColor=#B3B3B3;" parent="ynnYWYYo9pkcd0MtkCud-116" vertex="1" connectable="0">
|
||||
<mxGeometry x="0.0818" y="1" relative="1" as="geometry">
|
||||
<mxPoint x="6" y="1" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-15" value="<font style="">BLE</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="1130" y="900" width="180" height="150" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-16" value="<font style="">ATT/ GATT</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="1145" y="930" width="150" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-17" value="<font style="">L2CAP</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="1145" y="970" width="150" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-18" value="<font style="">BLE Link Layer</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="1145" y="1010" width="150" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-22" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" parent="1" source="ynnYWYYo9pkcd0MtkCud-9" target="ynnYWYYo9pkcd0MtkCud-18" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="1127" y="1025" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-42" value="segmented<br>BLE packets" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ynnYWYYo9pkcd0MtkCud-22" vertex="1" connectable="0">
|
||||
<mxGeometry x="-0.5098" y="-1" relative="1" as="geometry">
|
||||
<mxPoint x="74" y="-1" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-30" value="<font style="">GeoNetworking SHB</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="531" y="846" width="177" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-90" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;endArrow=none;endFill=0;" parent="1" source="ynnYWYYo9pkcd0MtkCud-31" target="ynnYWYYo9pkcd0MtkCud-30" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="710" y="861" />
|
||||
<mxPoint x="710" y="861" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-31" value="<font style="">synced transmission dir.</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="720.25" y="846" width="210" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-144" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="ynnYWYYo9pkcd0MtkCud-32" target="ynnYWYYo9pkcd0MtkCud-141" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="1185" y="830" />
|
||||
<mxPoint x="1185" y="830" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-150" value="Transmission<br>Power" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ynnYWYYo9pkcd0MtkCud-144" vertex="1" connectable="0">
|
||||
<mxGeometry x="-0.3161" relative="1" as="geometry">
|
||||
<mxPoint y="-3" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-145" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="ynnYWYYo9pkcd0MtkCud-32" target="Iy5nDPde1Y9wQlhL-YeU-39" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="1100" y="830" />
|
||||
<mxPoint x="1100" y="830" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-149" value="Transmission<br>Direction" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ynnYWYYo9pkcd0MtkCud-145" vertex="1" connectable="0">
|
||||
<mxGeometry x="-0.3251" y="1" relative="1" as="geometry">
|
||||
<mxPoint x="1" y="-3" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-32" value="<font style="">Message Splitter<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="1060" y="837" width="310" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-48" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.5;entryY=1;entryDx=0;entryDy=0;" parent="1" source="ynnYWYYo9pkcd0MtkCud-44" target="ynnYWYYo9pkcd0MtkCud-45" edge="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-129" value="MPDU" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ynnYWYYo9pkcd0MtkCud-48" vertex="1" connectable="0">
|
||||
<mxGeometry x="-0.4731" y="2" relative="1" as="geometry">
|
||||
<mxPoint x="2" y="-4" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-44" value="<font style="">MAC<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="1229" y="698" width="140" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-49" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;" parent="1" source="ynnYWYYo9pkcd0MtkCud-45" target="Iy5nDPde1Y9wQlhL-YeU-34" edge="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-130" value="PPDU" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ynnYWYYo9pkcd0MtkCud-49" vertex="1" connectable="0">
|
||||
<mxGeometry x="-0.5724" y="-1" relative="1" as="geometry">
|
||||
<mxPoint x="-1" y="-7" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-127" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#B3B3B3;" parent="1" source="ynnYWYYo9pkcd0MtkCud-45" target="ynnYWYYo9pkcd0MtkCud-126" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="1439" y="640" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-45" value="<font style="">PHY<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="1229" y="630" width="140" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-50" value="" style="verticalLabelPosition=bottom;shadow=0;dashed=0;align=center;html=1;verticalAlign=top;shape=mxgraph.electrical.radio.aerial_-_antenna_1;" parent="1" vertex="1">
|
||||
<mxGeometry x="1187" y="280" width="30" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-56" value="802.11p @ 5.9GHz" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="1" vertex="1" connectable="0">
|
||||
<mxGeometry x="1250.0006451612903" y="310" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-57" value="" style="endArrow=none;html=1;rounded=0;entryX=1;entryY=0.75;entryDx=0;entryDy=0;" parent="1" source="3a17f1ce550125da-2" edge="1">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="590" y="790" as="sourcePoint" />
|
||||
<mxPoint x="940" y="790" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-68" value="" style="endArrow=none;html=1;rounded=0;exitX=0;exitY=0.5;exitDx=0;exitDy=0;entryX=1;entryY=0.5;entryDx=0;entryDy=0;" parent="1" edge="1">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="431" y="490" as="sourcePoint" />
|
||||
<mxPoint x="941" y="490" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-70" value="if rx enabled" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="720" y="320" width="205" height="160" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-71" value="GLOSA" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="730" y="355" width="180" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-72" value="Collision Warning" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="730" y="396" width="180" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-73" value="Recorder/ Logger" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="730" y="435" width="180" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-79" value="Decode<br>VAM" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="451.5" y="600" width="68.5" height="52" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-80" value="Reception<br>Management" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="531" y="600" width="111.5" height="52" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-82" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.71;entryY=1.005;entryDx=0;entryDy=0;entryPerimeter=0;" parent="1" source="Iy5nDPde1Y9wQlhL-YeU-12" target="Iy5nDPde1Y9wQlhL-YeU-21" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="850" y="610" as="targetPoint" />
|
||||
<Array as="points">
|
||||
<mxPoint x="731" y="725" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-83" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.559;entryY=1.01;entryDx=0;entryDy=0;entryPerimeter=0;" parent="1" source="Iy5nDPde1Y9wQlhL-YeU-16" target="Iy5nDPde1Y9wQlhL-YeU-21" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="670" y="725" />
|
||||
<mxPoint x="670" y="680" />
|
||||
<mxPoint x="669" y="680" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-86" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="Iy5nDPde1Y9wQlhL-YeU-33" target="ynnYWYYo9pkcd0MtkCud-30" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="596" y="840" as="targetPoint" />
|
||||
<Array as="points">
|
||||
<mxPoint x="570" y="822" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-93" value="encrypted" style="text;html=1;align=center;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;" parent="1" vertex="1">
|
||||
<mxGeometry x="946" y="935" width="80" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-95" value="<font style="">Platform<br>Security<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="1500" y="580" width="110" height="470" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-99" value="<font style="">Secure<br>Boot<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="1510" y="837" width="90" height="48" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-102" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;fontColor=#B3B3B3;strokeColor=#B3B3B3;" parent="1" source="ynnYWYYo9pkcd0MtkCud-100" target="ynnYWYYo9pkcd0MtkCud-15" edge="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-153" value="BLE Credentials" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontColor=#B3B3B3;" parent="ynnYWYYo9pkcd0MtkCud-102" vertex="1" connectable="0">
|
||||
<mxGeometry x="0.5722" y="1" relative="1" as="geometry">
|
||||
<mxPoint x="52" y="9" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-100" value="<font style="">BLE<br>credentials<br>config<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="1510" y="965" width="90" height="73" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-101" value="<font style="">Debug<br>lockdown<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="1510" y="902" width="90" height="48" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-106" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="ynnYWYYo9pkcd0MtkCud-103" target="Iy5nDPde1Y9wQlhL-YeU-21" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="430" y="580" />
|
||||
<mxPoint x="430" y="580" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-103" value="<font style="">Platform<br>Security<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="300" y="300" width="120" height="740" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-105" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="ynnYWYYo9pkcd0MtkCud-104" target="ynnYWYYo9pkcd0MtkCud-3" edge="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-104" value="<font style="">BLE<br>credentials<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="310" y="982" width="100" height="48" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-108" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=1.008;entryY=0.559;entryDx=0;entryDy=0;entryPerimeter=0;" parent="1" source="Iy5nDPde1Y9wQlhL-YeU-12" target="Iy5nDPde1Y9wQlhL-YeU-33" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="850" y="824" />
|
||||
<mxPoint x="684" y="824" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-109" value="PCI" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ynnYWYYo9pkcd0MtkCud-108" vertex="1" connectable="0">
|
||||
<mxGeometry x="-0.0782" y="2" relative="1" as="geometry">
|
||||
<mxPoint as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-110" value="<font style="">LLC/SNAP<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="1230" y="760" width="140" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-111" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="ynnYWYYo9pkcd0MtkCud-32" target="ynnYWYYo9pkcd0MtkCud-110" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="1300" y="810" as="targetPoint" />
|
||||
<Array as="points">
|
||||
<mxPoint x="1300" y="830" />
|
||||
<mxPoint x="1300" y="830" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-112" value="GNPDU" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ynnYWYYo9pkcd0MtkCud-111" vertex="1" connectable="0">
|
||||
<mxGeometry x="-0.1526" relative="1" as="geometry">
|
||||
<mxPoint as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-113" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.502;entryY=1;entryDx=0;entryDy=0;entryPerimeter=0;" parent="1" source="ynnYWYYo9pkcd0MtkCud-110" target="ynnYWYYo9pkcd0MtkCud-44" edge="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-114" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.297;entryY=-0.004;entryDx=0;entryDy=0;entryPerimeter=0;" parent="1" source="ynnYWYYo9pkcd0MtkCud-30" target="ynnYWYYo9pkcd0MtkCud-3" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="618" y="890" />
|
||||
<mxPoint x="618" y="890" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-115" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="ynnYWYYo9pkcd0MtkCud-31" target="ynnYWYYo9pkcd0MtkCud-3" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="810" y="896" />
|
||||
<mxPoint x="810" y="896" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-119" value="<font style="">Certificate<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="310" y="720" width="100" height="33" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-122" value="<font style="">Private Key Handling<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="310" y="920" width="100" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-123" value="<font style="">Signer Selection<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="310" y="770" width="100" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-125" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="ynnYWYYo9pkcd0MtkCud-124" target="ynnYWYYo9pkcd0MtkCud-30" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="470" y="865" />
|
||||
<mxPoint x="470" y="865" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-124" value="<font style="">Secured<br>Message<br>Creation<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="310" y="837" width="100" height="70" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-126" value="<font style="">DCC-ACC Calculation<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="1390" y="655" width="97" height="55" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-128" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;jumpStyle=arc;fontColor=#B3B3B3;strokeColor=#B3B3B3;" parent="1" source="ynnYWYYo9pkcd0MtkCud-126" target="ynnYWYYo9pkcd0MtkCud-15" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="1129" y="930" as="targetPoint" />
|
||||
<Array as="points">
|
||||
<mxPoint x="1440" y="930" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-152" value="DCC Feedback" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontColor=#B3B3B3;" parent="ynnYWYYo9pkcd0MtkCud-128" vertex="1" connectable="0">
|
||||
<mxGeometry x="0.7303" relative="1" as="geometry">
|
||||
<mxPoint x="13" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-131" value="" style="endArrow=none;html=1;rounded=0;entryX=1;entryY=0.5;entryDx=0;entryDy=0;" parent="1" edge="1">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="430" y="793" as="sourcePoint" />
|
||||
<mxPoint x="940" y="793" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-133" value="" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="847" y="787" width="6" height="9" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-136" value="" style="endArrow=none;html=1;rounded=0;exitX=0;exitY=0.5;exitDx=0;exitDy=0;entryX=1;entryY=0.5;entryDx=0;entryDy=0;" parent="1" edge="1">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="431" y="883.78" as="sourcePoint" />
|
||||
<mxPoint x="941" y="883.78" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-137" value="" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="647" y="787" width="6" height="9" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-140" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="ynnYWYYo9pkcd0MtkCud-139" target="Iy5nDPde1Y9wQlhL-YeU-21" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="895" y="580" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-139" value="<font style="">DCC-<br>FAC<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="865" y="500" width="60" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-142" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="ynnYWYYo9pkcd0MtkCud-141" target="Iy5nDPde1Y9wQlhL-YeU-34" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="1185" y="584" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-148" value="TX power <br>config" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ynnYWYYo9pkcd0MtkCud-142" vertex="1" connectable="0">
|
||||
<mxGeometry x="-0.4822" y="1" relative="1" as="geometry">
|
||||
<mxPoint as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-141" value="<font style="">Radio<br>Control<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="1150" y="746" width="70" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-155" value="" style="endArrow=none;html=1;rounded=0;exitX=0;exitY=0.5;exitDx=0;exitDy=0;entryX=1;entryY=0.5;entryDx=0;entryDy=0;" parent="1" edge="1">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="431" y="887" as="sourcePoint" />
|
||||
<mxPoint x="941" y="887" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-154" value="" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="615" y="881" width="6" height="9" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-157" value="" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="807" y="881" width="6" height="9" as="geometry" />
|
||||
</mxCell>
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
</mxfile>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 237 KiB |
@@ -51,3 +51,49 @@ Known gaps, tracked as TODOs in the source: no real GNSS (lat/long hardcoded
|
||||
0), no real time source (detectionTime/referenceTime hardcoded 0, decodes as
|
||||
2004-01-01), fixed (non-rotating) pseudonym MAC, SHB instead of GeoBroadcast
|
||||
(no multi-hop forwarding), unsecured (no IEEE 1609.2 signing).
|
||||
|
||||
## Running it as a bench beacon
|
||||
|
||||
This firmware needs no phone: it beacons a CAM every second by itself
|
||||
(`TX_INTERVAL_MS`) from station `0x0BADC0DE` (195936478), stationType 5
|
||||
(passengerCar), at the hardcoded bench position, under the fixed MAC
|
||||
`02:00:00:00:00:01`, on 5900 MHz. That makes it the quickest way to put known,
|
||||
repeatable traffic on air, and it is how the 4-bit `yawRateConfidence` encoding
|
||||
was confirmed over the air on 2026-09-14.
|
||||
|
||||
A board with only one USB-C port is fine. This firmware's console is on UART0,
|
||||
so such a board shows no log output, but nothing here needs the console.
|
||||
|
||||
Flash it from the toolchain terminal (ESP-IDF 5.5.4, see the table above):
|
||||
|
||||
```powershell
|
||||
cd C:\Users\Ashin\AndroidStudioProjects\MicrOBU\obu-cam-transmistter
|
||||
idf.py -p COM10 -b 921600 flash
|
||||
```
|
||||
|
||||
Or flash the existing build without any toolchain terminal:
|
||||
|
||||
```powershell
|
||||
cd obu-cam-transmistter\build
|
||||
C:\Espressif\python_env\idf5.5_py3.11_env\Scripts\python.exe -m esptool --chip esp32c5 -p COM10 -b 921600 write_flash --flash_mode dio --flash_freq 80m --flash_size 2MB 0x2000 bootloader/bootloader.bin 0x8000 partition_table/partition-table.bin 0x10000 obu_firmware.bin
|
||||
```
|
||||
|
||||
It starts beaconing as soon as it boots, so there is nothing to start by hand,
|
||||
and unplugging it is how you stop it.
|
||||
|
||||
**It transmits under the same MAC as the phone's CAM pinger**, so on air the two
|
||||
are told apart by station ID (195936478 here, 999999 for the pinger), never by
|
||||
source address.
|
||||
|
||||
To see what it is sending, capture on the sniffer board and decode:
|
||||
|
||||
```powershell
|
||||
cd capture
|
||||
py -3.11 live_capture.py COM8
|
||||
py -3.11 ..\obu-firmware\test\pcap_gn_tally.py recordings\capture_<timestamp>.pcap
|
||||
```
|
||||
|
||||
The tally lists it as SHB / port 2001 / lifetime `0x05`. For the message itself,
|
||||
decode the payload with `asn1tools` against `asn1/cam_1_4_1.asn` +
|
||||
`asn1/cdd_1_3_1_1.asn`; re-encoding must return the identical bytes. On
|
||||
2026-09-14, 72 of 72 frames did.
|
||||
|
||||
@@ -117,9 +117,13 @@ int cam_encode(const cam_fields_t *f, uint8_t *buf, size_t buf_len)
|
||||
bw_put_bits(&bw, 0, 1); // extension bit: value is in the root list
|
||||
bw_put_bits(&bw, 2, 2); // unavailable(2)
|
||||
// YawRate: YawRateValue(-32766..32767)->16 (offset from -32766),
|
||||
// YawRateConfidence ENUM 8 values -> 3 bits
|
||||
// YawRateConfidence ENUM with NINE values, degSec-000-01(0) .. unavailable(8)
|
||||
// (cdd_1_3_1_1.asn) -> 4 bits. This wrote 3 bits with value 7, one bit short and the
|
||||
// wrong symbol (7 is outOfRange), so every field after it shifted for any
|
||||
// standards-compliant receiver. The app's CamUperCodec.kt fixed the same line on
|
||||
// 2026-08-20; this copy was missed until 2026-09-11.
|
||||
bw_put_bits(&bw, 32767 - (uint32_t)(-32766), 16); // yawRateValue: unavailable(32767)
|
||||
bw_put_bits(&bw, 7, 3); // yawRateConfidence: unavailable(7)
|
||||
bw_put_bits(&bw, 8, 4); // yawRateConfidence: unavailable(8)
|
||||
|
||||
// ---- LowFrequencyContainer ---- CHOICE { basicVehicleContainerLowFrequency,
|
||||
// ... } - EXTENSIBLE, 1 root alternative (index needs 0 bits).
|
||||
|
||||
@@ -19,7 +19,10 @@ int geonet_wrap_shb(const uint8_t *its_payload, int its_len,
|
||||
// ---- GN Basic Header (4 bytes) ---- (EN 302 636-4-1 clause 9.6)
|
||||
*p++ = (uint8_t)((1 << 4) | 1); // version=1, NextHeader=1 (Common Header, unsecured)
|
||||
*p++ = 0x00; // reserved
|
||||
*p++ = 0x83; // lifetime (~60s in the base/multiplier encoding) - tune if needed
|
||||
// Lifetime: multiplier in the upper 6 bits, base in the lower 2 (0 = 50 ms, 1 = 1 s, 2 = 10 s,
|
||||
// 3 = 100 s). 0x05 = 1 x 1 s, what real stations send their CAMs with. Was 0x83, commented as
|
||||
// ~60 s but decoding to 32 x 100 s = 3200 s. See obu-firmware's geonet.c.
|
||||
*p++ = 0x05;
|
||||
*p++ = 1; // remaining hop limit = 1 (SHB single-hop; matches CAM in the Rust reference)
|
||||
|
||||
// ---- GN Common Header (8 bytes) ---- (clause 9.7)
|
||||
|
||||
@@ -122,9 +122,13 @@ int cam_encode(const cam_fields_t *f, uint8_t *buf, size_t buf_len)
|
||||
bw_put_bits(&bw, 0, 1); // extension bit: value is in the root list
|
||||
bw_put_bits(&bw, 2, 2); // unavailable(2)
|
||||
// YawRate: YawRateValue(-32766..32767)->16 (offset from -32766),
|
||||
// YawRateConfidence ENUM 8 values -> 3 bits
|
||||
// YawRateConfidence ENUM with NINE values, degSec-000-01(0) .. unavailable(8)
|
||||
// (cdd_1_3_1_1.asn) -> 4 bits. This wrote 3 bits with value 7, one bit short and the
|
||||
// wrong symbol (7 is outOfRange), so every field after it shifted for any
|
||||
// standards-compliant receiver. The app's CamUperCodec.kt fixed the same line on
|
||||
// 2026-08-20; this reference copy was missed until 2026-09-11.
|
||||
bw_put_bits(&bw, 32767 - (uint32_t)(-32766), 16); // yawRateValue: unavailable(32767)
|
||||
bw_put_bits(&bw, 7, 3); // yawRateConfidence: unavailable(7)
|
||||
bw_put_bits(&bw, 8, 4); // yawRateConfidence: unavailable(8)
|
||||
|
||||
// ---- LowFrequencyContainer ---- CHOICE { basicVehicleContainerLowFrequency,
|
||||
// ... } - EXTENSIBLE, 1 root alternative (index needs 0 bits).
|
||||
|
||||
@@ -33,7 +33,12 @@ int geonet_wrap_shb(const uint8_t *its_payload, int its_len,
|
||||
// ---- GN Basic Header (4 bytes) ---- (EN 302 636-4-1 clause 9.6)
|
||||
*p++ = (uint8_t)((1 << 4) | 1); // version=1, NextHeader=1 (Common Header, unsecured)
|
||||
*p++ = 0x00; // reserved
|
||||
*p++ = 0x83; // lifetime (~60s in the base/multiplier encoding) - tune if needed
|
||||
// Lifetime: multiplier in the upper 6 bits, base in the lower 2 (0 = 50 ms, 1 = 1 s, 2 = 10 s,
|
||||
// 3 = 100 s). 0x05 = 1 x 1 s, which is what every other station in
|
||||
// its-g5-receiver-firmware/recordings sends its CAMs with. This was 0x83, commented as ~60 s but
|
||||
// decoding to 32 x 100 s = 3200 s, beyond the 600 s itsGnMaxPacketLifetime a sender may use at
|
||||
// all. A DENM would want a longer one (the captured GeoBroadcast DENMs use 0x79, 30 s).
|
||||
*p++ = 0x05;
|
||||
*p++ = 1; // remaining hop limit = 1 (SHB single-hop; matches CAM in the Rust reference)
|
||||
|
||||
// ---- GN Common Header (8 bytes) ---- (clause 9.7)
|
||||
|
||||
+131
-15
@@ -28,9 +28,20 @@
|
||||
#define GN_HEADER_TYPE_TSB (5) // Topologically-Scoped Broadcast
|
||||
#define GN_HEADER_SUBTYPE_SINGLE_HOP (0)
|
||||
|
||||
#define GN_NEXT_HEADER_COMMON (1) // unsecured; 2 would be a secured packet
|
||||
#define GN_NEXT_HEADER_COMMON (1) // unsecured: the Common Header follows
|
||||
#define GN_NEXT_HEADER_SECURED (2) // a TS 103 097 envelope follows, Common Header inside it
|
||||
#define GN_COMMON_NEXT_HEADER_BTP_B (2)
|
||||
|
||||
// Common Header field (clause 9.7): length of everything after the GeoNetworking headers, i.e.
|
||||
// the BTP-B header plus the ITS payload.
|
||||
#define GN_COMMON_PAYLOAD_LEN_OFFSET (4)
|
||||
|
||||
// IEEE 1609.2 / TS 103 097 envelope, COER encoded - see unwrap_secured().
|
||||
#define IEEE1609DOT2_VERSION (3)
|
||||
#define CONTENT_TAG_UNSECURED_DATA (0x80) // Ieee1609Dot2Content CHOICE, context tag 0
|
||||
#define CONTENT_TAG_SIGNED_DATA (0x81) // context tag 1
|
||||
#define SIGNED_PAYLOAD_HAS_DATA (0x40) // SignedDataPayload preamble: `data` present
|
||||
|
||||
#define BTP_DEST_PORT_CAM (2001) // ETSI TS 103 248
|
||||
#define BTP_DEST_PORT_DENM (2002)
|
||||
// NOTE the crossover: SPATEM is BTP port 2004 but ItsPduHeader messageID 4, while MAPEM is port
|
||||
@@ -51,6 +62,90 @@ static uint16_t be16(const uint8_t *p)
|
||||
return (uint16_t)(((uint16_t)p[0] << 8) | (uint16_t)p[1]);
|
||||
}
|
||||
|
||||
// COER length determinant (ITU-T X.696): a first byte below 0x80 is the length itself; otherwise
|
||||
// its low 7 bits count the big-endian length bytes that follow. Two of them cover anything this
|
||||
// radio can deliver. Returns how many bytes the determinant occupies, or 0 if it does not fit in
|
||||
// `avail` or uses a form this does not read.
|
||||
static int coer_length(const uint8_t *p, int avail, int *len)
|
||||
{
|
||||
if (avail < 1) {
|
||||
return 0;
|
||||
}
|
||||
if (p[0] < 0x80) {
|
||||
*len = p[0];
|
||||
return 1;
|
||||
}
|
||||
const int n = p[0] & 0x7F;
|
||||
if (n < 1 || n > 2 || avail < 1 + n) {
|
||||
return 0;
|
||||
}
|
||||
int v = 0;
|
||||
for (int i = 1; i <= n; i++) {
|
||||
v = (v << 8) | p[i];
|
||||
}
|
||||
*len = v;
|
||||
return 1 + n;
|
||||
}
|
||||
|
||||
// Locates the GeoNetworking packet inside a secured one. `offset` points just past the Basic
|
||||
// Header. Returns the offset of the inner Common Header and sets *inner_end to where the envelope
|
||||
// says the inner packet ends - which lies beyond frame_len if the capture was cut short - or
|
||||
// returns -1 for anything this does not unwrap.
|
||||
//
|
||||
// The envelope is an Ieee1609Dot2Data (IEEE 1609.2, profiled by TS 103 097 v1.3.1 and later),
|
||||
// COER encoded. A signed message starts:
|
||||
//
|
||||
// 03 protocolVersion 3
|
||||
// 81 content = signedData
|
||||
// 00 hashId (sha256; any one-byte value is accepted - the hash is not checked)
|
||||
// 40 tbsData.payload preamble: `data` present (bit 6)
|
||||
// 03 80 <len> payload.data: an Ieee1609Dot2Data holding unsecuredData of <len> bytes, which
|
||||
// are the Common Header, extended header, BTP-B header and ITS payload
|
||||
// ... headerInfo, signer, signature: not read
|
||||
//
|
||||
// The inner packet comes first inside tbsData, so it is found without parsing the certificate
|
||||
// or the signature, and its explicit length is what separates it from them. The shape is
|
||||
// measured, not only read from the standard: all 157 signed frames in
|
||||
// capture_20260817_171055.pcap have it (150 CAM, 7 GeoBroadcast DENM; <len> in all three COER
|
||||
// forms), and asn1tools decodes every one of them to the same unsecuredData. A top-level
|
||||
// unsecuredData (03 80 <len>, no signature at all) is accepted too.
|
||||
static int unwrap_secured(const uint8_t *frame, int offset, int frame_len,
|
||||
int *inner_end, bool *is_signed)
|
||||
{
|
||||
const uint8_t *p = frame + offset;
|
||||
const int avail = frame_len - offset;
|
||||
int i;
|
||||
|
||||
if (avail < 2 || p[0] != IEEE1609DOT2_VERSION) {
|
||||
return -1; // includes the legacy TS 103 097 v1.2.1 envelope, protocolVersion 2
|
||||
}
|
||||
if (p[1] == CONTENT_TAG_SIGNED_DATA) {
|
||||
if (avail < 6 ||
|
||||
p[2] >= 0x80 || // hashId: a one-byte enumerated value
|
||||
!(p[3] & SIGNED_PAYLOAD_HAS_DATA) || // signs only a hash of data sent elsewhere
|
||||
p[4] != IEEE1609DOT2_VERSION ||
|
||||
p[5] != CONTENT_TAG_UNSECURED_DATA) { // nested signing or encryption
|
||||
return -1;
|
||||
}
|
||||
i = 6;
|
||||
*is_signed = true;
|
||||
} else if (p[1] == CONTENT_TAG_UNSECURED_DATA) {
|
||||
i = 2;
|
||||
*is_signed = false;
|
||||
} else {
|
||||
return -1; // encryptedData, certificate requests
|
||||
}
|
||||
|
||||
int len;
|
||||
const int used = coer_length(p + i, avail - i, &len);
|
||||
if (used == 0) {
|
||||
return -1;
|
||||
}
|
||||
i += used;
|
||||
*inner_end = offset + i + len;
|
||||
return offset + i;
|
||||
}
|
||||
|
||||
bool gn_unwrap_its(const uint8_t *frame, int frame_len, gn_rx_t *out)
|
||||
{
|
||||
if (!frame || !out || frame_len < IEEE80211_HEADER_LEN) {
|
||||
@@ -91,22 +186,33 @@ bool gn_unwrap_its(const uint8_t *frame, int frame_len, gn_rx_t *out)
|
||||
if (frame_len < offset + GN_BASIC_HEADER_LEN) {
|
||||
return false;
|
||||
}
|
||||
// NextHeader distinguishes an unsecured packet (1 = Common Header follows) from a secured one
|
||||
// (2 = a TS 103 097 SecuredMessage follows, with the Common Header buried inside it at a
|
||||
// variable offset). Checking this rather than blindly skipping means a secured packet is
|
||||
// rejected cleanly instead of having its security envelope misread as a Common Header.
|
||||
if ((frame[offset] & 0x0F) != GN_NEXT_HEADER_COMMON) {
|
||||
return false;
|
||||
}
|
||||
const uint8_t basic_next_header = frame[offset] & 0x0F;
|
||||
offset += GN_BASIC_HEADER_LEN;
|
||||
|
||||
// The headers from here on must end before `limit`: the end of the frame, or for a secured
|
||||
// packet the end of the envelope's inner packet if that comes first.
|
||||
int limit = frame_len;
|
||||
int envelope_end = -1;
|
||||
if (basic_next_header == GN_NEXT_HEADER_SECURED) {
|
||||
offset = unwrap_secured(frame, offset, frame_len, &envelope_end, &out->signed_unverified);
|
||||
if (offset < 0) {
|
||||
return false;
|
||||
}
|
||||
if (envelope_end < limit) {
|
||||
limit = envelope_end;
|
||||
}
|
||||
} else if (basic_next_header != GN_NEXT_HEADER_COMMON) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---- GN Common Header (8 bytes) ----
|
||||
if (frame_len < offset + GN_COMMON_HEADER_LEN) {
|
||||
if (limit < offset + GN_COMMON_HEADER_LEN) {
|
||||
return false;
|
||||
}
|
||||
uint8_t next_header = (frame[offset + 0] >> 4) & 0x0F;
|
||||
uint8_t header_type = (frame[offset + 1] >> 4) & 0x0F;
|
||||
uint8_t header_subtype = frame[offset + 1] & 0x0F;
|
||||
const int gn_payload_len = be16(frame + offset + GN_COMMON_PAYLOAD_LEN_OFFSET);
|
||||
if (next_header != GN_COMMON_NEXT_HEADER_BTP_B) {
|
||||
return false;
|
||||
}
|
||||
@@ -126,7 +232,7 @@ bool gn_unwrap_its(const uint8_t *frame, int frame_len, gn_rx_t *out)
|
||||
} else {
|
||||
return false; // Beacon / GeoUnicast / GeoAnycast / multi-hop TSB - see header comment
|
||||
}
|
||||
if (frame_len < offset + ext_len) {
|
||||
if (limit < offset + ext_len) {
|
||||
return false;
|
||||
}
|
||||
if (is_gbc) {
|
||||
@@ -138,7 +244,7 @@ bool gn_unwrap_its(const uint8_t *frame, int frame_len, gn_rx_t *out)
|
||||
offset += ext_len;
|
||||
|
||||
// ---- BTP-B header (4 bytes) ----
|
||||
if (frame_len < offset + BTP_B_HEADER_LEN) {
|
||||
if (limit < offset + BTP_B_HEADER_LEN) {
|
||||
return false;
|
||||
}
|
||||
uint16_t dest_port = be16(frame + offset);
|
||||
@@ -146,16 +252,26 @@ bool gn_unwrap_its(const uint8_t *frame, int frame_len, gn_rx_t *out)
|
||||
dest_port != BTP_DEST_PORT_SPATEM) {
|
||||
return false;
|
||||
}
|
||||
offset += BTP_B_HEADER_LEN;
|
||||
|
||||
// ---- Whatever's left is the ITS UPER payload ----
|
||||
int payload_len = frame_len - offset;
|
||||
// ---- ITS payload: exactly as long as the Common Header declares ----
|
||||
// Not "whatever is left of the frame": see "Payload bounds" in gn_unwrap.h for the 8 trailing
|
||||
// bytes every received frame carries and the signature that follows a secured packet.
|
||||
if (gn_payload_len <= BTP_B_HEADER_LEN) {
|
||||
return false; // no ITS payload at all
|
||||
}
|
||||
const int payload_start = offset + BTP_B_HEADER_LEN;
|
||||
const int payload_end = offset + gn_payload_len;
|
||||
if (envelope_end >= 0 && payload_end > envelope_end) {
|
||||
return false; // the inner packet claims more than its envelope holds
|
||||
}
|
||||
out->truncated = payload_end > frame_len;
|
||||
const int payload_len = (out->truncated ? frame_len : payload_end) - payload_start;
|
||||
if (payload_len <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
out->btp_dest_port = dest_port;
|
||||
out->payload = frame + offset;
|
||||
out->payload = frame + payload_start;
|
||||
out->payload_len = payload_len;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
|
||||
// Inverse of geonet_wrap_shb() + dot11p_build_frame(): takes a raw 802.11 frame as delivered by
|
||||
// the WiFi driver's promiscuous RX callback and strips 802.11 header -> LLC/SNAP ->
|
||||
// GeoNetworking Basic/Common/extended header -> BTP-B header, leaving the ITS payload (a UPER
|
||||
// message) plus the metadata the phone needs to know what it received.
|
||||
// GeoNetworking Basic Header -> [security envelope] -> Common/extended header -> BTP-B header,
|
||||
// leaving the ITS payload (a UPER message) plus the metadata the phone needs to know what it
|
||||
// received.
|
||||
//
|
||||
// ---- Supported GeoNetworking header types --------------------------------------------------
|
||||
// Two shapes, chosen by the Common Header's HeaderType, with DIFFERENT extended-header lengths:
|
||||
@@ -28,6 +29,23 @@
|
||||
// Beacon, GeoUnicast, GeoAnycast and multi-hop TSB are still rejected - nothing this project
|
||||
// talks to sends them, and each has its own extended-header length that would need measuring.
|
||||
//
|
||||
// ---- Secured packets -----------------------------------------------------------------------
|
||||
// A Basic Header NextHeader of 2 means an ETSI TS 103 097 (IEEE 1609.2) envelope follows, with
|
||||
// the Common Header onward inside it. Signed messages are unwrapped WITHOUT verifying the
|
||||
// signature or the certificate - this firmware has no trust store - and are reported with
|
||||
// signed_unverified set so the phone can tell. Most real traffic is signed: the 2026-08-17
|
||||
// capture held 157 signed frames from 15 source MACs. Encrypted payloads, nested signing and the
|
||||
// legacy v1.2.1 envelope are rejected. The layout is documented at unwrap_secured() in
|
||||
// gn_unwrap.c. Before 2026-09-11 every secured packet was rejected.
|
||||
//
|
||||
// ---- Payload bounds ------------------------------------------------------------------------
|
||||
// The payload is exactly as long as the Common Header's payload-length field says, minus the
|
||||
// BTP-B header - not "the rest of the frame". After the message comes, in a signed packet, the
|
||||
// signature; and every frame recorded through this chip's promiscuous RX API (~15 000 of them)
|
||||
// ends in 8 more bytes that are not part of the 802.11 frame and not a valid FCS. Until
|
||||
// 2026-09-11 those 8 bytes were forwarded to the phone as the tail of every message. UPER
|
||||
// decoders stop where the message ends, which is why nothing visibly broke.
|
||||
//
|
||||
// ---- Accepted BTP-B ports (ETSI TS 103 248) ------------------------------------------------
|
||||
// 2001 (CAM), 2002 (DENM) and 2004 (SPATEM). MAPEM (2003) and the rest are deliberately not
|
||||
// accepted yet: the phone has no decoder for them, so forwarding would just burn serial
|
||||
@@ -38,17 +56,11 @@
|
||||
// counted as an oversize drop rather than forwarded. The bench RSU trigger emits ~58-byte SPATEMs
|
||||
// and is unaffected, but real road RSUs measured 555 bytes median and 1243 max (2026-03-18 drive,
|
||||
// 79k messages), i.e. roughly 70% would be dropped. Raising the cap is deliberately deferred: it
|
||||
// also requires enlarging RX_FRAME_MAX_LEN and moving rx_item_t off the WiFi callback stack,
|
||||
// which at that size would overflow it.
|
||||
// also requires enlarging main.c's RX_FRAME_MAX_LEN.
|
||||
//
|
||||
// ---- What is NOT handled -------------------------------------------------------------------
|
||||
// Secured packets (GN Basic Header NextHeader=2, i.e. ETSI TS 103 097 signed messages). The
|
||||
// units on this bench run with ItsGnSecurity=0 so everything observed is unsecured; a secured
|
||||
// packet is rejected rather than mis-parsed.
|
||||
//
|
||||
// No FCS/CRC check: the WiFi driver has already validated and stripped it.
|
||||
// No FCS/CRC check here: the WiFi driver has already validated the frame.
|
||||
typedef struct {
|
||||
// BTP-B destination port, identifying the service: 2001 = CAM, 2002 = DENM.
|
||||
// BTP-B destination port, identifying the service: 2001 = CAM, 2002 = DENM, 2004 = SPATEM.
|
||||
uint16_t btp_dest_port;
|
||||
|
||||
// ITS payload (UPER message bytes). Points INTO the caller's `frame` buffer - NOT a copy, so
|
||||
@@ -64,11 +76,20 @@ typedef struct {
|
||||
int32_t geo_area_lat_tenmicrodeg;
|
||||
int32_t geo_area_lon_tenmicrodeg;
|
||||
uint16_t geo_area_distance_a_m;
|
||||
|
||||
// The packet arrived inside a TS 103 097 signed envelope. The signature was NOT checked.
|
||||
bool signed_unverified;
|
||||
|
||||
// The frame ended before the payload its headers declare. On the board only main.c's
|
||||
// RX_FRAME_MAX_LEN capture limit causes this (the driver drops frames that fail their FCS).
|
||||
// payload/payload_len then cover just the part that arrived, so it must not be forwarded.
|
||||
bool truncated;
|
||||
} gn_rx_t;
|
||||
|
||||
// Returns true and fills *out if this was a well-formed, supported ITS frame. Returns false
|
||||
// otherwise (wrong ethertype, secured, unsupported header type, unaccepted BTP port, truncated,
|
||||
// or promiscuous-capture garbage) - all common and expected on an open-air capture, so the caller
|
||||
// Returns true and fills *out if this was a well-formed, supported ITS frame - check `truncated`
|
||||
// before using the payload. Returns false otherwise (wrong ethertype, encrypted or unsupported
|
||||
// envelope, unsupported header type, unaccepted BTP port, headers cut short, or
|
||||
// promiscuous-capture garbage) - all common and expected on an open-air capture, so the caller
|
||||
// should treat false as "not for us", not as an error worth logging per frame.
|
||||
bool gn_unwrap_its(const uint8_t *frame, int frame_len, gn_rx_t *out);
|
||||
|
||||
|
||||
@@ -280,8 +280,16 @@ static void rx_forward_task(void *arg)
|
||||
// returning false here is the common case, not an error, so it isn't logged per frame.
|
||||
gn_rx_t rx;
|
||||
if (gn_unwrap_its(item.data, item.len, &rx)) {
|
||||
if (rx.truncated) {
|
||||
// Longer than the RX_FRAME_MAX_LEN bytes captured above, so it cannot be forwarded
|
||||
// whole - and at that size it could not cross the serial link either. Counted as
|
||||
// an oversize drop, as it was when the cut-off frame still reached
|
||||
// serial_link_send_v2x_rx() and failed the size check there.
|
||||
serial_link_note_oversize_drop(rx.btp_dest_port);
|
||||
continue;
|
||||
}
|
||||
serial_link_send_v2x_rx(rx.btp_dest_port, item.rssi,
|
||||
rx.has_geo_area,
|
||||
rx.has_geo_area, rx.signed_unverified,
|
||||
rx.geo_area_lat_tenmicrodeg,
|
||||
rx.geo_area_lon_tenmicrodeg,
|
||||
rx.geo_area_distance_a_m,
|
||||
|
||||
@@ -38,6 +38,13 @@ void serial_link_note_tx_failure(void)
|
||||
bump(&s_tx_failures);
|
||||
}
|
||||
|
||||
void serial_link_note_oversize_drop(uint16_t btp_dest_port)
|
||||
{
|
||||
bump(&s_oversize_drops);
|
||||
ESP_LOGW(TAG, "port %u message larger than the RX capture buffer, total oversize drops %u",
|
||||
btp_dest_port, s_oversize_drops);
|
||||
}
|
||||
|
||||
// ---- CRC-16/CCITT-FALSE (poly 0x1021, init 0xFFFF, no reflect, no xorout) ----
|
||||
// Bytewise (no table) - frames here are at most SERIAL_LINK_MAX_PAYLOAD + 3 bytes, so table
|
||||
// lookup isn't worth the flash/RAM tradeoff. MUST match the Kotlin-side implementation exactly
|
||||
@@ -114,7 +121,7 @@ static bool send_frame(uint8_t type, const uint8_t *payload, int len)
|
||||
}
|
||||
|
||||
bool serial_link_send_v2x_rx(uint16_t btp_dest_port, int8_t rssi,
|
||||
bool has_geo_area,
|
||||
bool has_geo_area, bool signed_unverified,
|
||||
int32_t geo_area_lat_tenmicrodeg,
|
||||
int32_t geo_area_lon_tenmicrodeg,
|
||||
uint16_t geo_area_distance_a_m,
|
||||
@@ -138,7 +145,7 @@ bool serial_link_send_v2x_rx(uint16_t btp_dest_port, int8_t rssi,
|
||||
s_v2x_payload[0] = (uint8_t)(btp_dest_port & 0xFF);
|
||||
s_v2x_payload[1] = (uint8_t)((btp_dest_port >> 8) & 0xFF);
|
||||
s_v2x_payload[2] = (uint8_t)rssi;
|
||||
s_v2x_payload[3] = has_geo_area ? 0x01 : 0x00;
|
||||
s_v2x_payload[3] = (uint8_t)((has_geo_area ? 0x01 : 0x00) | (signed_unverified ? 0x02 : 0x00));
|
||||
uint32_t lat = (uint32_t)geo_area_lat_tenmicrodeg;
|
||||
uint32_t lon = (uint32_t)geo_area_lon_tenmicrodeg;
|
||||
s_v2x_payload[4] = (uint8_t)(lat & 0xFF);
|
||||
|
||||
@@ -37,10 +37,13 @@
|
||||
// [0..1] btp_dest_port uint16 LE 2001 = CAM, 2002 = DENM (ETSI TS 103 248)
|
||||
// [2] rssi int8 dBm, from the promiscuous RX metadata
|
||||
// [3] flags uint8 bit0: geo area fields below are valid
|
||||
// bit1: arrived signed (TS 103 097), signature NOT
|
||||
// verified. An app that tests only bit0 ignores it.
|
||||
// [4..7] geo_area_lat int32 LE 1/10 microdegree, GeoBroadcast destination area
|
||||
// [8..11] geo_area_lon int32 LE 1/10 microdegree
|
||||
// [12..13] geo_area_dist uint16 LE Distance A, metres (relevance radius for a circle)
|
||||
// [14..] UPER message bytes
|
||||
// [14..] UPER message bytes - exactly the message. Before 2026-09-11 they were followed by
|
||||
// the 8 bytes the chip's promiscuous RX appends (gn_unwrap.h, "Payload bounds").
|
||||
//
|
||||
// All prefix fields are LITTLE-endian, matching this framing's own length field - note the
|
||||
// GeoNetworking wire format they came from is big-endian, so gn_unwrap.c converts.
|
||||
@@ -138,10 +141,11 @@ void serial_link_init(serial_link_cam_tx_cb_t on_cam_tx,
|
||||
// Sends a SERIAL_MSG_V2X_RX frame: the metadata prefix plus the UPER bytes gn_unwrap.c extracted
|
||||
// from an over-the-air frame. Pass has_geo_area=false and zeroes for the area fields when the
|
||||
// source frame carried no destination area (i.e. it was single-hop broadcast, not GeoBroadcast).
|
||||
// signed_unverified is gn_rx_t's flag of the same name; it sets bit1 of the prefix flags.
|
||||
// Returns true if the frame was written to the USB endpoint - not an end-to-end ack, the phone
|
||||
// may still drop it.
|
||||
bool serial_link_send_v2x_rx(uint16_t btp_dest_port, int8_t rssi,
|
||||
bool has_geo_area,
|
||||
bool has_geo_area, bool signed_unverified,
|
||||
int32_t geo_area_lat_tenmicrodeg,
|
||||
int32_t geo_area_lon_tenmicrodeg,
|
||||
uint16_t geo_area_distance_a_m,
|
||||
@@ -156,4 +160,9 @@ bool serial_link_send_status(uint8_t status);
|
||||
// otherwise indistinguishable, from the phone's side, from one that transmitted fine.
|
||||
void serial_link_note_tx_failure(void);
|
||||
|
||||
// Counts an ITS message that cannot be forwarded because it is too large, in the same heartbeat
|
||||
// counter serial_link_send_v2x_rx() uses for its own size check. For main.c's rx_forward_task,
|
||||
// whose capture buffer is smaller than the largest frames on air.
|
||||
void serial_link_note_oversize_drop(uint16_t btp_dest_port);
|
||||
|
||||
#endif
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# Host-side tests for obu-firmware. See README.md: needs gcc and make on PATH (MSYS2 UCRT64), and
|
||||
# asn1tools under `py -3.11` for the replay check.
|
||||
#
|
||||
# make build and run everything: chain, replay, fuzz
|
||||
# make chain | replay | fuzz one of them
|
||||
# make fuzz FUZZ_ITER=50000000 FUZZ_SEED=7 a longer or different fuzz run
|
||||
# make clean remove build/
|
||||
#
|
||||
# The firmware sources are compiled straight from ../../main, never copied.
|
||||
|
||||
CC = gcc
|
||||
PYTHON = python
|
||||
PYTHON_ASN1 = py -3.11
|
||||
FW = ../../main
|
||||
BUILD = build
|
||||
EXE = $(if $(filter Windows_NT,$(OS)),.exe,)
|
||||
# Captures live in capture/recordings/ since 2026-09-14; older ones are still in the
|
||||
# receiver checkout beside this repo.
|
||||
RECORDINGS = $(wildcard ../../../capture/recordings/*.pcap ../../../its-g5-receiver-firmware/recordings/*.pcap)
|
||||
FUZZ_ITER = 2000000
|
||||
FUZZ_SEED = 1
|
||||
|
||||
# -Werror: these sources must stay warning-free on the host compiler too.
|
||||
# UBSan in trap mode needs no runtime library, so it works on MinGW; a trap shows up as a crash.
|
||||
CFLAGS = -std=c11 -O2 -g -Wall -Wextra -Wpedantic -Werror -I$(FW) \
|
||||
-fsanitize=undefined -fsanitize-undefined-trap-on-error
|
||||
|
||||
FW_SRCS = $(FW)/geonet.c $(FW)/dot11p.c $(FW)/gn_unwrap.c
|
||||
DEPS = test_util.c test_util.h $(FW_SRCS) $(wildcard $(FW)/*.h)
|
||||
|
||||
.PHONY: all check chain replay fuzz clean
|
||||
|
||||
all: check
|
||||
|
||||
check: chain replay fuzz
|
||||
|
||||
$(BUILD):
|
||||
mkdir -p $@
|
||||
|
||||
$(BUILD)/%$(EXE): %.c $(DEPS) | $(BUILD)
|
||||
$(CC) $(CFLAGS) -o $@ $< test_util.c $(FW_SRCS)
|
||||
|
||||
# The pcap goes through pcap_gn_tally.py as a second, independent parser of the same frames.
|
||||
chain: $(BUILD)/test_chain$(EXE)
|
||||
$(BUILD)/test_chain$(EXE) $(BUILD)/test_chain.pcap
|
||||
$(PYTHON) ../pcap_gn_tally.py $(BUILD)/test_chain.pcap
|
||||
|
||||
replay: $(BUILD)/test_replay$(EXE)
|
||||
ifeq ($(RECORDINGS),)
|
||||
@echo "replay: no recordings in ../../../its-g5-receiver-firmware/recordings, skipped"
|
||||
else
|
||||
$(BUILD)/test_replay$(EXE) $(BUILD)/replay.tsv $(RECORDINGS)
|
||||
$(PYTHON_ASN1) check_replay.py $(BUILD)/replay.tsv $(RECORDINGS)
|
||||
endif
|
||||
|
||||
fuzz: $(BUILD)/fuzz_gn_unwrap$(EXE)
|
||||
$(BUILD)/fuzz_gn_unwrap$(EXE) $(FUZZ_ITER) $(FUZZ_SEED) $(RECORDINGS)
|
||||
|
||||
clean:
|
||||
rm -rf $(BUILD)
|
||||
@@ -0,0 +1,151 @@
|
||||
# Host-side tests for obu-firmware
|
||||
|
||||
**Status (2026-09-11):** the chain test, the capture replay and the fuzzer are written and pass;
|
||||
results under "Running". Toolchain: MSYS2 UCRT64 gcc, Option B below (chosen and
|
||||
installed 2026-09-11).
|
||||
|
||||
`geonet.c`, `dot11p.c` and `gn_unwrap.c` include nothing but standard C headers, so they compile
|
||||
unmodified on a PC. That makes three things possible without a board: a TX -> RX round trip
|
||||
through our own code, replaying real captures through the RX parser, and fuzzing the parser that
|
||||
reads untrusted radio bytes. ESP-IDF only builds `main/` (and `components/`), so nothing under
|
||||
`test/` ever affects the firmware image.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
obu-firmware/
|
||||
├── main/ firmware sources; the tests compile these directly, never copies
|
||||
└── test/
|
||||
├── pcap_gn_tally.py GN header fields per station over .pcap captures (Python only)
|
||||
└── host/
|
||||
├── README.md this file
|
||||
├── Makefile `make`: builds ../../main/{geonet,dot11p,gn_unwrap}.c + tests, runs them
|
||||
├── test_chain.c geonet_wrap_shb -> dot11p_build_frame -> gn_unwrap_its, byte-checked
|
||||
├── test_util.c/.h shared: guard page, crash report, pcap read/write
|
||||
├── test_replay.c every recorded frame through gn_unwrap_its, results to a TSV
|
||||
├── check_replay.py re-derives each result independently, then asn1tools on the messages
|
||||
├── fuzz_gn_unwrap.c mutation fuzzer; each input ends against a no-access guard page, so
|
||||
│ an over-read faults (no ASan on MinGW)
|
||||
└── build/ compiler output; already ignored by the repo's `build/` rule
|
||||
```
|
||||
|
||||
The repo's `vanetza/` folder is a gitignored reading copy only; it is not built. `check_replay.py`
|
||||
reads the IEEE 1609.2 ASN.1 modules from it (they carry no licence header, so they are not copied
|
||||
into `asn1/`). Without it, signed frames are still replayed but not checked independently.
|
||||
|
||||
## Running
|
||||
|
||||
From PowerShell, with MSYS2 on PATH for the session (Option B step 3):
|
||||
|
||||
```powershell
|
||||
$env:PATH = "C:\msys64\ucrt64\bin;C:\msys64\usr\bin;$env:PATH"
|
||||
cd C:\Users\Ashin\AndroidStudioProjects\MicrOBU\obu-firmware\test\host
|
||||
make
|
||||
```
|
||||
|
||||
`make` runs three things (`make chain`, `make replay`, `make fuzz` run one). A non-zero exit, or a
|
||||
`CRASH ... during: <what>` line (from the fuzzer, followed by the input as hex), is a failure.
|
||||
|
||||
- **chain** (`test_chain.c`): frames built by the firmware's own TX code, checked byte by byte
|
||||
against EN 302 636-4-1 and parsed back. Covers the CAM layout (non-QoS and QoS), Source Position
|
||||
Vector edges, output-buffer bounds, a 512-byte payload through `main.c`'s buffer sizes,
|
||||
hand-built GeoBroadcast frames in all three shapes, signed frames in all three COER length
|
||||
forms plus a top-level unsecuredData, one-byte mutations that must be rejected or accepted, the
|
||||
Common Header's payload length as the message boundary, the 8-byte RX trailer, and every
|
||||
truncation length of each frame against the guard page. `pcap_gn_tally.py` then reads the
|
||||
frames back as a second parser; `build/test_chain.pcap` opens in Wireshark too.
|
||||
2026-09-11: 1731 checks, 0 failed.
|
||||
- **replay** (`test_replay.c` + `check_replay.py`): every record in
|
||||
`its-g5-receiver-firmware/recordings/*.pcap` through `gn_unwrap_its`, each cut to `main.c`'s
|
||||
800-byte capture buffer as on the board. `check_replay.py` re-derives each result on its own
|
||||
(its own GN/BTP parse; the security envelope decoded by asn1tools from the IEEE 1609.2 modules),
|
||||
compares record by record, then decodes and re-encodes every distinct message with asn1tools -
|
||||
a byte-identical re-encode is only possible when the message was cut at exactly the right byte.
|
||||
Needs `py -3.11` with asn1tools. 2026-09-11: 15 145 records, 15 131 accepted (10 831 CAM,
|
||||
4 300 DENM; 157 of them signed); C and Python agree on every record; 11 043 of the 11 106
|
||||
distinct messages re-encode byte-identically. The other 63 fail the same way with the old 8
|
||||
trailing bytes put back, so the boundary is not the cause: 5 are our own CAMs from before the
|
||||
2026-08-20 yawRateConfidence fix, 56 come from the CiT One and 1 from another station (see
|
||||
`TODO.md`), and 1 uses an extension asn1tools cannot re-encode.
|
||||
- **fuzz** (`fuzz_gn_unwrap.c`): random edits of every recorded frame, each run against the guard
|
||||
page; an over-read crashes, an accepted payload outside its input fails. Default 2 000 000
|
||||
iterations (about 2 s); `make fuzz FUZZ_ITER=50000000 FUZZ_SEED=7` for a longer run.
|
||||
2026-09-11: 50 000 000 iterations, no crash.
|
||||
|
||||
Not covered: `main.c` (serial prefix parsing, queues) and `serial_link.c`, which need ESP-IDF;
|
||||
and the phone's encoder, whose bytes are opaque here (asn1tools and the app's golden test cover
|
||||
it).
|
||||
|
||||
## Option A (not used): WSL2 + Ubuntu 24.04
|
||||
|
||||
Kept as the fallback if AddressSanitizer or libFuzzer are ever needed; Option B has neither.
|
||||
|
||||
1. In **PowerShell as Administrator**:
|
||||
|
||||
```powershell
|
||||
wsl --install -d Ubuntu-24.04
|
||||
```
|
||||
|
||||
Reboot if it asks. Ubuntu then opens and asks for a Linux username and password (separate from
|
||||
the Windows account). Checked 2026-09-11: Hyper-V is already running on this PC, so no BIOS
|
||||
change should be needed. If the install says virtualization is disabled, enable Intel VT-x /
|
||||
AMD SVM in the BIOS.
|
||||
|
||||
2. Confirm it is WSL **2**: `wsl -l -v` should list `Ubuntu-24.04` with VERSION `2`.
|
||||
|
||||
3. Inside Ubuntu, the compilers:
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install -y build-essential clang cmake ninja-build git pkg-config python3
|
||||
```
|
||||
|
||||
4. Smoke test: the firmware sources compile on the host (expect no output):
|
||||
|
||||
```bash
|
||||
cd /mnt/c/Users/Ashin/AndroidStudioProjects/MicrOBU/obu-firmware/test/host
|
||||
cc -std=c11 -Wall -Wextra -fsyntax-only ../../main/geonet.c ../../main/dot11p.c ../../main/gn_unwrap.c
|
||||
```
|
||||
|
||||
## Option B (chosen): native Windows gcc via MSYS2
|
||||
|
||||
Enough for the round-trip test, the capture replay and the guard-page fuzzer. No libFuzzer and no
|
||||
AddressSanitizer with MinGW gcc, which is why the fuzzer uses a guard page instead.
|
||||
|
||||
1. In PowerShell: `winget install -e --id MSYS2.MSYS2` (installs to `C:\msys64`).
|
||||
2. Open **MSYS2 UCRT64** from the Start menu and run `pacman -Syu`. If the window closes, reopen
|
||||
it and run `pacman -Syu` again. Then:
|
||||
|
||||
```bash
|
||||
pacman -S --needed mingw-w64-ucrt-x86_64-gcc make
|
||||
```
|
||||
|
||||
3. **`C:\msys64\ucrt64\bin` must be on PATH.** Calling `C:\msys64\ucrt64\bin\gcc.exe` by its full
|
||||
path alone exits 1 with no message, because gcc's compiler stages load their DLLs from that
|
||||
folder. `make` lives on the MSYS side, in `C:\msys64\usr\bin`. Either work inside the
|
||||
**MSYS2 UCRT64** shell, which has both, or put them on PATH for the current session:
|
||||
|
||||
```powershell
|
||||
$env:PATH = "C:\msys64\ucrt64\bin;C:\msys64\usr\bin;$env:PATH" # PowerShell
|
||||
```
|
||||
|
||||
```bash
|
||||
export PATH=/c/msys64/ucrt64/bin:/c/msys64/usr/bin:$PATH # Git Bash
|
||||
```
|
||||
|
||||
Adding them to the user PATH permanently also works; it was deliberately not done by setup.
|
||||
4. Smoke test (expect no output):
|
||||
|
||||
```powershell
|
||||
cd C:\Users\Ashin\AndroidStudioProjects\MicrOBU\obu-firmware\test\host
|
||||
gcc -std=c11 -Wall -Wextra -fsyntax-only ../../main/geonet.c ../../main/dot11p.c ../../main/gn_unwrap.c
|
||||
```
|
||||
|
||||
Installed on this PC 2026-09-11: MSYS2 20260611, gcc 16.2.0 (UCRT64), GNU Make 4.4.1. All three
|
||||
firmware sources compile with `-std=c11 -O2 -Wall -Wextra -Wpedantic` and no warnings.
|
||||
|
||||
## Line endings
|
||||
|
||||
The repo runs with `core.autocrlf=true`, so Windows checkouts have CRLF line endings. C compilers
|
||||
don't care; shell scripts run from WSL do (`bash: $'\r': command not found`). When the first `.sh`
|
||||
file lands here, add `*.sh text eol=lf` to a root `.gitattributes` (none exists yet).
|
||||
@@ -0,0 +1,253 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Independent check of test_replay's output. See README.md.
|
||||
|
||||
For every recorded frame this works out on its own what gn_unwrap_its should have produced -
|
||||
GeoNetworking and BTP parsed here from EN 302 636-4-1, the TS 103 097 security envelope decoded by
|
||||
asn1tools from the IEEE 1609.2 ASN.1 modules rather than by hand - and compares that with what the
|
||||
C code did, record by record. Then it decodes every distinct extracted message with asn1tools and
|
||||
re-encodes it. Only a message cut at exactly the right byte re-encodes to the same bytes, so this
|
||||
is what proves that no trailer or signature bytes came along with it.
|
||||
|
||||
py -3.11 check_replay.py replay.tsv capture.pcap [capture.pcap ...]
|
||||
|
||||
Needs asn1tools (installed for Python 3.11 on this PC). Message modules come from the repo's
|
||||
asn1/; the IEEE 1609.2 ones from asn1/ or, failing that, the gitignored vanetza/ checkout.
|
||||
Exit status 1 if the C code and this disagree about any record.
|
||||
"""
|
||||
import collections
|
||||
import pathlib
|
||||
import struct
|
||||
import sys
|
||||
|
||||
import asn1tools
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[3]
|
||||
PORT_NAMES = {2001: "CAM", 2002: "DENM", 2004: "SPATEM"}
|
||||
# main.c's RX_FRAME_MAX_LEN: the most of any frame the board hands to gn_unwrap_its. test_replay
|
||||
# cuts frames to it, so this does too.
|
||||
RX_FRAME_MAX_LEN = 800
|
||||
|
||||
|
||||
def compile_specs():
|
||||
a = ROOT / "asn1"
|
||||
uper = {
|
||||
2001: asn1tools.compile_files([str(a / "cam_1_4_1.asn"), str(a / "cdd_1_3_1_1.asn")], "uper"),
|
||||
2002: asn1tools.compile_files([str(a / "denm_1_3_1.asn"), str(a / "cdd_1_3_1_1.asn")], "uper"),
|
||||
2004: asn1tools.compile_files(
|
||||
[str(a / n) for n in ("spatem_2_2_1.asn", "mapem_2_2_1.asn", "dsrc_2_2_1.asn", "cdd_2_2_1.asn")],
|
||||
"uper"),
|
||||
}
|
||||
names = ("IEEE1609dot2BaseTypes.asn", "IEEE1609dot2.asn")
|
||||
for d in (a, ROOT / "vanetza" / "asn1"):
|
||||
if all((d / n).exists() for n in names):
|
||||
return uper, asn1tools.compile_files([str(d / n) for n in names], "oer"), d
|
||||
return uper, None, None
|
||||
|
||||
|
||||
def frames(path):
|
||||
"""(record index, 802.11 frame) for every record, numbered the way test_util.c numbers them."""
|
||||
d = pathlib.Path(path).read_bytes()
|
||||
if len(d) < 24:
|
||||
return
|
||||
magic = struct.unpack("<I", d[:4])[0]
|
||||
e = "<" if magic in (0xA1B2C3D4, 0xA1B23C4D) else ">"
|
||||
link = struct.unpack(e + "I", d[20:24])[0]
|
||||
if link not in (105, 127):
|
||||
return
|
||||
off, index = 24, 0
|
||||
while off + 16 <= len(d):
|
||||
incl = struct.unpack(e + "I", d[off + 8:off + 12])[0]
|
||||
if incl > len(d) - off - 16:
|
||||
break
|
||||
pkt = d[off + 16:off + 16 + incl]
|
||||
off += 16 + incl
|
||||
if link == 127:
|
||||
rl = pkt[2] | pkt[3] << 8 if len(pkt) >= 4 else len(pkt) + 1
|
||||
if rl > len(pkt):
|
||||
index += 1
|
||||
continue
|
||||
pkt = pkt[rl:]
|
||||
yield index, pkt
|
||||
index += 1
|
||||
|
||||
|
||||
def open_envelope(sec, env):
|
||||
"""(inner GeoNetworking packet, signed) of a TS 103 097 envelope per asn1tools, or None."""
|
||||
try:
|
||||
m = sec.decode("Ieee1609Dot2Data", env)
|
||||
except Exception:
|
||||
return None
|
||||
if m["protocolVersion"] != 3:
|
||||
return None
|
||||
kind, content = m["content"]
|
||||
if kind == "unsecuredData":
|
||||
return content, False
|
||||
if kind == "signedData":
|
||||
data = content["tbsData"]["payload"].get("data")
|
||||
if data and data["protocolVersion"] == 3 and data["content"][0] == "unsecuredData":
|
||||
return data["content"][1], True
|
||||
return None
|
||||
|
||||
|
||||
def u16(b):
|
||||
return int.from_bytes(b, "big")
|
||||
|
||||
|
||||
def s32(b):
|
||||
return int.from_bytes(b, "big", signed=True)
|
||||
|
||||
|
||||
def expect(f, sec):
|
||||
"""What gn_unwrap_its should report for frame f: None, or a dict matching test_replay's row.
|
||||
Second value: how many bytes after the message the pre-2026-09-11 code would have forwarded."""
|
||||
if len(f) < 24 or (f[0] >> 2) & 3 != 2 or f[1] & 3 == 3:
|
||||
return None, None
|
||||
o = 24 + (2 if f[0] & 0x80 else 0)
|
||||
if f[o:o + 8] != b"\xaa\xaa\x03\x00\x00\x00\x89\x47" or len(f) < o + 12:
|
||||
return None, None
|
||||
nh = f[o + 8] & 0x0F
|
||||
o += 12
|
||||
if nh == 2:
|
||||
if sec is None:
|
||||
return "unchecked", None
|
||||
opened = open_envelope(sec, f[o:])
|
||||
if opened is None:
|
||||
return None, None
|
||||
region, signed = opened
|
||||
elif nh == 1:
|
||||
region, signed = f[o:], False
|
||||
else:
|
||||
return None, None
|
||||
|
||||
if len(region) < 8 or region[0] >> 4 != 2:
|
||||
return None, None
|
||||
ht, hst, pl = region[1] >> 4, region[1] & 0x0F, u16(region[4:6])
|
||||
if ht == 5 and hst == 0:
|
||||
ext, area = 28, None
|
||||
elif ht == 4:
|
||||
ext = 44
|
||||
else:
|
||||
return None, None
|
||||
if len(region) < 8 + ext + 4:
|
||||
return None, None
|
||||
if ht == 4:
|
||||
a = 8 + 28
|
||||
area = (s32(region[a:a + 4]), s32(region[a + 4:a + 8]), u16(region[a + 8:a + 10]))
|
||||
port = u16(region[8 + ext:8 + ext + 2])
|
||||
if port not in PORT_NAMES or pl <= 4:
|
||||
return None, None
|
||||
start, end = 8 + ext + 4, 8 + ext + pl
|
||||
if signed is not None and nh == 2 and end > len(region):
|
||||
return None, None # the inner packet claims more than its envelope holds
|
||||
payload = region[start:min(end, len(region))]
|
||||
if not payload:
|
||||
return None, None
|
||||
old_extra = len(region) - end if nh == 1 else None
|
||||
return dict(port=port, signed=signed, truncated=end > len(region), area=area, payload=payload), old_extra
|
||||
|
||||
|
||||
def read_tsv(path):
|
||||
rows = {}
|
||||
with open(path, encoding="ascii") as fh:
|
||||
next(fh)
|
||||
for line in fh:
|
||||
c = line.rstrip("\n").split("\t")
|
||||
key = (c[0], int(c[1]))
|
||||
if c[2] == "0":
|
||||
rows[key] = None
|
||||
else:
|
||||
rows[key] = dict(port=int(c[3]), signed=c[4] == "1", truncated=c[5] == "1",
|
||||
area=(int(c[7]), int(c[8]), int(c[9])) if c[6] == "1" else None,
|
||||
payload=bytes.fromhex(c[10]))
|
||||
return rows
|
||||
|
||||
|
||||
def describe(r):
|
||||
if r is None:
|
||||
return "rejected"
|
||||
return "port %d signed %s truncated %s area %s %d bytes" % (
|
||||
r["port"], r["signed"], r["truncated"], r["area"], len(r["payload"]))
|
||||
|
||||
|
||||
def main(argv):
|
||||
if len(argv) < 2:
|
||||
sys.exit(__doc__)
|
||||
got = read_tsv(argv[0])
|
||||
uper, sec, sec_dir = compile_specs()
|
||||
if sec is None:
|
||||
print("WARNING: IEEE 1609.2 modules not found; secured frames are not checked independently")
|
||||
|
||||
stats, extra, disagreements, messages = collections.Counter(), collections.Counter(), [], {}
|
||||
for path in argv[1:]:
|
||||
for index, f in frames(path):
|
||||
key = (path, index)
|
||||
stats["records"] += 1
|
||||
stats["capped"] += len(f) > RX_FRAME_MAX_LEN
|
||||
want, old_extra = expect(f[:RX_FRAME_MAX_LEN], sec)
|
||||
if key not in got:
|
||||
disagreements.append((key, "no row from test_replay"))
|
||||
continue
|
||||
have = got.pop(key)
|
||||
if want == "unchecked":
|
||||
stats["secured, unchecked"] += 1
|
||||
continue
|
||||
if want != have:
|
||||
disagreements.append((key, "C: %s | independent: %s" % (describe(have), describe(want))))
|
||||
continue
|
||||
if want:
|
||||
stats["accepted"] += 1
|
||||
stats[PORT_NAMES[want["port"]]] += 1
|
||||
stats["signed"] += want["signed"]
|
||||
stats["truncated"] += want["truncated"]
|
||||
if old_extra is not None:
|
||||
extra[old_extra] += 1
|
||||
messages.setdefault((want["port"], want["payload"]), key)
|
||||
for key in got:
|
||||
disagreements.append((key, "row from test_replay for a record this did not see"))
|
||||
|
||||
print("check_replay: %d records (%d cut to %d bytes), %d accepted (CAM %d, DENM %d, SPATEM %d; "
|
||||
"%d signed, %d truncated)"
|
||||
% (stats["records"], stats["capped"], RX_FRAME_MAX_LEN, stats["accepted"], stats["CAM"],
|
||||
stats["DENM"], stats["SPATEM"], stats["signed"], stats["truncated"]))
|
||||
if sec_dir:
|
||||
print(" envelopes decoded with asn1tools using %s" % sec_dir.relative_to(ROOT))
|
||||
print(" C vs independent parse: %s" % ("agree on every record" if not disagreements
|
||||
else "%d DISAGREEMENTS" % len(disagreements)))
|
||||
for key, why in disagreements[:15]:
|
||||
print(" %s record %d: %s" % (pathlib.Path(key[0]).name, key[1], why))
|
||||
|
||||
outcome, failures = collections.Counter(), []
|
||||
for (port, payload), key in messages.items():
|
||||
name, spec = PORT_NAMES[port], uper[port]
|
||||
try:
|
||||
decoded = spec.decode(name, payload)
|
||||
except Exception as e:
|
||||
outcome[(name, "does not decode")] += 1
|
||||
failures.append((key, name, len(payload), str(e)[:110]))
|
||||
continue
|
||||
try:
|
||||
encoded = spec.encode(name, decoded)
|
||||
except Exception as e:
|
||||
# asn1tools decodes an alternative from a later module version (a CHOICE extension)
|
||||
# as (None, None) and then cannot encode it back. Says nothing about where the message
|
||||
# was cut, so it is reported apart from real mismatches.
|
||||
outcome[(name, "decodes; uses a newer extension")] += 1
|
||||
failures.append((key, name, len(payload), "cannot re-encode: " + str(e)[:90]))
|
||||
continue
|
||||
if encoded == payload:
|
||||
outcome[(name, "re-encodes byte-identically")] += 1
|
||||
else:
|
||||
outcome[(name, "re-encodes differently")] += 1
|
||||
failures.append((key, name, len(payload), "re-encoded to different bytes"))
|
||||
print(" asn1tools on the %d distinct extracted messages:" % len(messages))
|
||||
for (name, what), n in sorted(outcome.items()):
|
||||
print(" %-6s %-28s %d" % (name, what, n))
|
||||
for key, name, n, why in failures[:15]:
|
||||
print(" %s record %d, %s %d bytes: %s" % (pathlib.Path(key[0]).name, key[1], name, n, why))
|
||||
print(" bytes the pre-2026-09-11 code forwarded after each unsecured message: %s"
|
||||
% dict(sorted(extra.items())))
|
||||
return 1 if disagreements else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
@@ -0,0 +1,218 @@
|
||||
// Mutation fuzzer for gn_unwrap_its, the one function in this firmware that parses bytes from the
|
||||
// air. See README.md.
|
||||
//
|
||||
// Seeds are every recorded frame in the pcaps given, plus frames built by the firmware's own TX
|
||||
// code. Each iteration takes a seed, applies 1-4 random edits - bit flips, random bytes, boundary
|
||||
// bytes such as COER length markers, 16-bit length fields, truncation, insertion, deletion,
|
||||
// appended bytes - mostly within the first 128 bytes where the headers are, and runs gn_unwrap_its
|
||||
// on the result placed against the guard page. A read past the end crashes and prints the input;
|
||||
// an accepted frame whose payload is not inside the input is reported the same way. MinGW has
|
||||
// neither libFuzzer nor AddressSanitizer, hence this rather than coverage guidance. The same seed
|
||||
// gives the same run.
|
||||
//
|
||||
// Usage: fuzz_gn_unwrap iterations seed [capture.pcap ...]
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "dot11p.h"
|
||||
#include "geonet.h"
|
||||
#include "gn_unwrap.h"
|
||||
#include "test_util.h"
|
||||
|
||||
#define MAX_FRAME 2048 // within the guard page's one page, and above any 802.11 frame
|
||||
|
||||
static uint8_t **s_seeds;
|
||||
static int *s_seed_len;
|
||||
static int s_nseeds;
|
||||
static int s_seed_cap;
|
||||
|
||||
static void add_seed(const uint8_t *f, int len)
|
||||
{
|
||||
if (len <= 0 || len > MAX_FRAME) {
|
||||
return;
|
||||
}
|
||||
if (s_nseeds == s_seed_cap) {
|
||||
s_seed_cap = s_seed_cap ? 2 * s_seed_cap : 1024;
|
||||
s_seeds = realloc(s_seeds, (size_t)s_seed_cap * sizeof *s_seeds);
|
||||
s_seed_len = realloc(s_seed_len, (size_t)s_seed_cap * sizeof *s_seed_len);
|
||||
if (!s_seeds || !s_seed_len) {
|
||||
fprintf(stderr, "out of memory\n");
|
||||
exit(2);
|
||||
}
|
||||
}
|
||||
s_seeds[s_nseeds] = malloc((size_t)len);
|
||||
if (!s_seeds[s_nseeds]) {
|
||||
fprintf(stderr, "out of memory\n");
|
||||
exit(2);
|
||||
}
|
||||
memcpy(s_seeds[s_nseeds], f, (size_t)len);
|
||||
s_seed_len[s_nseeds++] = len;
|
||||
}
|
||||
|
||||
static void on_frame(const uint8_t *f, int len, int index, void *ctx)
|
||||
{
|
||||
(void)index;
|
||||
(void)ctx;
|
||||
add_seed(f, len);
|
||||
}
|
||||
|
||||
static void add_own_seeds(void)
|
||||
{
|
||||
static const uint8_t cam[] = {0x02, 0x02, 0x00, 0x0f, 0x42, 0x3f, 0x37, 0x00, 0x40, 0x2a, 0xb2};
|
||||
gn_lpv_t lpv;
|
||||
memset(&lpv, 0, sizeof lpv);
|
||||
lpv.mac[0] = 0x02;
|
||||
lpv.station_type = 2;
|
||||
uint8_t gn[256];
|
||||
uint8_t f[512];
|
||||
const int gn_len = geonet_wrap_shb(cam, (int)sizeof cam, &lpv, 2001, gn, sizeof gn);
|
||||
for (int qos = 0; qos <= 1; qos++) {
|
||||
add_seed(f, dot11p_build_frame(gn, gn_len, lpv.mac, f, sizeof f, qos));
|
||||
}
|
||||
}
|
||||
|
||||
static uint64_t s_rng;
|
||||
|
||||
static uint64_t rnd(void)
|
||||
{
|
||||
s_rng ^= s_rng << 13;
|
||||
s_rng ^= s_rng >> 7;
|
||||
s_rng ^= s_rng << 17;
|
||||
return s_rng;
|
||||
}
|
||||
|
||||
static int below(int n)
|
||||
{
|
||||
return n <= 0 ? 0 : (int)(rnd() % (uint64_t)n);
|
||||
}
|
||||
|
||||
// Three times in four inside the headers, otherwise anywhere.
|
||||
static int pick_pos(int len)
|
||||
{
|
||||
return below(4) ? below(len < 128 ? len : 128) : below(len);
|
||||
}
|
||||
|
||||
static const uint8_t k_bytes[] = {0x00, 0x01, 0x02, 0x03, 0x05, 0x10, 0x12, 0x20,
|
||||
0x40, 0x50, 0x7F, 0x80, 0x81, 0x82, 0x83, 0xFF};
|
||||
static const uint16_t k_words[] = {0x0000, 0x0001, 0x0003, 0x0004, 0x0005, 0x007F,
|
||||
0x0080, 0x00FF, 0x0100, 0x7FFF, 0x8000, 0xFFFF};
|
||||
|
||||
static void mutate(uint8_t *b, int *len)
|
||||
{
|
||||
const int edits = 1 + below(4);
|
||||
for (int e = 0; e < edits; e++) {
|
||||
const int n = *len;
|
||||
switch (below(8)) {
|
||||
case 0: // flip a bit
|
||||
if (n) {
|
||||
b[pick_pos(n)] ^= (uint8_t)(1u << below(8));
|
||||
}
|
||||
break;
|
||||
case 1: // random byte
|
||||
if (n) {
|
||||
b[pick_pos(n)] = (uint8_t)rnd();
|
||||
}
|
||||
break;
|
||||
case 2: // boundary byte
|
||||
if (n) {
|
||||
b[pick_pos(n)] = k_bytes[below((int)sizeof k_bytes)];
|
||||
}
|
||||
break;
|
||||
case 3: // truncate
|
||||
*len = below(n + 1);
|
||||
break;
|
||||
case 4: { // append
|
||||
const int add = 1 + below(16);
|
||||
if (n + add <= MAX_FRAME) {
|
||||
for (int i = 0; i < add; i++) {
|
||||
b[n + i] = (uint8_t)rnd();
|
||||
}
|
||||
*len = n + add;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 5: // insert a byte
|
||||
if (n < MAX_FRAME) {
|
||||
const int p = below(n + 1);
|
||||
memmove(b + p + 1, b + p, (size_t)(n - p));
|
||||
b[p] = (uint8_t)rnd();
|
||||
*len = n + 1;
|
||||
}
|
||||
break;
|
||||
case 6: // delete a byte
|
||||
if (n) {
|
||||
const int p = below(n);
|
||||
memmove(b + p, b + p + 1, (size_t)(n - p - 1));
|
||||
*len = n - 1;
|
||||
}
|
||||
break;
|
||||
default: // boundary 16-bit big-endian value, e.g. a length field
|
||||
if (n >= 2) {
|
||||
const int p = pick_pos(n - 1);
|
||||
const uint16_t v = k_words[below((int)(sizeof k_words / sizeof k_words[0]))];
|
||||
b[p] = (uint8_t)(v >> 8);
|
||||
b[p + 1] = (uint8_t)v;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
if (argc < 3) {
|
||||
fprintf(stderr, "usage: fuzz_gn_unwrap iterations seed [capture.pcap ...]\n");
|
||||
return 2;
|
||||
}
|
||||
const unsigned long long iterations = strtoull(argv[1], NULL, 10);
|
||||
s_rng = (strtoull(argv[2], NULL, 10) * 0x9E3779B97F4A7C15ull) | 1;
|
||||
|
||||
tu_install_crash_handler();
|
||||
tu_guard_init();
|
||||
for (int i = 3; i < argc; i++) {
|
||||
if (tu_pcap_foreach(argv[i], on_frame, NULL) < 0) {
|
||||
fprintf(stderr, "cannot read %s as a pcap\n", argv[i]);
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
add_own_seeds();
|
||||
|
||||
static uint8_t buf[MAX_FRAME];
|
||||
int len = 0;
|
||||
tu_set_crash_input(buf, &len);
|
||||
unsigned long long accepted = 0, signed_frames = 0, truncated = 0;
|
||||
for (unsigned long long it = 0; it < iterations; it++) {
|
||||
const int s = below(s_nseeds);
|
||||
len = s_seed_len[s];
|
||||
memcpy(buf, s_seeds[s], (size_t)len);
|
||||
mutate(buf, &len);
|
||||
tu_set_context("fuzz iteration %llu (seed %s), mutated from seed frame %d", it, argv[2], s);
|
||||
|
||||
const uint8_t *g = tu_guarded(buf, len);
|
||||
gn_rx_t rx;
|
||||
if (gn_unwrap_its(g, len, &rx)) {
|
||||
accepted++;
|
||||
signed_frames += rx.signed_unverified;
|
||||
truncated += rx.truncated;
|
||||
const uintptr_t lo = (uintptr_t)g;
|
||||
const uintptr_t p = (uintptr_t)rx.payload;
|
||||
if (p < lo || rx.payload_len <= 0 || p + (uintptr_t)rx.payload_len > lo + (uintptr_t)len) {
|
||||
fprintf(stderr, "FAIL during %s: accepted payload lies outside the input\ninput (%d bytes): ",
|
||||
tu_context(), len);
|
||||
for (int i = 0; i < len; i++) {
|
||||
fprintf(stderr, "%02x", buf[i]);
|
||||
}
|
||||
fputc('\n', stderr);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
printf("fuzz_gn_unwrap: %llu iterations from %d seed frames, %llu accepted (%llu signed, "
|
||||
"%llu truncated), no crash\n",
|
||||
iterations, s_nseeds, accepted, signed_frames, truncated);
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,730 @@
|
||||
// Host-side chain test for obu-firmware. See README.md in this folder.
|
||||
//
|
||||
// Builds frames with the firmware's own TX code (geonet_wrap_shb -> dot11p_build_frame) and parses
|
||||
// them back with its own RX code (gn_unwrap_its), all compiled from ../../main unmodified.
|
||||
//
|
||||
// Two kinds of check, on purpose. The round trip proves TX and RX agree with each other. The byte
|
||||
// checks at fixed offsets prove they agree with EN 302 636-4-1, and only those catch a mistake made
|
||||
// the same way on both sides: the missing 4-byte SHB field (fixed 2026-08-13) round-tripped fine
|
||||
// between two ESP32s and was wrong against every other station. So expected values here come from
|
||||
// the standard, vanetza's serializers and real captures - never from reading geonet.c.
|
||||
//
|
||||
// Every frame handed to gn_unwrap_its is first copied so that its last byte sits right before a
|
||||
// no-access page (test_util.c): reading even one byte past a frame crashes the test instead of
|
||||
// passing quietly. MinGW has no AddressSanitizer; this covers what matters for a radio parser.
|
||||
//
|
||||
// Usage: test_chain [out.pcap]
|
||||
// With a path, also writes every test frame to a pcap (linktype 105, bare 802.11) so a second,
|
||||
// independent parser can read them: ../pcap_gn_tally.py, or Wireshark. The GeoBroadcast and
|
||||
// signed frames carry stub payloads/signatures; their headers are real.
|
||||
|
||||
#include <stdarg.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "dot11p.h"
|
||||
#include "geonet.h"
|
||||
#include "gn_unwrap.h"
|
||||
#include "serial_link.h" // SERIAL_LINK_MAX_PAYLOAD only; serial_link.c itself needs ESP-IDF
|
||||
#include "test_util.h"
|
||||
|
||||
// Same buffer sizes as tx_radio_task in main.c. Keep them in step with it.
|
||||
#define GN_BUF_LEN (SERIAL_LINK_MAX_PAYLOAD + 64)
|
||||
#define FRAME_BUF_LEN (SERIAL_LINK_MAX_PAYLOAD + 192)
|
||||
|
||||
// ---- Checks ---------------------------------------------------------------------------------
|
||||
|
||||
static int s_checks;
|
||||
static int s_failures;
|
||||
|
||||
static void check(bool ok, int line, const char *fmt, ...)
|
||||
{
|
||||
s_checks++;
|
||||
if (ok) {
|
||||
return;
|
||||
}
|
||||
s_failures++;
|
||||
va_list ap;
|
||||
va_start(ap, fmt);
|
||||
fprintf(stderr, "FAIL line %d [%s]: ", line, tu_context());
|
||||
vfprintf(stderr, fmt, ap);
|
||||
fputc('\n', stderr);
|
||||
va_end(ap);
|
||||
}
|
||||
#define CHECK(cond, ...) check((cond), __LINE__, __VA_ARGS__)
|
||||
|
||||
// ---- Test data ------------------------------------------------------------------------------
|
||||
|
||||
static uint16_t rd16(const uint8_t *p)
|
||||
{
|
||||
return (uint16_t)((p[0] << 8) | p[1]);
|
||||
}
|
||||
|
||||
static uint32_t rd32(const uint8_t *p)
|
||||
{
|
||||
return ((uint32_t)p[0] << 24) | ((uint32_t)p[1] << 16) | ((uint32_t)p[2] << 8) | p[3];
|
||||
}
|
||||
|
||||
// The app's reference CAM: the expected bytes of CamEncodeGoldenTest.kt (stationID 999999), which
|
||||
// asn1tools decodes and re-encodes byte-identically. The chain treats it as opaque bytes. Not taken
|
||||
// from the old captures on purpose: our own CAMs there predate the 2026-08-20 yawRateConfidence fix
|
||||
// and do not decode.
|
||||
static const uint8_t k_cam[] = {
|
||||
0x02, 0x02, 0x00, 0x0f, 0x42, 0x3f, 0x37, 0x00, 0x40, 0x2a, 0xb2, 0x15, 0xaf, 0x6e, 0x28, 0x64,
|
||||
0x77, 0xdf, 0xff, 0xff, 0xfc, 0x23, 0xb7, 0x74, 0x3e, 0x00, 0x27, 0xff, 0xc0, 0xd0, 0xfe, 0x01,
|
||||
0x18, 0x32, 0x93, 0x37, 0xfe, 0xeb, 0xff, 0xf6, 0x00, 0x00, 0x00,
|
||||
};
|
||||
#define CAM_LEN ((int)sizeof k_cam)
|
||||
|
||||
static const uint8_t k_mac[6] = {0x02, 0x11, 0x22, 0x33, 0x44, 0x55};
|
||||
static const uint8_t k_bcast[6] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF};
|
||||
static const uint8_t k_llc_snap_gn[8] = {0xAA, 0xAA, 0x03, 0x00, 0x00, 0x00, 0x89, 0x47};
|
||||
|
||||
// The 8 bytes that follow the 802.11 frame in every frame recorded through the ESP32-C5's
|
||||
// promiscuous RX (these from capture_20260817_171055.pcap). See gn_unwrap.h, "Payload bounds".
|
||||
static const uint8_t k_rx_trailer[8] = {0xc8, 0x01, 0x00, 0x00, 0x88, 0x00, 0x00, 0x00};
|
||||
|
||||
// Header lengths from EN 302 636-4-1 / IEEE 802.11, used to compute every offset below.
|
||||
enum {
|
||||
MAC_HDR = 24, QOS_CTRL = 2, LLC_SNAP = 8, GN_BASIC = 4, GN_COMMON = 8,
|
||||
SHB_EXT = 28, // Source Position Vector (24) + DCC-MCO / reserved (4)
|
||||
GBC_EXT = 44, // SN (2) + reserved (2) + SO PV (24) + area (12) + reserved (4)
|
||||
BTP_B = 4,
|
||||
GN_COMMON_PAYLOAD_LEN_FIELD = 4, // offset of the payload-length field in the Common Header
|
||||
};
|
||||
// Where the ITS payload starts in a non-QoS SHB frame.
|
||||
#define SHB_PAYLOAD_OFFSET (MAC_HDR + LLC_SNAP + GN_BASIC + GN_COMMON + SHB_EXT + BTP_B)
|
||||
|
||||
static gn_lpv_t test_lpv(void)
|
||||
{
|
||||
gn_lpv_t lpv;
|
||||
memset(&lpv, 0, sizeof lpv);
|
||||
memcpy(lpv.mac, k_mac, sizeof lpv.mac);
|
||||
lpv.station_type = 2; // cyclist
|
||||
lpv.pai = true;
|
||||
lpv.tst_ms = 0x89ABCDEFu;
|
||||
lpv.lat_tenmicrodeg = 535546667; // the bench, 53.5546667 N
|
||||
lpv.lon_tenmicrodeg = -10022389; // negative on purpose: the sign has to survive
|
||||
lpv.speed_cms = 1234;
|
||||
lpv.heading_decideg = 2700;
|
||||
return lpv;
|
||||
}
|
||||
|
||||
// The TX path exactly as tx_radio_task runs it. Returns the frame length, or <= 0 on failure.
|
||||
static int build(const uint8_t *payload, int len, const gn_lpv_t *lpv, uint16_t port, bool qos,
|
||||
uint8_t *frame, size_t frame_size)
|
||||
{
|
||||
static uint8_t gn[GN_BUF_LEN];
|
||||
int gn_len = geonet_wrap_shb(payload, len, lpv, port, gn, sizeof gn);
|
||||
if (gn_len <= 0) {
|
||||
return gn_len;
|
||||
}
|
||||
return dot11p_build_frame(gn, gn_len, lpv->mac, frame, frame_size, qos);
|
||||
}
|
||||
|
||||
// Every prefix of a frame, each ending against the guard page. Up to the end of the BTP-B header
|
||||
// nothing may be accepted; a frame cut inside the payload is accepted as truncated with what
|
||||
// arrived; from `payload_end` on, the payload is exactly the declared one whatever follows it.
|
||||
static void sweep(const char *name, const uint8_t *f, int len, int payload_start, int payload_end)
|
||||
{
|
||||
for (int n = 0; n <= len; n++) {
|
||||
tu_set_context("truncation sweep, %s, %d of %d bytes", name, n, len);
|
||||
const uint8_t *g = tu_guarded(f, n);
|
||||
gn_rx_t rx;
|
||||
const bool ok = gn_unwrap_its(g, n, &rx);
|
||||
if (n <= payload_start) {
|
||||
CHECK(!ok, "%s cut to %d bytes was accepted", name, n);
|
||||
} else if (n < payload_end) {
|
||||
CHECK(ok && rx.truncated && rx.payload == g + payload_start &&
|
||||
rx.payload_len == n - payload_start,
|
||||
"%s cut to %d bytes: ok=%d truncated=%d payload_len=%d", name, n, ok,
|
||||
ok && rx.truncated, ok ? rx.payload_len : -1);
|
||||
} else {
|
||||
CHECK(ok && !rx.truncated && rx.payload == g + payload_start &&
|
||||
rx.payload_len == payload_end - payload_start,
|
||||
"%s at %d bytes: ok=%d truncated=%d payload_len=%d", name, n, ok,
|
||||
ok && rx.truncated, ok ? rx.payload_len : -1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- SHB: layout, position vector, bounds ---------------------------------------------------
|
||||
|
||||
// Every byte of a CAM frame against the standard, then back through the RX path.
|
||||
static void test_shb_layout(bool qos)
|
||||
{
|
||||
tu_set_context("SHB layout, qos=%d", qos);
|
||||
const gn_lpv_t lpv = test_lpv();
|
||||
uint8_t f[FRAME_BUF_LEN];
|
||||
const int len = build(k_cam, CAM_LEN, &lpv, 2001, qos, f, sizeof f);
|
||||
|
||||
const int llc = MAC_HDR + (qos ? QOS_CTRL : 0);
|
||||
const int basic = llc + LLC_SNAP;
|
||||
const int common = basic + GN_BASIC;
|
||||
const int shb = common + GN_COMMON;
|
||||
const int btp = shb + SHB_EXT;
|
||||
const int pay = btp + BTP_B;
|
||||
CHECK(len == pay + CAM_LEN, "frame length %d, expected %d", len, pay + CAM_LEN);
|
||||
if (len != pay + CAM_LEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 802.11 MAC header
|
||||
CHECK(f[0] == (qos ? 0x88 : 0x08) && f[1] == 0x00, "frame control %02x %02x", f[0], f[1]);
|
||||
CHECK(memcmp(f + 4, k_bcast, 6) == 0, "addr1 must be broadcast");
|
||||
CHECK(memcmp(f + 10, k_mac, 6) == 0, "addr2 must be the pseudonym");
|
||||
CHECK(memcmp(f + 16, k_bcast, 6) == 0, "addr3 (BSSID) must be the OCB wildcard");
|
||||
if (qos) {
|
||||
CHECK(f[24] == 0 && f[25] == 0, "QoS control %02x %02x", f[24], f[25]);
|
||||
}
|
||||
CHECK(memcmp(f + llc, k_llc_snap_gn, 8) == 0, "LLC/SNAP with EtherType 0x8947");
|
||||
|
||||
// GN Basic Header
|
||||
CHECK(f[basic + 0] == 0x11, "basic: version 1 + NextHeader 1 (Common, unsecured), got %02x",
|
||||
f[basic + 0]);
|
||||
CHECK(f[basic + 1] == 0x00, "basic: reserved, got %02x", f[basic + 1]);
|
||||
// Multiplier 1 in the upper 6 bits, base 1 (= 1 s) in the lower 2: 1 s, what every real station
|
||||
// in the recordings sends its CAMs with. Was 0x83 = 3200 s until 2026-09-11.
|
||||
CHECK(f[basic + 2] == 0x05, "basic: lifetime 0x05 (1 s), got 0x%02x", f[basic + 2]);
|
||||
CHECK(f[basic + 3] == 1, "basic: remaining hop limit 1, got %d", f[basic + 3]);
|
||||
|
||||
// GN Common Header
|
||||
CHECK(f[common + 0] == 0x20, "common: NextHeader 2 (BTP-B), got %02x", f[common + 0]);
|
||||
CHECK(f[common + 1] == 0x50, "common: HeaderType 5 (TSB) / subtype 0 (single hop), got %02x",
|
||||
f[common + 1]);
|
||||
CHECK(f[common + 2] == 0x02, "common: traffic class TC-ID 2 (DP2, CAM), got %02x", f[common + 2]);
|
||||
CHECK(f[common + 3] == 0x80, "common: flags = mobile, got %02x", f[common + 3]);
|
||||
CHECK(rd16(f + common + 4) == BTP_B + CAM_LEN,
|
||||
"common: payload length must count BTP-B + payload only, got %d", rd16(f + common + 4));
|
||||
CHECK(f[common + 6] == 1, "common: maximum hop limit 1, got %d", f[common + 6]);
|
||||
CHECK(f[common + 7] == 0, "common: reserved, got %02x", f[common + 7]);
|
||||
|
||||
// SHB extended header: Source Position Vector, then 4 bytes DCC-MCO / reserved
|
||||
// GN_ADDR: M flag bit 15, station type bits 14..10 (vanetza geonet/address.cpp), MID.
|
||||
CHECK(rd16(f + shb) == (2u << 10), "GN_ADDR: M=0, station type 2, got %04x", rd16(f + shb));
|
||||
CHECK(memcmp(f + shb + 2, k_mac, 6) == 0, "GN_ADDR MID must equal the 802.11 source address");
|
||||
CHECK(rd32(f + shb + 8) == 0x89ABCDEFu, "TST, got %08lx", (unsigned long)rd32(f + shb + 8));
|
||||
CHECK((int32_t)rd32(f + shb + 12) == 535546667, "latitude, got %ld",
|
||||
(long)(int32_t)rd32(f + shb + 12));
|
||||
CHECK((int32_t)rd32(f + shb + 16) == -10022389, "longitude, got %ld",
|
||||
(long)(int32_t)rd32(f + shb + 16));
|
||||
CHECK(rd16(f + shb + 20) == (0x8000 | 1234), "PAI bit 15 + speed 1234, got %04x",
|
||||
rd16(f + shb + 20));
|
||||
CHECK(rd16(f + shb + 22) == 2700, "heading, got %d", rd16(f + shb + 22));
|
||||
CHECK(rd32(f + shb + 24) == 0, "DCC-MCO / reserved: present and zero, got %08lx",
|
||||
(unsigned long)rd32(f + shb + 24));
|
||||
|
||||
// BTP-B, payload
|
||||
CHECK(rd16(f + btp) == 2001, "BTP-B destination port, got %d", rd16(f + btp));
|
||||
CHECK(rd16(f + btp + 2) == 0, "BTP-B destination port info, got %d", rd16(f + btp + 2));
|
||||
CHECK(memcmp(f + pay, k_cam, (size_t)CAM_LEN) == 0, "payload bytes");
|
||||
|
||||
// ...and back through the RX path
|
||||
const uint8_t *g = tu_guarded(f, len);
|
||||
gn_rx_t rx;
|
||||
const bool ok = gn_unwrap_its(g, len, &rx);
|
||||
CHECK(ok, "gn_unwrap_its rejected our own frame");
|
||||
if (ok) {
|
||||
CHECK(rx.btp_dest_port == 2001, "RX port %d", rx.btp_dest_port);
|
||||
CHECK(!rx.has_geo_area, "RX reports a geo area for an SHB frame");
|
||||
CHECK(!rx.signed_unverified, "RX reports an unsecured frame as signed");
|
||||
CHECK(!rx.truncated, "RX reports a whole frame as truncated");
|
||||
CHECK(rx.payload == g + pay, "RX payload offset %d, expected %d", (int)(rx.payload - g), pay);
|
||||
CHECK(rx.payload_len == CAM_LEN, "RX payload length %d, expected %d", rx.payload_len, CAM_LEN);
|
||||
CHECK(rx.payload_len == CAM_LEN && memcmp(rx.payload, k_cam, (size_t)CAM_LEN) == 0,
|
||||
"RX payload bytes differ from what was sent");
|
||||
}
|
||||
tu_pcap_add(f, len);
|
||||
}
|
||||
|
||||
// The Source Position Vector's packed fields at their edges.
|
||||
static void test_pv_encoding(void)
|
||||
{
|
||||
// Offsets inside the GeoNetworking packet (no 802.11 header): SO PV starts after Basic + Common.
|
||||
const int pv = GN_BASIC + GN_COMMON;
|
||||
uint8_t gn[GN_BUF_LEN];
|
||||
|
||||
// Speed is 15-bit signed (geonet.h), clamped rather than wrapped; PAI is bit 15.
|
||||
static const struct { int16_t speed; bool pai; uint16_t expect; } speeds[] = {
|
||||
{0, false, 0x0000}, {1234, true, 0x84D2}, {16383, false, 0x3FFF},
|
||||
{16384, false, 0x3FFF}, {32767, false, 0x3FFF}, {-100, false, 0x7F9C},
|
||||
{-16384, false, 0x4000}, {-32768, true, 0xC000},
|
||||
};
|
||||
for (size_t i = 0; i < sizeof speeds / sizeof speeds[0]; i++) {
|
||||
tu_set_context("PV speed %d pai %d", speeds[i].speed, speeds[i].pai);
|
||||
gn_lpv_t lpv = test_lpv();
|
||||
lpv.speed_cms = speeds[i].speed;
|
||||
lpv.pai = speeds[i].pai;
|
||||
const int n = geonet_wrap_shb(k_cam, CAM_LEN, &lpv, 2001, gn, sizeof gn);
|
||||
CHECK(n > 0 && rd16(gn + pv + 20) == speeds[i].expect, "speed %d pai %d: got %04x, expected %04x",
|
||||
speeds[i].speed, speeds[i].pai, rd16(gn + pv + 20), speeds[i].expect);
|
||||
}
|
||||
|
||||
// Heading is 0..3599 tenths of a degree, wrapped.
|
||||
static const struct { uint16_t in, expect; } headings[] = {
|
||||
{0, 0}, {2700, 2700}, {3599, 3599}, {3600, 0}, {3601, 1}, {65535, 735},
|
||||
};
|
||||
for (size_t i = 0; i < sizeof headings / sizeof headings[0]; i++) {
|
||||
tu_set_context("PV heading %d", headings[i].in);
|
||||
gn_lpv_t lpv = test_lpv();
|
||||
lpv.heading_decideg = headings[i].in;
|
||||
const int n = geonet_wrap_shb(k_cam, CAM_LEN, &lpv, 2001, gn, sizeof gn);
|
||||
CHECK(n > 0 && rd16(gn + pv + 22) == headings[i].expect, "heading %d: got %d, expected %d",
|
||||
headings[i].in, rd16(gn + pv + 22), headings[i].expect);
|
||||
}
|
||||
|
||||
// Station type has 5 bits; anything larger must not spill into the M flag.
|
||||
static const struct { uint8_t in; uint16_t expect; } types[] = {
|
||||
{2, 0x0800}, {15, 0x3C00}, {0xFF, 0x7C00},
|
||||
};
|
||||
for (size_t i = 0; i < sizeof types / sizeof types[0]; i++) {
|
||||
tu_set_context("PV station type %d", types[i].in);
|
||||
gn_lpv_t lpv = test_lpv();
|
||||
lpv.station_type = types[i].in;
|
||||
const int n = geonet_wrap_shb(k_cam, CAM_LEN, &lpv, 2001, gn, sizeof gn);
|
||||
CHECK(n > 0 && rd16(gn + pv) == types[i].expect, "station type %d: GN_ADDR %04x, expected %04x",
|
||||
types[i].in, rd16(gn + pv), types[i].expect);
|
||||
}
|
||||
}
|
||||
|
||||
// Output buffers: an exact fit works and writes nothing past its end; one byte less is refused.
|
||||
static void test_bounds(void)
|
||||
{
|
||||
const gn_lpv_t lpv = test_lpv();
|
||||
const int gn_len = GN_BASIC + GN_COMMON + SHB_EXT + BTP_B + CAM_LEN;
|
||||
|
||||
tu_set_context("geonet_wrap_shb bounds");
|
||||
CHECK(geonet_wrap_shb(k_cam, CAM_LEN, &lpv, 2001, tu_guard_buf(gn_len), (size_t)gn_len) == gn_len,
|
||||
"geonet_wrap_shb: exact-size buffer must fit");
|
||||
CHECK(geonet_wrap_shb(k_cam, CAM_LEN, &lpv, 2001, tu_guard_buf(gn_len - 1), (size_t)gn_len - 1) == -1,
|
||||
"geonet_wrap_shb: one byte short must be refused");
|
||||
|
||||
uint8_t gn[GN_BUF_LEN];
|
||||
geonet_wrap_shb(k_cam, CAM_LEN, &lpv, 2001, gn, sizeof gn);
|
||||
for (int qos = 0; qos <= 1; qos++) {
|
||||
tu_set_context("dot11p_build_frame bounds, qos=%d", qos);
|
||||
const int frame_len = MAC_HDR + (qos ? QOS_CTRL : 0) + LLC_SNAP + gn_len;
|
||||
CHECK(dot11p_build_frame(gn, gn_len, k_mac, tu_guard_buf(frame_len), (size_t)frame_len, qos) ==
|
||||
frame_len,
|
||||
"dot11p_build_frame qos=%d: exact-size buffer must fit", qos);
|
||||
CHECK(dot11p_build_frame(gn, gn_len, k_mac, tu_guard_buf(frame_len - 1), (size_t)frame_len - 1,
|
||||
qos) == -1,
|
||||
"dot11p_build_frame qos=%d: one byte short must be refused", qos);
|
||||
}
|
||||
}
|
||||
|
||||
// The largest CAM the serial link can carry must survive the whole chain in main.c's buffers.
|
||||
static void test_max_payload(void)
|
||||
{
|
||||
static uint8_t big[SERIAL_LINK_MAX_PAYLOAD];
|
||||
for (int i = 0; i < SERIAL_LINK_MAX_PAYLOAD; i++) {
|
||||
big[i] = (uint8_t)(i * 7 + 3);
|
||||
}
|
||||
for (int qos = 0; qos <= 1; qos++) {
|
||||
tu_set_context("max payload %d, qos=%d", SERIAL_LINK_MAX_PAYLOAD, qos);
|
||||
const gn_lpv_t lpv = test_lpv();
|
||||
uint8_t f[FRAME_BUF_LEN];
|
||||
const int hdr = MAC_HDR + (qos ? QOS_CTRL : 0) + LLC_SNAP + GN_BASIC + GN_COMMON + SHB_EXT + BTP_B;
|
||||
const int len = build(big, SERIAL_LINK_MAX_PAYLOAD, &lpv, 2001, qos, f, sizeof f);
|
||||
CHECK(len == hdr + SERIAL_LINK_MAX_PAYLOAD, "qos=%d: frame length %d, expected %d", qos, len,
|
||||
hdr + SERIAL_LINK_MAX_PAYLOAD);
|
||||
if (len <= 0) {
|
||||
continue;
|
||||
}
|
||||
gn_rx_t rx;
|
||||
const uint8_t *g = tu_guarded(f, len);
|
||||
const bool ok = gn_unwrap_its(g, len, &rx);
|
||||
CHECK(ok && !rx.truncated && rx.payload_len == SERIAL_LINK_MAX_PAYLOAD &&
|
||||
memcmp(rx.payload, big, SERIAL_LINK_MAX_PAYLOAD) == 0,
|
||||
"qos=%d: max-size payload did not round-trip", qos);
|
||||
tu_pcap_add(f, len);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- GeoBroadcast ---------------------------------------------------------------------------
|
||||
|
||||
// A GeoBroadcast DENM frame laid out by hand from EN 302 636-4-1 clause 9.8.5, since the firmware
|
||||
// has no GBC builder and real RSUs send DENM this way. QoS Data, lifetime 0x79 (30 s) and the
|
||||
// 44-byte extended header all as seen from the CiT One in the recordings.
|
||||
static const uint8_t k_rsu_mac[6] = {0x02, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE};
|
||||
static const uint8_t k_denm_stub[] = {0x02, 0x01, 0x00, 0x00, 0x30, 0x39, 0xDE, 0xAD, 0xBE,
|
||||
0xEF, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08};
|
||||
#define DENM_STUB_LEN ((int)sizeof k_denm_stub)
|
||||
#define GBC_PAYLOAD_OFFSET (MAC_HDR + QOS_CTRL + LLC_SNAP + GN_BASIC + GN_COMMON + GBC_EXT + BTP_B)
|
||||
|
||||
static uint8_t *put(uint8_t *p, const void *src, int n)
|
||||
{
|
||||
memcpy(p, src, (size_t)n);
|
||||
return p + n;
|
||||
}
|
||||
|
||||
static uint8_t *put16(uint8_t *p, uint16_t v)
|
||||
{
|
||||
*p++ = (uint8_t)(v >> 8);
|
||||
*p++ = (uint8_t)v;
|
||||
return p;
|
||||
}
|
||||
|
||||
static uint8_t *put32(uint8_t *p, uint32_t v)
|
||||
{
|
||||
p = put16(p, (uint16_t)(v >> 16));
|
||||
return put16(p, (uint16_t)v);
|
||||
}
|
||||
|
||||
static int build_gbc(uint8_t subtype, int32_t area_lat, int32_t area_lon, uint16_t dist_a, uint8_t *f)
|
||||
{
|
||||
uint8_t *p = f;
|
||||
// 802.11 QoS Data: FC, duration, addr1..3, sequence control, QoS control
|
||||
p = put16(p, 0x8800);
|
||||
p = put16(p, 0);
|
||||
p = put(p, k_bcast, 6);
|
||||
p = put(p, k_rsu_mac, 6);
|
||||
p = put(p, k_bcast, 6);
|
||||
p = put16(p, 0x1000);
|
||||
p = put16(p, 0);
|
||||
p = put(p, k_llc_snap_gn, 8);
|
||||
// Basic: version 1 / NH common, reserved, lifetime 30 s, RHL 10
|
||||
const uint8_t basic[4] = {0x11, 0x00, 0x79, 10};
|
||||
p = put(p, basic, 4);
|
||||
// Common: NH BTP-B, HT 4 (GBC) / subtype = area shape, TC 1, flags 0 (stationary), PL, MHL, reserved
|
||||
const uint8_t common[4] = {0x20, (uint8_t)(0x40 | subtype), 0x01, 0x00};
|
||||
p = put(p, common, 4);
|
||||
p = put16(p, (uint16_t)(BTP_B + DENM_STUB_LEN));
|
||||
*p++ = 10;
|
||||
*p++ = 0;
|
||||
// GBC extended header: SN, reserved, SO PV (GN_ADDR with station type 15 = RSU, MID, TST, lat,
|
||||
// lon, PAI/speed, heading), area centre lat/lon, DistanceA, DistanceB, angle, reserved
|
||||
p = put16(p, 0x1234);
|
||||
p = put16(p, 0);
|
||||
p = put16(p, 15u << 10);
|
||||
p = put(p, k_rsu_mac, 6);
|
||||
p = put32(p, 1000);
|
||||
p = put32(p, 535540100);
|
||||
p = put32(p, 100220100);
|
||||
p = put16(p, 0);
|
||||
p = put16(p, 0);
|
||||
p = put32(p, (uint32_t)area_lat);
|
||||
p = put32(p, (uint32_t)area_lon);
|
||||
p = put16(p, dist_a);
|
||||
p = put16(p, subtype ? 40 : 0);
|
||||
p = put16(p, subtype ? 900 : 0);
|
||||
p = put16(p, 0);
|
||||
// BTP-B: DENM
|
||||
p = put16(p, 2002);
|
||||
p = put16(p, 0);
|
||||
p = put(p, k_denm_stub, DENM_STUB_LEN);
|
||||
return (int)(p - f);
|
||||
}
|
||||
|
||||
static void test_gbc_rx(void)
|
||||
{
|
||||
static const struct { uint8_t subtype; int32_t lat, lon; uint16_t dist_a; } areas[] = {
|
||||
{0, 535540000, 100220000, 250}, // circle around the bench
|
||||
{1, -338600000, 1512100000, 1000}, // rectangle; southern and far-eastern: signs and range
|
||||
{2, 535540000, -100220000, 65535}, // ellipse; western, largest DistanceA
|
||||
};
|
||||
for (size_t i = 0; i < sizeof areas / sizeof areas[0]; i++) {
|
||||
tu_set_context("GBC subtype %d", areas[i].subtype);
|
||||
uint8_t f[256];
|
||||
const int len = build_gbc(areas[i].subtype, areas[i].lat, areas[i].lon, areas[i].dist_a, f);
|
||||
CHECK(len == GBC_PAYLOAD_OFFSET + DENM_STUB_LEN, "GBC frame length %d", len);
|
||||
gn_rx_t rx;
|
||||
const uint8_t *g = tu_guarded(f, len);
|
||||
const bool ok = gn_unwrap_its(g, len, &rx);
|
||||
CHECK(ok, "GBC subtype %d rejected", areas[i].subtype);
|
||||
if (ok) {
|
||||
CHECK(rx.btp_dest_port == 2002, "GBC port %d", rx.btp_dest_port);
|
||||
CHECK(rx.has_geo_area, "GBC area missing");
|
||||
CHECK(!rx.signed_unverified && !rx.truncated, "GBC flags signed=%d truncated=%d",
|
||||
rx.signed_unverified, rx.truncated);
|
||||
CHECK(rx.geo_area_lat_tenmicrodeg == areas[i].lat, "area lat %ld, expected %ld",
|
||||
(long)rx.geo_area_lat_tenmicrodeg, (long)areas[i].lat);
|
||||
CHECK(rx.geo_area_lon_tenmicrodeg == areas[i].lon, "area lon %ld, expected %ld",
|
||||
(long)rx.geo_area_lon_tenmicrodeg, (long)areas[i].lon);
|
||||
CHECK(rx.geo_area_distance_a_m == areas[i].dist_a, "DistanceA %d, expected %d",
|
||||
rx.geo_area_distance_a_m, areas[i].dist_a);
|
||||
CHECK(rx.payload == g + GBC_PAYLOAD_OFFSET && rx.payload_len == DENM_STUB_LEN &&
|
||||
memcmp(rx.payload, k_denm_stub, (size_t)DENM_STUB_LEN) == 0,
|
||||
"GBC payload offset %d length %d", (int)(rx.payload - g), rx.payload_len);
|
||||
}
|
||||
tu_pcap_add(f, len);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- What gets rejected, and where the payload ends -----------------------------------------
|
||||
|
||||
// One-byte changes to a good CAM frame: what must be dropped, and what must still pass.
|
||||
static void test_rejections(void)
|
||||
{
|
||||
const gn_lpv_t lpv = test_lpv();
|
||||
uint8_t good[FRAME_BUF_LEN];
|
||||
const int len = build(k_cam, CAM_LEN, &lpv, 2001, false, good, sizeof good);
|
||||
|
||||
// Offsets into that non-QoS frame: 802.11 0, LLC/SNAP 24, Basic 32, Common 36, SHB 44, BTP-B 72.
|
||||
static const struct {
|
||||
const char *what;
|
||||
int offset;
|
||||
uint8_t value;
|
||||
bool accept;
|
||||
uint16_t port;
|
||||
} cases[] = {
|
||||
{"management frame (beacon)", 0, 0x80, false, 0},
|
||||
{"WDS (ToDS and FromDS)", 1, 0x03, false, 0},
|
||||
{"LLC DSAP not 0xAA", 24, 0xAB, false, 0},
|
||||
{"EtherType not 0x8947", 30, 0x08, false, 0},
|
||||
{"Basic NextHeader 2 with no security envelope", 32, 0x12, false, 0},
|
||||
{"Basic NextHeader 0 (any)", 32, 0x10, false, 0},
|
||||
{"Common NextHeader 1 (BTP-A)", 36, 0x10, false, 0},
|
||||
{"Beacon (HeaderType 1)", 37, 0x10, false, 0},
|
||||
{"GeoUnicast (HeaderType 2)", 37, 0x20, false, 0},
|
||||
{"multi-hop TSB (HeaderType 5, subtype 1)", 37, 0x51, false, 0},
|
||||
{"BTP port 2003 (MAPEM, not accepted yet)", 73, 0xD3, false, 0},
|
||||
{"BTP port 2018 (VAM, not accepted yet)", 73, 0xE2, false, 0},
|
||||
{"BTP port 2002 (DENM)", 73, 0xD2, true, 2002},
|
||||
{"BTP port 2004 (SPATEM)", 73, 0xD4, true, 2004},
|
||||
};
|
||||
for (size_t i = 0; i < sizeof cases / sizeof cases[0]; i++) {
|
||||
tu_set_context("mutation: %s", cases[i].what);
|
||||
uint8_t f[FRAME_BUF_LEN];
|
||||
memcpy(f, good, (size_t)len);
|
||||
f[cases[i].offset] = cases[i].value;
|
||||
gn_rx_t rx;
|
||||
const bool ok = gn_unwrap_its(tu_guarded(f, len), len, &rx);
|
||||
CHECK(ok == cases[i].accept, "%s: %s", cases[i].what, ok ? "accepted" : "rejected");
|
||||
if (ok && cases[i].accept) {
|
||||
CHECK(rx.btp_dest_port == cases[i].port, "%s: port %d", cases[i].what, rx.btp_dest_port);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The Common Header's payload length, not the end of the frame, decides where the message stops.
|
||||
static void test_payload_length(void)
|
||||
{
|
||||
const gn_lpv_t lpv = test_lpv();
|
||||
uint8_t good[FRAME_BUF_LEN];
|
||||
const int len = build(k_cam, CAM_LEN, &lpv, 2001, false, good, sizeof good);
|
||||
const int pl = MAC_HDR + LLC_SNAP + GN_BASIC + GN_COMMON_PAYLOAD_LEN_FIELD;
|
||||
|
||||
static const struct { const char *what; uint16_t value; bool accept; bool truncated; int payload_len; }
|
||||
cases[] = {
|
||||
{"one byte less than sent", BTP_B + CAM_LEN - 1, true, false, CAM_LEN - 1},
|
||||
{"BTP-B header only, no payload", BTP_B, false, false, 0},
|
||||
{"shorter than the BTP-B header", BTP_B - 1, false, false, 0},
|
||||
{"zero", 0, false, false, 0},
|
||||
{"far more than arrived", 0xFFFF, true, true, CAM_LEN},
|
||||
};
|
||||
for (size_t i = 0; i < sizeof cases / sizeof cases[0]; i++) {
|
||||
tu_set_context("Common payload length: %s", cases[i].what);
|
||||
uint8_t f[FRAME_BUF_LEN];
|
||||
memcpy(f, good, (size_t)len);
|
||||
f[pl] = (uint8_t)(cases[i].value >> 8);
|
||||
f[pl + 1] = (uint8_t)cases[i].value;
|
||||
gn_rx_t rx;
|
||||
const uint8_t *g = tu_guarded(f, len);
|
||||
const bool ok = gn_unwrap_its(g, len, &rx);
|
||||
CHECK(ok == cases[i].accept, "%s: %s", cases[i].what, ok ? "accepted" : "rejected");
|
||||
if (ok && cases[i].accept) {
|
||||
CHECK(rx.truncated == cases[i].truncated && rx.payload == g + SHB_PAYLOAD_OFFSET &&
|
||||
rx.payload_len == cases[i].payload_len,
|
||||
"%s: truncated=%d payload_len=%d", cases[i].what, rx.truncated, rx.payload_len);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The 8 bytes the chip's promiscuous RX appends to every frame must not reach the phone.
|
||||
static void test_trailer(void)
|
||||
{
|
||||
uint8_t f[FRAME_BUF_LEN];
|
||||
for (int qos = 0; qos <= 1; qos++) {
|
||||
tu_set_context("RX trailer after an SHB frame, qos=%d", qos);
|
||||
const gn_lpv_t lpv = test_lpv();
|
||||
const int len = build(k_cam, CAM_LEN, &lpv, 2001, qos, f, sizeof f);
|
||||
memcpy(f + len, k_rx_trailer, sizeof k_rx_trailer);
|
||||
gn_rx_t rx;
|
||||
const bool ok = gn_unwrap_its(tu_guarded(f, len + 8), len + 8, &rx);
|
||||
CHECK(ok && !rx.truncated && rx.payload_len == CAM_LEN &&
|
||||
memcmp(rx.payload, k_cam, (size_t)CAM_LEN) == 0,
|
||||
"qos=%d: payload_len %d with the trailer, expected %d", qos, ok ? rx.payload_len : -1,
|
||||
CAM_LEN);
|
||||
}
|
||||
|
||||
tu_set_context("RX trailer after a GBC frame");
|
||||
const int len = build_gbc(0, 535540000, 100220000, 250, f);
|
||||
memcpy(f + len, k_rx_trailer, sizeof k_rx_trailer);
|
||||
gn_rx_t rx;
|
||||
const bool ok = gn_unwrap_its(tu_guarded(f, len + 8), len + 8, &rx);
|
||||
CHECK(ok && !rx.truncated && rx.payload_len == DENM_STUB_LEN,
|
||||
"GBC: payload_len %d with the trailer, expected %d", ok ? rx.payload_len : -1, DENM_STUB_LEN);
|
||||
}
|
||||
|
||||
// ---- Secured (TS 103 097) -------------------------------------------------------------------
|
||||
|
||||
// Stand-in for what follows the inner packet in a real signed frame: headerInfo, signer and
|
||||
// signature, 94 bytes with a digest signer. Its content is never read.
|
||||
#define SIG_STUB_LEN 94
|
||||
|
||||
static int put_coer_length(int len, uint8_t *out)
|
||||
{
|
||||
if (len < 0x80) {
|
||||
out[0] = (uint8_t)len;
|
||||
return 1;
|
||||
}
|
||||
if (len <= 0xFF) {
|
||||
out[0] = 0x81;
|
||||
out[1] = (uint8_t)len;
|
||||
return 2;
|
||||
}
|
||||
out[0] = 0x82;
|
||||
out[1] = (uint8_t)(len >> 8);
|
||||
out[2] = (uint8_t)len;
|
||||
return 3;
|
||||
}
|
||||
|
||||
// A secured CAM frame shaped like the recorded ones: our own SHB packet with the Basic Header's
|
||||
// NextHeader set to 2 and everything after the Basic Header wrapped as `prefix` + COER length +
|
||||
// inner packet, then the signature stand-in. Sets where the ITS payload starts and ends.
|
||||
static int build_secured(const uint8_t *prefix, int prefix_len, const uint8_t *payload,
|
||||
int payload_len, uint8_t *f, int *payload_start, int *payload_end)
|
||||
{
|
||||
const gn_lpv_t lpv = test_lpv();
|
||||
uint8_t plain[FRAME_BUF_LEN];
|
||||
const int plain_len = build(payload, payload_len, &lpv, 2001, false, plain, sizeof plain);
|
||||
const int basic = MAC_HDR + LLC_SNAP;
|
||||
const int inner = basic + GN_BASIC; // the Common Header onward
|
||||
const int inner_len = plain_len - inner;
|
||||
|
||||
int n = inner;
|
||||
memcpy(f, plain, (size_t)inner);
|
||||
f[basic] = (uint8_t)((f[basic] & 0xF0) | 2); // Basic Header NextHeader: secured
|
||||
memcpy(f + n, prefix, (size_t)prefix_len);
|
||||
n += prefix_len;
|
||||
n += put_coer_length(inner_len, f + n);
|
||||
memcpy(f + n, plain + inner, (size_t)inner_len);
|
||||
*payload_start = n + GN_COMMON + SHB_EXT + BTP_B;
|
||||
n += inner_len;
|
||||
*payload_end = n;
|
||||
for (int i = 0; i < SIG_STUB_LEN; i++) {
|
||||
f[n++] = (uint8_t)(0xA5 ^ i);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
static const uint8_t k_signed_prefix[] = {0x03, 0x81, 0x00, 0x40, 0x03, 0x80};
|
||||
static const uint8_t k_unsecured_prefix[] = {0x03, 0x80};
|
||||
|
||||
static void test_secured(void)
|
||||
{
|
||||
static uint8_t big[300];
|
||||
for (int i = 0; i < (int)sizeof big; i++) {
|
||||
big[i] = (uint8_t)(i * 13 + 1);
|
||||
}
|
||||
static const struct { const char *what; bool is_signed; int payload_len; } cases[] = {
|
||||
{"signed, short length form", true, CAM_LEN}, // inner packet 83 bytes
|
||||
{"signed, 1-byte long length form", true, 100}, // 140
|
||||
{"signed, 2-byte long length form", true, 300}, // 340
|
||||
{"top-level unsecuredData", false, CAM_LEN},
|
||||
};
|
||||
for (size_t c = 0; c < sizeof cases / sizeof cases[0]; c++) {
|
||||
tu_set_context("secured: %s", cases[c].what);
|
||||
const uint8_t *payload = cases[c].payload_len == CAM_LEN ? k_cam : big;
|
||||
uint8_t f[FRAME_BUF_LEN];
|
||||
int start, end;
|
||||
const int len = cases[c].is_signed
|
||||
? build_secured(k_signed_prefix, (int)sizeof k_signed_prefix, payload,
|
||||
cases[c].payload_len, f, &start, &end)
|
||||
: build_secured(k_unsecured_prefix, (int)sizeof k_unsecured_prefix, payload,
|
||||
cases[c].payload_len, f, &start, &end);
|
||||
gn_rx_t rx;
|
||||
const uint8_t *g = tu_guarded(f, len);
|
||||
const bool ok = gn_unwrap_its(g, len, &rx);
|
||||
CHECK(ok, "%s: rejected", cases[c].what);
|
||||
if (ok) {
|
||||
CHECK(rx.signed_unverified == cases[c].is_signed, "%s: signed_unverified %d",
|
||||
cases[c].what, rx.signed_unverified);
|
||||
CHECK(!rx.truncated && rx.btp_dest_port == 2001, "%s: truncated %d port %d",
|
||||
cases[c].what, rx.truncated, rx.btp_dest_port);
|
||||
CHECK(rx.payload == g + start && rx.payload_len == cases[c].payload_len &&
|
||||
memcmp(rx.payload, payload, (size_t)cases[c].payload_len) == 0,
|
||||
"%s: payload offset %d length %d, expected %d / %d", cases[c].what,
|
||||
(int)(rx.payload - g), rx.payload_len, start, cases[c].payload_len);
|
||||
}
|
||||
tu_pcap_add(f, len);
|
||||
sweep(cases[c].what, f, len, start, end);
|
||||
}
|
||||
|
||||
// One-byte changes to the signed, short-form frame. `env` is where the envelope starts.
|
||||
uint8_t good[FRAME_BUF_LEN];
|
||||
int start, end;
|
||||
const int len = build_secured(k_signed_prefix, (int)sizeof k_signed_prefix, k_cam, CAM_LEN,
|
||||
good, &start, &end);
|
||||
const int env = MAC_HDR + LLC_SNAP + GN_BASIC;
|
||||
const int inner = env + (int)sizeof k_signed_prefix + 1; // + one length byte
|
||||
static const struct { const char *what; int offset; uint8_t value; } bad[] = {
|
||||
{"legacy envelope, protocolVersion 2", 0, 0x02},
|
||||
{"encryptedData", 1, 0x82},
|
||||
{"hashId not a one-byte value", 2, 0x80},
|
||||
{"no data, only extDataHash (preamble 0x20)", 3, 0x20},
|
||||
{"inner protocolVersion 2", 4, 0x02},
|
||||
{"nested signedData", 5, 0x81},
|
||||
{"length form with no length bytes (0x80)", 6, 0x80},
|
||||
{"length form with 3 length bytes (0x83)", 6, 0x83},
|
||||
{"envelope too short for the Common Header", 6, 0x05},
|
||||
};
|
||||
for (size_t i = 0; i < sizeof bad / sizeof bad[0]; i++) {
|
||||
tu_set_context("secured mutation: %s", bad[i].what);
|
||||
uint8_t f[FRAME_BUF_LEN];
|
||||
memcpy(f, good, (size_t)len);
|
||||
f[env + bad[i].offset] = bad[i].value;
|
||||
gn_rx_t rx;
|
||||
CHECK(!gn_unwrap_its(tu_guarded(f, len), len, &rx), "%s: accepted", bad[i].what);
|
||||
}
|
||||
|
||||
tu_set_context("secured mutation: inner packet claims more than its envelope holds");
|
||||
uint8_t f[FRAME_BUF_LEN];
|
||||
memcpy(f, good, (size_t)len);
|
||||
f[inner + GN_COMMON_PAYLOAD_LEN_FIELD + 1]++; // Common Header payload length, low byte
|
||||
gn_rx_t rx;
|
||||
CHECK(!gn_unwrap_its(tu_guarded(f, len), len, &rx), "payload overrunning its envelope: accepted");
|
||||
}
|
||||
|
||||
static void test_truncation(void)
|
||||
{
|
||||
const gn_lpv_t lpv = test_lpv();
|
||||
uint8_t f[FRAME_BUF_LEN];
|
||||
for (int qos = 0; qos <= 1; qos++) {
|
||||
const int len = build(k_cam, CAM_LEN, &lpv, 2001, qos, f, sizeof f);
|
||||
const int start = MAC_HDR + (qos ? QOS_CTRL : 0) + LLC_SNAP + GN_BASIC + GN_COMMON + SHB_EXT + BTP_B;
|
||||
sweep(qos ? "SHB QoS" : "SHB", f, len, start, len);
|
||||
}
|
||||
const int len = build_gbc(0, 535540000, 100220000, 250, f);
|
||||
sweep("GBC", f, len, GBC_PAYLOAD_OFFSET, len);
|
||||
}
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
tu_install_crash_handler();
|
||||
tu_guard_init();
|
||||
if (argc > 1 && !tu_pcap_open(argv[1])) {
|
||||
fprintf(stderr, "cannot write %s\n", argv[1]);
|
||||
return 2;
|
||||
}
|
||||
|
||||
test_shb_layout(false);
|
||||
test_shb_layout(true);
|
||||
test_pv_encoding();
|
||||
test_bounds();
|
||||
test_max_payload();
|
||||
test_gbc_rx();
|
||||
test_rejections();
|
||||
test_payload_length();
|
||||
test_trailer();
|
||||
test_secured();
|
||||
test_truncation();
|
||||
|
||||
tu_pcap_close();
|
||||
printf("test_chain: %d checks, %d failed\n", s_checks, s_failures);
|
||||
return s_failures ? 1 : 0;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// Replays recorded air traffic through gn_unwrap_its, one pcap record at a time and exactly as the
|
||||
// promiscuous RX callback hands each frame over (the recordings come through the same API), and
|
||||
// writes what came out to a TSV that check_replay.py verifies independently. See README.md.
|
||||
//
|
||||
// Usage: test_replay out.tsv capture.pcap [capture.pcap ...]
|
||||
//
|
||||
// One row per pcap record:
|
||||
// file, record, accepted, port, signed, truncated, area, area_lat, area_lon, area_dist, payload
|
||||
// with everything after `accepted` empty for rejected frames and the payload in hex. Each frame
|
||||
// is placed against the guard page, and an accepted payload that is not inside its frame is a
|
||||
// failure here already.
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
#include "gn_unwrap.h"
|
||||
#include "test_util.h"
|
||||
|
||||
// main.c's RX_FRAME_MAX_LEN: the promiscuous callback copies at most this many bytes of a frame,
|
||||
// so it is the most gn_unwrap_its ever sees on the board. Keep in step with main.c.
|
||||
#define RX_FRAME_MAX_LEN 800
|
||||
|
||||
typedef struct {
|
||||
FILE *out;
|
||||
const char *path;
|
||||
long records, capped, accepted, cam, denm, spatem, signed_frames, truncated, bad;
|
||||
} replay_t;
|
||||
|
||||
static void on_frame(const uint8_t *frame, int len, int index, void *ctx)
|
||||
{
|
||||
replay_t *r = ctx;
|
||||
r->records++;
|
||||
if (len > RX_FRAME_MAX_LEN) {
|
||||
len = RX_FRAME_MAX_LEN;
|
||||
r->capped++;
|
||||
}
|
||||
tu_set_context("replay %s record %d (%d bytes)", r->path, index, len);
|
||||
const uint8_t *g = tu_guarded(frame, len);
|
||||
gn_rx_t rx;
|
||||
const bool ok = gn_unwrap_its(g, len, &rx);
|
||||
fprintf(r->out, "%s\t%d\t%d", r->path, index, ok);
|
||||
if (ok) {
|
||||
const uintptr_t lo = (uintptr_t)g;
|
||||
const uintptr_t p = (uintptr_t)rx.payload;
|
||||
if (p < lo || rx.payload_len <= 0 || p + (uintptr_t)rx.payload_len > lo + (uintptr_t)len) {
|
||||
fprintf(stderr, "FAIL %s: payload outside the frame\n", tu_context());
|
||||
r->bad++;
|
||||
} else {
|
||||
fprintf(r->out, "\t%u\t%d\t%d\t%d\t%ld\t%ld\t%u\t", rx.btp_dest_port, rx.signed_unverified,
|
||||
rx.truncated, rx.has_geo_area, (long)rx.geo_area_lat_tenmicrodeg,
|
||||
(long)rx.geo_area_lon_tenmicrodeg, rx.geo_area_distance_a_m);
|
||||
for (int i = 0; i < rx.payload_len; i++) {
|
||||
fprintf(r->out, "%02x", rx.payload[i]);
|
||||
}
|
||||
}
|
||||
r->accepted++;
|
||||
r->cam += rx.btp_dest_port == 2001;
|
||||
r->denm += rx.btp_dest_port == 2002;
|
||||
r->spatem += rx.btp_dest_port == 2004;
|
||||
r->signed_frames += rx.signed_unverified;
|
||||
r->truncated += rx.truncated;
|
||||
}
|
||||
fputc('\n', r->out);
|
||||
}
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
if (argc < 3) {
|
||||
fprintf(stderr, "usage: test_replay out.tsv capture.pcap [capture.pcap ...]\n");
|
||||
return 2;
|
||||
}
|
||||
tu_install_crash_handler();
|
||||
tu_guard_init();
|
||||
|
||||
replay_t r = {0};
|
||||
r.out = fopen(argv[1], "w");
|
||||
if (!r.out) {
|
||||
fprintf(stderr, "cannot write %s\n", argv[1]);
|
||||
return 2;
|
||||
}
|
||||
fprintf(r.out, "file\trecord\taccepted\tport\tsigned\ttruncated\tarea\tarea_lat\tarea_lon\tarea_dist\tpayload\n");
|
||||
|
||||
int unreadable = 0;
|
||||
for (int i = 2; i < argc; i++) {
|
||||
r.path = argv[i];
|
||||
if (tu_pcap_foreach(argv[i], on_frame, &r) < 0) {
|
||||
fprintf(stderr, "cannot read %s as a pcap\n", argv[i]);
|
||||
unreadable++;
|
||||
}
|
||||
}
|
||||
fclose(r.out);
|
||||
|
||||
printf("test_replay: %ld records (%ld cut to %d bytes as main.c does), %ld accepted (CAM %ld, "
|
||||
"DENM %ld, SPATEM %ld; %ld signed, %ld truncated), %ld bad\n",
|
||||
r.records, r.capped, RX_FRAME_MAX_LEN, r.accepted, r.cam, r.denm, r.spatem,
|
||||
r.signed_frames, r.truncated, r.bad);
|
||||
return (r.bad || unreadable) ? 1 : 0;
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
#ifndef _WIN32
|
||||
#define _DEFAULT_SOURCE // MAP_ANONYMOUS under -std=c11
|
||||
#endif
|
||||
|
||||
#include "test_util.h"
|
||||
|
||||
#include <stdarg.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <windows.h>
|
||||
#else
|
||||
#include <signal.h>
|
||||
#include <sys/mman.h>
|
||||
#include <unistd.h>
|
||||
#endif
|
||||
|
||||
// ---- Crash reporting ------------------------------------------------------------------------
|
||||
|
||||
static char s_context[200] = "startup";
|
||||
static const uint8_t *s_crash_bytes;
|
||||
static const int *s_crash_len;
|
||||
|
||||
void tu_set_context(const char *fmt, ...)
|
||||
{
|
||||
va_list ap;
|
||||
va_start(ap, fmt);
|
||||
vsnprintf(s_context, sizeof s_context, fmt, ap);
|
||||
va_end(ap);
|
||||
}
|
||||
|
||||
const char *tu_context(void)
|
||||
{
|
||||
return s_context;
|
||||
}
|
||||
|
||||
void tu_set_crash_input(const uint8_t *bytes, const int *len)
|
||||
{
|
||||
s_crash_bytes = bytes;
|
||||
s_crash_len = len;
|
||||
}
|
||||
|
||||
static void report_crash(const char *what)
|
||||
{
|
||||
fprintf(stderr, "CRASH (%s) during: %s\n", what, s_context);
|
||||
if (s_crash_bytes && s_crash_len) {
|
||||
fprintf(stderr, "input (%d bytes): ", *s_crash_len);
|
||||
for (int i = 0; i < *s_crash_len; i++) {
|
||||
fprintf(stderr, "%02x", s_crash_bytes[i]);
|
||||
}
|
||||
fputc('\n', stderr);
|
||||
}
|
||||
fflush(stderr);
|
||||
}
|
||||
|
||||
#ifdef _WIN32
|
||||
static LONG WINAPI on_crash(EXCEPTION_POINTERS *info)
|
||||
{
|
||||
char what[40];
|
||||
snprintf(what, sizeof what, "exception 0x%08lx",
|
||||
(unsigned long)info->ExceptionRecord->ExceptionCode);
|
||||
report_crash(what);
|
||||
return EXCEPTION_EXECUTE_HANDLER; // terminate, with the exception code as the exit status
|
||||
}
|
||||
|
||||
void tu_install_crash_handler(void)
|
||||
{
|
||||
SetUnhandledExceptionFilter(on_crash);
|
||||
}
|
||||
#else
|
||||
static void on_crash(int sig)
|
||||
{
|
||||
char what[40];
|
||||
snprintf(what, sizeof what, "signal %d", sig);
|
||||
report_crash(what); // not async-signal-safe, but the process is ending anyway
|
||||
_exit(2);
|
||||
}
|
||||
|
||||
void tu_install_crash_handler(void)
|
||||
{
|
||||
signal(SIGSEGV, on_crash);
|
||||
signal(SIGBUS, on_crash);
|
||||
signal(SIGILL, on_crash); // -fsanitize-undefined-trap-on-error traps with an illegal instruction
|
||||
}
|
||||
#endif
|
||||
|
||||
// ---- Guard page -----------------------------------------------------------------------------
|
||||
|
||||
static uint8_t *s_guard_end; // first byte of the no-access page
|
||||
static size_t s_page_size;
|
||||
|
||||
void tu_guard_init(void)
|
||||
{
|
||||
#ifdef _WIN32
|
||||
SYSTEM_INFO si;
|
||||
GetSystemInfo(&si);
|
||||
s_page_size = si.dwPageSize;
|
||||
uint8_t *base = VirtualAlloc(NULL, 2 * s_page_size, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);
|
||||
DWORD old;
|
||||
if (!base || !VirtualProtect(base + s_page_size, s_page_size, PAGE_NOACCESS, &old)) {
|
||||
fprintf(stderr, "guard page setup failed\n");
|
||||
exit(2);
|
||||
}
|
||||
#else
|
||||
s_page_size = (size_t)sysconf(_SC_PAGESIZE);
|
||||
uint8_t *base = mmap(NULL, 2 * s_page_size, PROT_READ | PROT_WRITE,
|
||||
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
|
||||
if (base == MAP_FAILED || mprotect(base + s_page_size, s_page_size, PROT_NONE) != 0) {
|
||||
fprintf(stderr, "guard page setup failed\n");
|
||||
exit(2);
|
||||
}
|
||||
#endif
|
||||
s_guard_end = base + s_page_size;
|
||||
}
|
||||
|
||||
uint8_t *tu_guard_buf(int len)
|
||||
{
|
||||
if (len < 0 || (size_t)len > s_page_size) {
|
||||
fprintf(stderr, "tu_guard_buf(%d) exceeds one page\n", len);
|
||||
exit(2);
|
||||
}
|
||||
return s_guard_end - len;
|
||||
}
|
||||
|
||||
const uint8_t *tu_guarded(const uint8_t *frame, int len)
|
||||
{
|
||||
uint8_t *dst = tu_guard_buf(len);
|
||||
if (len > 0) {
|
||||
memcpy(dst, frame, (size_t)len);
|
||||
}
|
||||
return dst;
|
||||
}
|
||||
|
||||
// ---- pcap -----------------------------------------------------------------------------------
|
||||
|
||||
static uint32_t rd_le32(const uint8_t *p)
|
||||
{
|
||||
return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24);
|
||||
}
|
||||
|
||||
static uint32_t rd_be32(const uint8_t *p)
|
||||
{
|
||||
return ((uint32_t)p[0] << 24) | ((uint32_t)p[1] << 16) | ((uint32_t)p[2] << 8) | (uint32_t)p[3];
|
||||
}
|
||||
|
||||
int tu_pcap_foreach(const char *path, tu_frame_fn fn, void *ctx)
|
||||
{
|
||||
FILE *f = fopen(path, "rb");
|
||||
if (!f) {
|
||||
return -1;
|
||||
}
|
||||
fseek(f, 0, SEEK_END);
|
||||
const long size = ftell(f);
|
||||
fseek(f, 0, SEEK_SET);
|
||||
if (size < 24) {
|
||||
fclose(f);
|
||||
return size == 0 ? 0 : -1; // the recordings include empty files
|
||||
}
|
||||
uint8_t *d = malloc((size_t)size);
|
||||
if (!d || fread(d, 1, (size_t)size, f) != (size_t)size) {
|
||||
fclose(f);
|
||||
free(d);
|
||||
return -1;
|
||||
}
|
||||
fclose(f);
|
||||
|
||||
const uint32_t magic = rd_le32(d);
|
||||
const bool swapped = magic == 0xD4C3B2A1u || magic == 0x4D3CB2A1u;
|
||||
if (!swapped && magic != 0xA1B2C3D4u && magic != 0xA1B23C4Du) {
|
||||
free(d);
|
||||
return -1;
|
||||
}
|
||||
uint32_t (*u32)(const uint8_t *) = swapped ? rd_be32 : rd_le32;
|
||||
const uint32_t linktype = u32(d + 20);
|
||||
if (linktype != 127 && linktype != 105) {
|
||||
free(d);
|
||||
return -1;
|
||||
}
|
||||
|
||||
long off = 24;
|
||||
int index = 0;
|
||||
while (off + 16 <= size) {
|
||||
const uint32_t incl = u32(d + off + 8);
|
||||
if (incl > (uint32_t)(size - off - 16)) {
|
||||
break; // last record cut off
|
||||
}
|
||||
const uint8_t *pkt = d + off + 16;
|
||||
int len = (int)incl;
|
||||
off += 16 + (long)incl;
|
||||
if (linktype == 127) {
|
||||
const int rt_len = len >= 4 ? (pkt[2] | (pkt[3] << 8)) : len + 1; // always little-endian
|
||||
if (rt_len > len) {
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
pkt += rt_len;
|
||||
len -= rt_len;
|
||||
}
|
||||
fn(pkt, len, index++, ctx);
|
||||
}
|
||||
free(d);
|
||||
return index;
|
||||
}
|
||||
|
||||
static FILE *s_pcap_out;
|
||||
|
||||
static void put_le32(uint8_t *p, uint32_t v)
|
||||
{
|
||||
p[0] = (uint8_t)v;
|
||||
p[1] = (uint8_t)(v >> 8);
|
||||
p[2] = (uint8_t)(v >> 16);
|
||||
p[3] = (uint8_t)(v >> 24);
|
||||
}
|
||||
|
||||
bool tu_pcap_open(const char *path)
|
||||
{
|
||||
// Global header: magic, version 2.4, zone 0, sigfigs 0, snaplen 65535, linktype 105.
|
||||
static const uint8_t hdr[24] = {
|
||||
0xD4, 0xC3, 0xB2, 0xA1, 0x02, 0x00, 0x04, 0x00, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0xFF, 0xFF, 0x00, 0x00, 105, 0, 0, 0,
|
||||
};
|
||||
s_pcap_out = fopen(path, "wb");
|
||||
return s_pcap_out && fwrite(hdr, 1, sizeof hdr, s_pcap_out) == sizeof hdr;
|
||||
}
|
||||
|
||||
void tu_pcap_add(const uint8_t *frame, int len)
|
||||
{
|
||||
static uint32_t seq;
|
||||
if (!s_pcap_out || len <= 0) {
|
||||
return;
|
||||
}
|
||||
uint8_t rec[16];
|
||||
put_le32(rec + 0, seq++); // timestamp seconds: just the frame's sequence number
|
||||
put_le32(rec + 4, 0);
|
||||
put_le32(rec + 8, (uint32_t)len);
|
||||
put_le32(rec + 12, (uint32_t)len);
|
||||
fwrite(rec, 1, sizeof rec, s_pcap_out);
|
||||
fwrite(frame, 1, (size_t)len, s_pcap_out);
|
||||
}
|
||||
|
||||
void tu_pcap_close(void)
|
||||
{
|
||||
if (s_pcap_out) {
|
||||
fclose(s_pcap_out);
|
||||
s_pcap_out = NULL;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
#ifndef TEST_UTIL_H
|
||||
#define TEST_UTIL_H
|
||||
// Shared by the host tests: crash reporting, the guard page, and pcap reading and writing.
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
|
||||
// What the test is doing right now, printed if it crashes. printf-style.
|
||||
void tu_set_context(const char *fmt, ...);
|
||||
const char *tu_context(void);
|
||||
// Also print these bytes as hex if it crashes (the fuzzer's current input). NULL to clear.
|
||||
void tu_set_crash_input(const uint8_t *bytes, const int *len);
|
||||
void tu_install_crash_handler(void);
|
||||
|
||||
// One read-write page followed by one no-access page.
|
||||
void tu_guard_init(void);
|
||||
// A buffer of exactly `len` bytes (at most one page) whose end touches the no-access page.
|
||||
uint8_t *tu_guard_buf(int len);
|
||||
// Copy of `frame` whose last byte is the last readable one: reading past it crashes.
|
||||
const uint8_t *tu_guarded(const uint8_t *frame, int len);
|
||||
|
||||
// Calls `fn` for every record of a pcap file, with its 802.11 frame. Linktype 127 (radiotap,
|
||||
// what its-g5-receiver-firmware records) has the radiotap header removed; linktype 105 is passed
|
||||
// as is. The frame is otherwise exactly as captured, including the 8 bytes the ESP32-C5's
|
||||
// promiscuous RX appends - which is what obu-firmware's callback receives through the same API.
|
||||
// `index` counts every record, including ones skipped for a malformed radiotap header. Returns
|
||||
// the number of records (0 for an empty file), or -1 if the file can't be read or isn't a pcap
|
||||
// of those linktypes.
|
||||
typedef void (*tu_frame_fn)(const uint8_t *frame, int len, int index, void *ctx);
|
||||
int tu_pcap_foreach(const char *path, tu_frame_fn fn, void *ctx);
|
||||
|
||||
// Writes frames to a pcap, linktype 105 (bare 802.11).
|
||||
bool tu_pcap_open(const char *path);
|
||||
void tu_pcap_add(const uint8_t *frame, int len);
|
||||
void tu_pcap_close(void);
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tally GeoNetworking header fields per sending station across .pcap captures.
|
||||
|
||||
Written to check the GN lifetime byte on air (see TODO.md), and it answers the general question
|
||||
"what do real stations put in this header" too: one row per source MAC, packet type, BTP port and
|
||||
lifetime byte, with a frame count.
|
||||
|
||||
python obu-firmware/test/pcap_gn_tally.py its-g5-receiver-firmware/recordings/*.pcap
|
||||
|
||||
Handles linktype 127 (radiotap, what its-g5-receiver-firmware records) and 105 (bare 802.11).
|
||||
Standard library only. Pseudonym MACs rotate, so one vehicle can appear as several rows. The
|
||||
pcap-over-serial dump path corrupts roughly 0.3% of frames, so a stray odd row is tooling noise.
|
||||
"""
|
||||
import collections
|
||||
import glob
|
||||
import struct
|
||||
import sys
|
||||
|
||||
LLC_SNAP_GN = b"\xaa\xaa\x03\x00\x00\x00\x89\x47"
|
||||
|
||||
# (HeaderType, HeaderSubtype) from the GN Common Header -> name, EN 302 636-4-1 table 9.
|
||||
HEADER_TYPES = {
|
||||
(1, 0): "beacon",
|
||||
(4, 0): "GBC-circle",
|
||||
(4, 1): "GBC-rect",
|
||||
(4, 2): "GBC-ellipse",
|
||||
(5, 0): "SHB",
|
||||
}
|
||||
# Extended header length, i.e. the distance from the end of the Common Header to BTP-B.
|
||||
EXT_LEN = {"SHB": 28, "GBC-circle": 44, "GBC-rect": 44, "GBC-ellipse": 44}
|
||||
|
||||
|
||||
def lifetime_seconds(raw):
|
||||
# Multiplier in the upper 6 bits, base in the lower 2: 50 ms, 1 s, 10 s, 100 s.
|
||||
return (raw >> 2) * (0.05, 1, 10, 100)[raw & 3]
|
||||
|
||||
|
||||
def frames(path):
|
||||
with open(path, "rb") as f:
|
||||
data = f.read()
|
||||
if len(data) < 24:
|
||||
return
|
||||
magic = struct.unpack("<I", data[:4])[0]
|
||||
endian = "<" if magic in (0xA1B2C3D4, 0xA1B23C4D) else ">"
|
||||
linktype = struct.unpack(endian + "I", data[20:24])[0]
|
||||
off = 24
|
||||
while off + 16 <= len(data):
|
||||
incl = struct.unpack(endian + "I", data[off + 8:off + 12])[0]
|
||||
pkt = data[off + 16:off + 16 + incl]
|
||||
off += 16 + incl
|
||||
if linktype == 127:
|
||||
if len(pkt) < 4:
|
||||
continue
|
||||
pkt = pkt[struct.unpack("<H", pkt[2:4])[0]:]
|
||||
elif linktype != 105:
|
||||
continue
|
||||
yield pkt
|
||||
|
||||
|
||||
def gn_fields(f):
|
||||
if len(f) < 24 or (f[0] >> 2) & 3 != 2:
|
||||
return None # Data frames only
|
||||
o = 24 + (2 if f[0] & 0x80 else 0) # QoS Data carries a 2-byte QoS Control field
|
||||
if f[o:o + 8] != LLC_SNAP_GN or len(f) < o + 12:
|
||||
return None
|
||||
o += 8
|
||||
src = f[10:16].hex(":")
|
||||
version, next_header, lifetime = f[o] >> 4, f[o] & 0x0F, f[o + 2]
|
||||
port = "-"
|
||||
if next_header == 2:
|
||||
kind = "secured" # Common Header is inside the security envelope
|
||||
elif next_header == 1 and len(f) >= o + 12:
|
||||
c = o + 4
|
||||
ht = (f[c + 1] >> 4, f[c + 1] & 0x0F)
|
||||
kind = HEADER_TYPES.get(ht, "type %d/%d" % ht)
|
||||
ext = EXT_LEN.get(kind)
|
||||
btp = c + 8 + (ext or 0)
|
||||
if ext and len(f) >= btp + 2:
|
||||
port = str(struct.unpack(">H", f[btp:btp + 2])[0])
|
||||
else:
|
||||
kind = "nh=%d" % next_header
|
||||
return src, version, kind, port, lifetime
|
||||
|
||||
|
||||
def main(argv):
|
||||
# PowerShell does not expand wildcards itself, so do it here.
|
||||
paths = [p for arg in argv for p in (glob.glob(arg) or [arg])]
|
||||
if not paths:
|
||||
sys.exit(__doc__)
|
||||
tally = collections.Counter()
|
||||
for path in paths:
|
||||
for frame in frames(path):
|
||||
fields = gn_fields(frame)
|
||||
if fields:
|
||||
tally[fields] += 1
|
||||
print("%-17s %3s %-11s %5s %8s %8s %7s"
|
||||
% ("source", "ver", "packet", "port", "lifetime", "seconds", "frames"))
|
||||
for (src, ver, kind, port, lt), n in sorted(tally.items()):
|
||||
print("%-17s %3d %-11s %5s %8s %8g %7d"
|
||||
% (src, ver, kind, port, "0x%02x" % lt, lifetime_seconds(lt), n))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(sys.argv[1:])
|
||||
Reference in New Issue
Block a user