Sign ITS messages on the ESP32-C5 with vanetza-idf, over USB or BLE

obu-firmware is now a port of the colleague's standalone VRU station
(microbu-esp32c5/firmware, kept beside this repository and gitignored): the
vanetza-idf C-ITS stack with the TS 103 097 security entity, credentials in
NVS, the station-link v1 protocol over the native USB port (frame type 0x10
in the existing 0xAA55 framing) and over a BLE GATT peripheral, and its
ITS-G5 radio adapter. The phone still builds CAM and VAM; the board adds
GeoNetworking/BTP and signs with the provisioned authorization ticket. The
private key never leaves the board. Builds with ESP-IDF 6.0.2 only, which
vanetza-idf pins for the radio's private driver ABI. The previous C firmware
stays on disk unbuilt; a full-flash backup of the bench board is kept in
firmware-backups/ (gitignored).

Changed against the colleague's firmware, marked MicrOBU: in the sources:
- Reception unchanged for the app. vanetza-idf drops what it cannot verify
  (unsigned traffic, every RSU), so each captured frame also goes through the
  previous gn_unwrap.c and reaches the phone as link opcode V2X_RX (0x85),
  whose body is the old SERIAL_MSG_V2X_RX payload.
- Unsigned transmission still possible, with the previous geonet.c header;
  the phone chooses per message.
- Console on UART0 (CH343 port); the native USB port carries only link frames.
- BLE advertising pauses while the USB link is in use: BLE and ITS-G5 share
  one RF front end.
- NVS 80 KB (app at 0x20000). At 24 KB, with Wi-Fi settings the previous
  firmware left behind, the BLE bond could not be stored and the phone had to
  pair on every connection.
- Bench fixes: the radio queue is drained before the first PoTi (no RX and
  ~177 queue drops before); the station loop waited pdMS_TO_TICKS(5) = 0
  ticks at 100 Hz and starved the idle task; the 2.4 KB RX capture buffer is
  off the Wi-Fi task stack; BLE notifications longer than the MTU are dropped
  instead of cut short, MTU 517; serial writes are skipped with no USB host.
- Manual country policy and TX-power read-back from the previous radio setup;
  logs for BLE encryption changes and the number of stored bonds.

Verified on the bench board (COM3) with the phone over USB and BLE: CAM and
VAM, signed and unsigned, go out; reception of the sim car and the RSU's
CAM/SPATEM/MAPEM continues; the board survives app restarts and reconnects.
See docs/06-signed-its-vam-ble.md.
This commit is contained in:
Ashin Walpola
2026-09-23 17:27:54 +02:00
parent 7285fa19b7
commit d2fd222a62
31 changed files with 4837 additions and 1022 deletions
+13
View File
@@ -46,3 +46,16 @@ sdkconfig.old
# Office lock files. Word/Excel create these beside a document while it is open # 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. # and remove them on close, so they are transient and machine-local.
~$* ~$*
# Colleague's standalone ESP32-C5 VRU station (signed ITS via Vanetza). It is its
# own git repository with its own history; kept beside the project as a reference
# for porting the signed-TX path into obu-firmware, not tracked here.
/microbu-esp32c5/
# Full-flash images read back off the bench boards before reflashing them (16 MB each).
# Restore with: esptool --chip esp32c5 -p COM<N> write-flash 0 <image>
/firmware-backups/
# ESP-IDF component manager downloads (espressif/esp-boost for obu-firmware's vanetza-idf), ~125 MB.
# dependencies.lock beside the project pins them and is committed; this is its cache.
managed_components/
+60 -7
View File
@@ -1,9 +1,62 @@
cmake_minimum_required(VERSION 3.16) cmake_minimum_required(VERSION 3.22)
# obu-firmware: the ESP32-C5 half of the MicrOBU station, on the vanetza-idf C-ITS stack.
#
# Since 2026-09-23 this is a port of the colleague's standalone VRU station
# (microbu-esp32c5/firmware, its own git repository beside this one and gitignored here). From it:
# BTP/GeoNetworking and the TS 103 097 security entity (vanetza-idf), the station-link message
# layer over native USB Serial/JTAG and BLE GATT, the NVS credential store, and the C5 radio adapter.
# Added here for this project: the raw receive path of the previous firmware (gn_unwrap.c, forwarded
# as link opcode V2X_RX) so unsigned and non-demo-signed traffic still reaches the phone, the
# unsigned transmit path of the previous firmware (geonet.c), and BLE pausing while USB is in use.
# See NOTES.md.
#
# ESP-IDF 6.0.2 exactly: the C5 radio's private Wi-Fi driver ABI (otm_tx_custom.c) is pinned to it
# by vanetza-idf's radio_c5.cmake and has only been validated there. The previous C firmware was
# built with IDF 6.1; see FLASHING.md for the export script of each.
#
# vanetza-idf is taken from the colleague's tree rather than vendored (it is ~90 MB). Override with
# -DVANETZA_IDF_DIR=... if it lives elsewhere.
if(NOT VANETZA_IDF_DIR)
set(VANETZA_IDF_DIR "${CMAKE_CURRENT_LIST_DIR}/../microbu-esp32c5/external/vanetza-idf")
endif()
if(NOT EXISTS "${VANETZA_IDF_DIR}/idf_component.yml")
message(FATAL_ERROR "vanetza-idf not found at ${VANETZA_IDF_DIR}: clone microbu-esp32c5 beside this "
"repository or pass -DVANETZA_IDF_DIR=<path to external/vanetza-idf>")
endif()
# Provenance guard for main/otm_tx_custom.c, copied unchanged from microbu-esp32c5/firmware/CMakeLists.txt:
# this checked-in copy is what microbu::C5Radio::request() links against for every transmission.
# Fail configure if either the pinned upstream or this file drifts from the reviewed revision.
set(_otm_upstream "${VANETZA_IDF_DIR}/ports/esp_idf/third_party/otm/main/tx_custom.c")
if(EXISTS "${_otm_upstream}")
file(READ "${_otm_upstream}" _otm_upstream_text)
string(REPLACE "\r\n" "\n" _otm_upstream_text "${_otm_upstream_text}")
string(SHA256 _otm_upstream_hash "${_otm_upstream_text}")
if(NOT _otm_upstream_hash STREQUAL "cb1dccfef96912ca59275e8a9102f41f56925b94a19d4a4629082aaeb779be1b")
message(FATAL_ERROR "OpenTrafficMap upstream tx_custom.c differs from the reviewed source revision (674e3412) -- review before updating main/otm_tx_custom.c and this hash")
endif()
endif()
set(_otm_checked_in "${CMAKE_CURRENT_LIST_DIR}/main/otm_tx_custom.c")
file(READ "${_otm_checked_in}" _otm_checked_in_text)
string(REPLACE "\r\n" "\n" _otm_checked_in_text "${_otm_checked_in_text}")
string(SHA256 _otm_checked_in_hash "${_otm_checked_in_text}")
if(NOT _otm_checked_in_hash STREQUAL "114693af99ce3866cfc767066d484e45822eaf15335d94a03626b60ac5777277")
message(FATAL_ERROR "main/otm_tx_custom.c differs from the reviewed copy -- review the change, then update this hash")
endif()
list(APPEND EXTRA_COMPONENT_DIRS "${VANETZA_IDF_DIR}")
set(SDKCONFIG_DEFAULTS "${CMAKE_CURRENT_LIST_DIR}/sdkconfig.defaults")
set(COMPONENTS main)
include($ENV{IDF_PATH}/tools/cmake/project.cmake) include($ENV{IDF_PATH}/tools/cmake/project.cmake)
# No longer need -Wl,-zmuldefs here - that was only for main/wifi_patches.c's
# symbol-override attempt (which didn't work anyway; see docs/04-transmit-setup.md),
# and that file is no longer part of the build. Superseded by main/tx_custom.c,
# which bypasses the gate at a different layer instead of trying to override it.
project(obu_firmware) project(obu_firmware)
add_compile_options(-Wno-error -Wno-cpp -Wno-error=implicit-function-declaration)
idf_component_get_property(vanetza_lib vanetza-idf COMPONENT_LIB)
if(vanetza_lib)
target_compile_options(${vanetza_lib} PRIVATE -Wno-error=implicit-function-declaration -Wno-error=cpp)
endif()
# GCC 15's stricter -Warray-bounds false-positives on NimBLE's fixed-size bond-store arrays
# (upstream Apache Mynewt code); demote to a warning so the component still builds.
idf_component_get_property(bt_lib bt COMPONENT_LIB)
if(bt_lib)
target_compile_options(${bt_lib} PRIVATE -Wno-error=array-bounds)
endif()
+37 -11
View File
@@ -2,24 +2,38 @@
## Two toolchains - use a dedicated terminal for each ## Two toolchains - use a dedicated terminal for each
This project builds against the receiver-firmware's pinned ESP-IDF **6.1**. Since 2026-09-23 this project builds against ESP-IDF **6.0.2** exactly
The separate `obu-cam-transmistter` project builds against the global ESP-IDF (`C:\Espressif\frameworks\esp-idf-v6.0.2`): it is the vanetza-idf port (see
**5.5.4**. Exporting both in one PowerShell window fails: the second export NOTES.md), and vanetza-idf's `radio_c5.cmake` refuses any other version because
the raw TX path pokes private Wi-Fi driver structures only validated there. The
previous C firmware used the receiver firmware's IDF **6.1**; the separate
`obu-cam-transmistter` project builds against the global ESP-IDF **5.5.4**.
Exporting two of them in one PowerShell window fails: the second export
inherits the first's `IDF_PYTHON_ENV_PATH` and reports every Python dependency inherits the first's `IDF_PYTHON_ENV_PATH` and reports every Python dependency
as unmet. Don't run `install.bat` to "fix" that - open a fresh terminal, or as unmet. Don't run `install.bat` to "fix" that - open a fresh terminal, or
clear the state with `$env:IDF_PYTHON_ENV_PATH = $null; $env:IDF_PATH = $null`. clear the state with `$env:IDF_PYTHON_ENV_PATH = $null; $env:IDF_PATH = $null`.
## Every new PowerShell session The build also needs the colleague's `microbu-esp32c5` checkout beside this
repository (gitignored here): vanetza-idf is taken from its
`external/vanetza-idf`. Pass `-DVANETZA_IDF_DIR=<path>` to `idf.py` if it lives
elsewhere. The first build downloads `espressif/esp-boost` into
`managed_components/`.
Activate the toolchain (obu-firmware has no esp-idf of its own — reuse the ## Every new PowerShell session
receiver firmware's already-installed checkout):
```powershell ```powershell
Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass
C:\Users\Ashin\Documents\micrOBU_workspace\its-g5-receiver-firmware\esp-idf\export.ps1 $env:IDF_TOOLS_PATH = "C:\Espressif"
idf.py --version C:\Espressif\frameworks\esp-idf-v6.0.2\export.ps1
idf.py --version # v6.0.2
``` ```
## Going back to the previous firmware
`firmware-backups/` in the repository root (gitignored) holds a full-flash image
of the COM3 board as it was before the port, with the esptool command to write
it back in its README.txt.
## Build & flash ## Build & flash
```powershell ```powershell
@@ -97,10 +111,22 @@ Work down this list — each step isolates the layer below it.
CAMs reach the ESP32 but `esp_wifi_80211_tx` rejects them — a radio problem, CAMs reach the ESP32 but `esp_wifi_80211_tx` rejects them — a radio problem,
not a link problem. not a link problem.
## Connecting over Bluetooth instead
Settings > Connection > ESP32-C5 > link: Bluetooth, then Connect. The board
advertises as `micrOBU-XXXX` (last two bytes of its BT MAC; `micrOBU-4AFA` on
COM3), but only while nothing uses its native USB port. Android asks to pair
the first time: passkey **123456** (fixed in `simple_ble.cpp`). The bond is kept
on both sides; the board keeps one bond, so pairing a second phone or a PC
replaces the first. Log lines on the console start with `cits_ble:`.
## Notes ## Notes
- No `git submodule update` needed here — obu-firmware has no pinned - No `git submodule update` needed here — obu-firmware has no pinned
submodule of its own, unlike its-g5-receiver-firmware. submodule of its own, unlike its-g5-receiver-firmware.
- Don't use the global "ESP-IDF 5.5 PowerShell" shortcut — always export from - Don't use the global "ESP-IDF 5.5 PowerShell" shortcut or the receiver
the receiver-firmware's pinned checkout, since this firmware's undocumented firmware's 6.1 checkout — export from `esp-idf-v6.0.2`, the version the
PHY/driver internals were verified against that specific build. vanetza-idf radio's undocumented driver internals were verified against.
- The console (ESP_LOG, boot messages, panics) stays on the UART-bridge port.
Opening it resets the board; do that only while nothing else depends on the
session.
+54 -1
View File
@@ -1,4 +1,57 @@
# OBU transmit firmware - Phase 2 (in progress: HLN-SV DENM beacon) # obu-firmware
## Since 2026-09-23: signed ITS on vanetza-idf
This firmware is a port of the colleague's standalone ESP32-C5 VRU station
(`microbu-esp32c5/firmware`, its own git repository kept beside this one and gitignored here).
From it: the vanetza-idf C-ITS stack (BTP, GeoNetworking, the TS 103 097 security entity with
credentials in NVS), the station-link protocol v1 (`link_protocol.*`, `link_service.*`) over the
native USB port (`serial_link.*`, frame type 0x10 in the same 0xAA55 framing as before) and over BLE
GATT (`simple_ble.*`), and the radio adapter (`c5_radio.*`, `otm_tx_custom.c`). Build with ESP-IDF
**6.0.2**; see FLASHING.md.
The phone builds CAM or VAM, configures the station, provisions credentials, sends PoTi and hands
each message over as a BTP-DATA.request; the firmware adds GN/BTP, signs with the authorization
ticket, and transmits. The phone side is `Esp32Link.kt` in the app.
Changed or added for this project (search for `MicrOBU:` in the sources):
- **Reception stays as it was.** vanetza-idf decapsulates with `itsGnSnDecapResultHandling =
STRICT`, so it drops unsigned traffic (the bench sim car) and everything signed under a root other
than the provisioned demo root (every RSU). Every captured frame therefore also goes through the
previous firmware's `gn_unwrap.c` and reaches the phone as link opcode `V2X_RX` (0x85), whose body
is exactly the old `SERIAL_MSG_V2X_RX` payload (`Station::forward_raw`). The app's receive side is
unchanged.
- **Unsigned transmission is still possible.** The colleague's station refuses unsecured requests.
Here a request with GN security profile 1 goes out with the previous firmware's `geonet.c` header
and the position of the last PoTi (`Station::unsecured_request`); the app's "Sign outgoing
messages" setting decides per message.
- **Console on UART0.** ESP_LOG stays on the CH343 bridge port (COM3 on the bench); the native port
carries only link frames. The colleague's single-port board routes the log into LOG frames there
(`CONFIG_MICROBU_LOG_OVER_LINK`, off here).
- **BLE pauses while USB is in use** (`CONFIG_MICROBU_BLE_USB_IDLE_MS`, 3 s): BLE and ITS-G5 share
the C5's one RF front end. Whether a live BLE connection disturbs 5.9 GHz is still unmeasured
(TODO.md).
- A serial write no longer stalls the station task when no USB host is present (BLE-only use).
- A notification longer than the ATT MTU is dropped instead of silently truncated.
- The Wi-Fi RX callback's 2.4 KB capture buffer is static rather than on the driver task's stack
(the previous firmware's commit 04b0076 fixed the same risk).
- Manual country policy and the TX-power read-back from the previous firmware's radio bring-up.
- The activity LED (GPIO27 on the colleague's XIAO board) is off unless configured.
Credentials: the app ships `assets/demo-chain.vcr`, a throwaway chain generated 2026-09-23 with the
colleague's `vidf_issue` (root `6E7D0374FB021901`, AA `B3312F29844299E0`, AT `B80B49387A4C12EB`
valid two years, permissions psid 36 SSP `010000` and psid 638 SSP `01`). The colleague's own demo
chain only grants psid 638 and so cannot sign CAMs. Not EU-registered: receivers that verify against
the EU trust list drop what it signs.
Time: the signature's generationTime comes from the PoTi timestamp, which follows the app's
`ItsTime` convention (UTC-based, no leap seconds). The colleague's VBS adds the 5 leap seconds.
Which one is right is the open question documented in `ItsTime.kt`.
## Earlier notes (Phase 2, superseded)
### OBU transmit firmware - Phase 2 (in progress: HLN-SV DENM beacon)
Started. See `docs/04-transmit-setup.md` in the project root for build/flash Started. See `docs/04-transmit-setup.md` in the project root for build/flash
steps and how to validate this against your own sniffer. steps and how to validate this against your own sniffer.
+21
View File
@@ -0,0 +1,21 @@
dependencies:
espressif/esp-boost:
component_hash: 45cfa63ade2ad7c489203ab2ddf3f33be50ec9cab752b6eb17a79f06aee8f8f0
dependencies:
- name: idf
require: private
version: '>=5.3'
source:
registry_url: https://components.espressif.com/
type: service
version: 0.4.1
idf:
source:
type: idf
version: 6.0.2
direct_dependencies:
- espressif/esp-boost
- idf
manifest_hash: 3fca1283d556ade5b08c63857e23cb3b08582f1d1c24bb7c1d5e374f438415d7
target: esp32c5
version: 3.0.0
+20 -16
View File
@@ -1,18 +1,22 @@
# wifi_patches.c is intentionally NOT in this list anymore - superseded by # C++ station (from microbu-esp32c5/firmware/main) plus the C files of the previous firmware that
# tx_custom.c (see that file for why). Left on disk, unused, for history. # still do a job here:
# gn_unwrap.c - raw receive path: 802.11/LLC-SNAP/GeoNetworking/BTP-B down to the ITS payload,
# for every frame on air, signed or not (station.cpp, forward_raw).
# geonet.c - unsigned transmit path: GN SHB + BTP-B exactly as the previous firmware built it
# (station.cpp, unsecured_request).
# #
# cam.c is ALSO intentionally not in this list anymore (Phase 03): CAM is now built on the # Left on disk and NOT built, like cam.c before them:
# phone and sent down over serial_link, so this firmware never encodes CAM itself. cam.c/.h are # main.c, serial_link.c/.h - the previous firmware's entry point and phone link (frame types
# left on disk as the byte-exact reference the Kotlin encoder was ported from - do not delete. # 0x01-0x05). Superseded by app_main.cpp and the station-link protocol.
# # dot11p.c/.h - previous 802.11 framing; c5_radio.cpp frames through vanetza-idf now.
# serial_link.c/.h - binary phone<->ESP32 framing over the native USB Serial/JTAG port # Still compiled by the host tests in test/host.
# (Phase 03; changed from a GPIO UART1 wire to native USB because neither # tx_custom.c/.h - previous copy of the OpenTrafficMap raw TX; otm_tx_custom.c replaces it.
# USB-C port on the ESP32-C5-WIFI6-KIT was actually routed to that UART). # denm.c/.h, cam.c/.h, wifi_patches.c - reference only, as before.
# gn_unwrap.c/.h - strips 802.11/LLC-SNAP/GeoNetworking/BTP-B off received frames down to CAM idf_component_register(SRCS "app_main.cpp" "board_controls.cpp" "simple_ble.cpp" "c5_radio.cpp" "otm_tx_custom.c"
# UPER bytes, for forwarding to the phone over serial_link. "link_protocol.cpp" "serial_link.cpp" "station.cpp" "station_test.cpp"
idf_component_register( "link_service.cpp" "test_channel.cpp"
SRCS "main.c" "denm.c" "geonet.c" "dot11p.c" "tx_custom.c" "serial_link.c" "gn_unwrap.c" "gn_unwrap.c" "geonet.c"
INCLUDE_DIRS "." INCLUDE_DIRS "."
REQUIRES esp_event esp_netif nvs_flash driver esp_phy esp_driver_gpio esp_driver_usb_serial_jtag LDFRAGMENTS "linker.lf"
PRIV_REQUIRES esp_wifi REQUIRES vanetza-idf esp_wifi esp_phy esp_event bt esp_driver_gpio esp_driver_usb_serial_jtag nvs_flash esp_timer)
) target_compile_features(${COMPONENT_LIB} PRIVATE cxx_std_17)
+39
View File
@@ -0,0 +1,39 @@
menu "micrOBU firmware"
config MICROBU_TEST_CHANNEL
bool "Test channel on the serial link (software lower tester, ETSI campaign hooks)"
default y
help
Serves serial frame type 0x11: mirror or divert every AL_DATA.request the stack
makes to the tester, inject AL_DATA.indication, GN-DATA.request for the
Security ATS. Not part of the phone interface; disable for a production image.
config MICROBU_LOG_OVER_LINK
bool "Also carry ESP_LOG output as LOG frames (0x7F) on the native USB link"
default n
help
The colleague's single-port board has no other place for its log. This board keeps
the console on UART0 (the CH343 bridge port), so the phone's link carries no text.
config MICROBU_BLE_USB_IDLE_MS
int "USB link idle time before BLE advertising resumes (ms)"
range 1000 60000
default 3000
help
BLE and the 5.9 GHz radio share the C5's single RF front end. While the phone is
using the USB link (any frame received within this window), BLE advertising is
stopped so it cannot take airtime from ITS-G5. An existing BLE connection is left
alone. The phone sends a PoTi update with every GNSS fix, which keeps the window
open for as long as it is connected over USB.
config MICROBU_TX_LED_GPIO
int "Active-low activity LED GPIO (-1: none)"
range -1 28
default -1
help
The colleague's XIAO ESP32-C5 has its yellow user LED on GPIO27. On this project's
board GPIO27 is not a plain LED, so the indicator is off unless configured.
config MICROBU_TX_LED_BLINK_MS
int "LED indication pulse (ms)"
range 10 2000
default 100
config MICROBU_LED_INVERTED_RX
bool "Inverted LED mode for receiver/bridge (normally ON, blink OFF on packet)"
default n
endmenu
+142
View File
@@ -0,0 +1,142 @@
// micrOBU firmware: the ESP32-C5 half of the VRU ITS-S on vanetza-idf.
// One station task owns stack, security entity and radio; the serial reader task only
// enqueues frames. Link messages: implementation/station-link/README.md.
#include "link_protocol.hpp"
#include "link_service.hpp"
#include "board_controls.hpp"
#include "simple_ble.hpp"
#include "serial_link.hpp"
#include "station.hpp"
#if CONFIG_MICROBU_TEST_CHANNEL
#include "test_channel.hpp"
#endif
#include <esp_log.h>
#include <esp_task_wdt.h>
#include <esp_timer.h>
#include <nvs_flash.h>
#include <freertos/FreeRTOS.h>
#include <freertos/queue.h>
#include <freertos/task.h>
#include <algorithm>
#include <cstdio>
#include <new>
namespace {
const char* TAG = "microbu";
/// @brief Represents one frame received from a transport, to be queued for the station task.
struct Incoming {
microbu::LinkTransport transport;
microbu::serial::Frame frame;
};
QueueHandle_t frames = nullptr; // FIFO of Incoming* (8 bytes each); frame bytes never enter the queue itself
/// @brief Attempts to heap-allocate an Incoming so it can be pushed onto the frames queue.
/// @param transport Transport the frame arrived on.
/// @param frame Decoded frame to take ownership of.
/// @return Owning pointer to push onto the queue, or nullptr if allocation failed.
Incoming* try_new_incoming(microbu::LinkTransport transport, microbu::serial::Frame frame) noexcept {
try {
// std::move here steals frame's payload buffer into the heap Incoming (no byte copy);
// it has nothing to do with the queue below, which only ever transports the resulting pointer.
return new Incoming {transport, std::move(frame)};
} catch (const std::bad_alloc&) {
return nullptr;
}
}
/// @brief Dispatches one queued frame to the link service (or the test channel); logs and drops it on failure.
void handle_incoming(microbu::Station& station, microbu::LinkService& link, const Incoming& incoming) {
try {
if (incoming.frame.type == microbu::serial::FrameType::LINK)
link.handle(incoming.frame.payload, incoming.transport);
#if CONFIG_MICROBU_TEST_CHANNEL
else if (incoming.transport == microbu::LinkTransport::serial &&
incoming.frame.type == microbu::serial::FrameType::TEST)
microbu::serial::write(microbu::serial::FrameType::TEST,
microbu::test_channel_execute(station, incoming.frame.payload));
#endif
} catch (const std::bad_alloc&) {
ESP_LOGE(TAG, "out of memory while handling a frame");
} catch (const std::exception& e) {
ESP_LOGE(TAG, "frame handling failed: %s", e.what());
}
}
/// @brief MicrOBU: BLE and ITS-G5 share the C5's one RF front end. While the phone is talking over
/// USB there is no reason for BLE to take airtime, so advertising stops until the USB link has been
/// quiet for CONFIG_MICROBU_BLE_USB_IDLE_MS. A phone already connected over BLE is left connected.
void pause_ble_while_usb_in_use() {
const auto last = microbu::serial::last_frame_ms();
const auto now = static_cast<std::uint32_t>(esp_timer_get_time() / 1000);
const bool usb_in_use = last != 0 && now - last < CONFIG_MICROBU_BLE_USB_IDLE_MS;
microbu::ble::set_advertising_allowed(!usb_in_use);
}
/// @brief FreeRTOS task body: owns the station, link service and board controls. Drains the incoming queue.
void station_task(void*) {
microbu::Station station;
microbu::LinkService link(station);
microbu::BoardControls controls;
station.on_disseminated([&controls] { controls.blink(); });
station.on_received([&controls] { controls.blink(); });
ESP_LOGI(TAG, "station task ready; the phone/PC configures the station over BLE or USB Serial-JTAG");
for (;;) { /// main loop: drain the incoming queue, tick the station and link service, tick the controls
Incoming* incoming = nullptr;
// MicrOBU: at least one tick. At CONFIG_FREERTOS_HZ=100 pdMS_TO_TICKS(5) is 0, so the wait
// never blocked: this priority-10 task spun on the single core, starved every lower-priority
// task and the idle task (task watchdog), for as long as the board ran.
if (xQueueReceive(frames, &incoming, std::max<TickType_t>(1, pdMS_TO_TICKS(5))) == pdTRUE && incoming) {
handle_incoming(station, link, *incoming);
delete incoming;
}
station.tick();
link.tick();
controls.tick();
pause_ble_while_usb_in_use();
}
}
}
/// @brief Firmware entry point: initializes NVS, watchdog, serial/BLE transports, and starts the station task.
extern "C" void app_main() {
// NVS: the credential store (and the Wi-Fi driver's calibration data)
esp_err_t nvs = nvs_flash_init();
if (nvs == ESP_ERR_NVS_NO_FREE_PAGES || nvs == ESP_ERR_NVS_NEW_VERSION_FOUND) {
ESP_ERROR_CHECK(nvs_flash_erase());
nvs = nvs_flash_init();
}
ESP_ERROR_CHECK(nvs);
// Signing and verification take tens of milliseconds each on the C5. This keeps the idle
// watchdog from reporting a busy station task (5 s default) while a credential bundle applies.
esp_task_wdt_config_t watchdog = {};
watchdog.timeout_ms = 30000;
watchdog.idle_core_mask = (1 << portNUM_PROCESSORS) - 1;
watchdog.trigger_panic = false; // log, don't reboot, on timeout
esp_task_wdt_reconfigure(&watchdog);
frames = xQueueCreate(32, sizeof(Incoming*));
microbu::serial::start([](microbu::serial::Frame frame) {
auto* copy = try_new_incoming(microbu::LinkTransport::serial, std::move(frame));
if (!copy) return;
// xQueueSend copies the 8-byte pointer value into the queue, not *copy itself.
// station_task then pops pointers FIFO and owns/deletes whatever it receives.
if (xQueueSend(frames, &copy, 0) != pdTRUE) delete copy; // the phone retries on a missing RESULT
});
if (!microbu::ble::start([](microbu::link::Bytes message) {
microbu::serial::Frame frame {microbu::serial::FrameType::LINK, std::move(message)};
auto* copy = try_new_incoming(microbu::LinkTransport::ble, std::move(frame));
if (!copy) return;
if (xQueueSend(frames, &copy, 0) != pdTRUE) delete copy; // same hand-off as the serial callback above
})) {
ESP_LOGE(TAG, "BLE station link failed to start. USB remains available");
}
ESP_LOGI(TAG, "MicrOBU obu-firmware on vanetza-idf, station-link v1 + V2X_RX, console on UART0%s",
#if CONFIG_MICROBU_TEST_CHANNEL
", test channel enabled");
#else
"");
#endif
xTaskCreate(station_task, "station", 12288, nullptr, 10, nullptr);
}
+65
View File
@@ -0,0 +1,65 @@
#include "board_controls.hpp"
#include <driver/gpio.h>
#include <esp_log.h>
#include <esp_timer.h>
namespace microbu {
namespace {
const char* TAG = "board";
constexpr std::int64_t blink_us = CONFIG_MICROBU_TX_LED_BLINK_MS * 1000LL;
}
BoardControls::BoardControls() {
#if CONFIG_MICROBU_TX_LED_GPIO < 0
ESP_LOGI(TAG, "no activity LED configured");
#else
gpio_config_t led = {};
led.pin_bit_mask = 1ULL << CONFIG_MICROBU_TX_LED_GPIO;
led.mode = GPIO_MODE_OUTPUT;
led.pull_up_en = GPIO_PULLUP_DISABLE;
led.pull_down_en = GPIO_PULLDOWN_DISABLE;
led.intr_type = GPIO_INTR_DISABLE;
ESP_ERROR_CHECK(gpio_config(&led));
#if CONFIG_MICROBU_LED_INVERTED_RX
mode_ = LedMode::inverted_rx;
#else
mode_ = LedMode::normal_tx;
#endif
apply_led_state();
ESP_LOGI(TAG, "LED GPIO %d (mode: %s)", CONFIG_MICROBU_TX_LED_GPIO,
mode_ == LedMode::inverted_rx ? "inverted_rx (normally ON)" : "normal_tx (normally OFF)");
#endif
}
void BoardControls::apply_led_state() {
#if CONFIG_MICROBU_TX_LED_GPIO >= 0
// The XIAO ESP32-C5 user LED is wired active-low (0 = ON, 1 = OFF).
// In normal_tx mode: default OFF (1), pulsing ON (0).
// In inverted_rx mode: default ON (0), pulsing OFF (1).
bool led_illuminated = false;
if (mode_ == LedMode::normal_tx) {
led_illuminated = pulsing_;
} else {
led_illuminated = !pulsing_;
}
gpio_set_level(static_cast<gpio_num_t>(CONFIG_MICROBU_TX_LED_GPIO), led_illuminated ? 0 : 1);
#endif
}
void BoardControls::blink() {
pulse_until_us_ = esp_timer_get_time() + blink_us;
if (!pulsing_) {
pulsing_ = true;
apply_led_state();
}
}
void BoardControls::tick() {
if (pulsing_ && esp_timer_get_time() >= pulse_until_us_) {
pulsing_ = false;
apply_led_state();
}
}
} // namespace microbu
+31
View File
@@ -0,0 +1,31 @@
#pragma once
#include <cstdint>
namespace microbu {
/// XIAO ESP32-C5 active-low user LED: a visual indicator for TX/RX activity.
class BoardControls {
public:
/// @brief Configures the LED GPIO and sets its idle state.
BoardControls();
enum class LedMode {
normal_tx, // Normally off, pulses on upon frame dissemination
inverted_rx // Normally on, pulses off upon frame reception
};
/// @brief Triggers visual indication (blink on in normal_tx mode, blink off in inverted_rx mode).
void blink();
/// @brief Updates LED state and checks indication timers; call periodically.
void tick();
private:
void apply_led_state();
LedMode mode_ = LedMode::normal_tx;
bool pulsing_ = false;
std::int64_t pulse_until_us_ = 0;
};
} // namespace microbu
+469
View File
@@ -0,0 +1,469 @@
#include "c5_radio.hpp"
#include "otm_tx_custom.h"
#include <vanetza_idf/its_g5_frame.hpp>
#include <esp_event.h>
#include <esp_log.h>
#include <esp_wifi.h>
#include <hal/modem_syscon_ll.h>
#include <esp_timer.h>
#include <freertos/FreeRTOS.h>
#include <freertos/queue.h>
#include <algorithm>
#include <atomic>
#include <cmath>
#include <cstring>
#include <iterator>
#include <limits>
#include <mutex>
extern "C" { //all of these arentt in the esp-idf public api
// phy_11p_set/phy_change_channel: undocumented esp_phy/lib/esp32c5/libphy.a entry points, not
// declared in any Espressif header.
// The call sites and argument values below (phy_11p_set(1, 0), phy_change_channel(freq, 1, 0, 0))
// are copied from OpenTrafficMap's its-g5-receiver-firmware_txenabled, main/cmd_sniffer.c
// (https://codeberg.org/opentrafficmap/its-g5-receiver-firmware_txenabled, community reverse
// engineering, no stated license).
void phy_11p_set(int enable, int arg2);
void phy_change_channel(int freq_mhz, int arg2_ignored, int arg3_ignored, int arg4);
// phy_get_cca/phy_set_cca: register 0x600a701c[7:0] holds the configured CCA energy
// detection threshold (defaults to 191 = 0xBF = -65 dBm in 8-bit two's complement).
// phy_get_cca() reads this configured threshold.
int phy_get_cca(void);
void phy_set_cca(int enable, int threshold);
// phy_get_cca_cnt/phy_set_cca_cnt: register 0x600a7c58 arms the 27-bit hardware CCA
// cycle counters (0x600a7c5c = total cycles, 0x600a7c60 = busy cycles; confirmed on
// hardware -- out[0] free-runs at ~40 MHz, out[1] stays near zero on a quiet channel).
// phy_get_cca_cnt returns bit 27 (busy/status bit) and writes both counters to out[2].
int phy_get_cca_cnt(std::int32_t out[2]);
void phy_set_cca_cnt(std::int32_t val, bool enable);
void phy_enable_cca(void);
void phy_disable_cca(void);
int phy_get_noise_floor(void);
}
namespace microbu {
namespace {
const char* TAG = "c5_radio";
}
class C5Radio::Impl {
public:
/// @brief Raw received frame metadata and bytes from the promiscuous RX callback.
struct Raw {
std::uint16_t length;
std::int8_t rssi;
std::uint32_t timestamp;
std::uint8_t bytes[2346]; // max 802.11 frame size
};
C5RadioConfig config;
QueueHandle_t queue = nullptr;
bool initialized = false, started = false, own_event_loop = false;
std::uint16_t sequence = 0;
std::atomic<std::uint32_t> dropped {0};
static Impl* active;
static std::mutex callback_mutex;
explicit Impl(C5RadioConfig c) : config(c) {}
static void receive(void* buffer, wifi_promiscuous_pkt_type_t type) {
if (!buffer || type != WIFI_PKT_DATA) return;
const auto* packet = static_cast<const wifi_promiscuous_pkt_t*>(buffer);
if (packet->rx_ctrl.rx_state != 0) return;
const auto length = packet->rx_ctrl.sig_len;
std::lock_guard<std::mutex> lock(callback_mutex);
if (!active || !active->queue) return;
// MicrOBU: a frame too short to hold any GN packet is not ITS traffic, so it is ignored
// rather than counted; dropped_frames() then means what the phone shows it as: lost frames.
if (length < 38) return;
if (length > sizeof(Raw::bytes)) { ++active->dropped; return; }
// MicrOBU: static, not a local. Raw is ~2.4 KB and this runs on the Wi-Fi driver's own task,
// several frames deep, on a stack of roughly 3.5 KB: the previous firmware hit exactly this
// with an 800-byte buffer (obu-firmware commit 04b0076). Safe as a static because only that
// one task calls this, and xQueueSend copies it out before the next call.
static Raw raw;
raw.length = length;
raw.rssi = packet->rx_ctrl.rssi;
raw.timestamp = packet->rx_ctrl.timestamp;
std::memcpy(raw.bytes, packet->payload, length);
if (xQueueSend(active->queue, &raw, 0) != pdTRUE) ++active->dropped;
}
};
C5Radio::Impl* C5Radio::Impl::active = nullptr;
std::mutex C5Radio::Impl::callback_mutex;
C5Radio::C5Radio(C5RadioConfig c) : impl_(std::make_unique<Impl>(c)) {}
C5Radio::~C5Radio() { stop(); }
esp_err_t C5Radio::start() {
auto& p = *impl_;
const auto& c = p.config;
if (p.initialized) return ESP_ERR_INVALID_STATE;
// Reject channel/power/queue config outside the supported ITS-G5 range
if (c.channel_number < 172 || c.channel_number > 184 || c.channel_number % 2 ||
!std::isfinite(c.transmit_power_dbm) || c.transmit_power_dbm < 2 || c.transmit_power_dbm > 23 ||
std::floor(c.transmit_power_dbm * 4) != c.transmit_power_dbm * 4 ||
c.receive_queue_length == 0 || c.receive_queue_length > 32) {
return ESP_ERR_INVALID_ARG;
}
{
std::lock_guard<std::mutex> lock(Impl::callback_mutex);
if (Impl::active) return ESP_ERR_INVALID_STATE;
p.queue = xQueueCreate(c.receive_queue_length, sizeof(Impl::Raw));
if (!p.queue) return ESP_ERR_NO_MEM;
Impl::active = &p;
}
auto result = esp_event_loop_create_default();
p.own_event_loop = (result == ESP_OK);
if (result != ESP_OK && result != ESP_ERR_INVALID_STATE) {
stop();
return result;
}
// Establish modem FE clock for 802.11p OFDM
modem_syscon_ll_enable_fe_40m_clock(&MODEM_SYSCON, true);
wifi_init_config_t wifi = WIFI_INIT_CONFIG_DEFAULT();
wifi.nvs_enable = 0;
result = esp_wifi_init(&wifi);
if (result != ESP_OK) {
stop();
return result;
}
p.initialized = true;
auto attempt = [&](esp_err_t r) { if (result == ESP_OK) result = r; };
attempt(esp_wifi_set_storage(WIFI_STORAGE_RAM));
attempt(esp_wifi_set_mode(WIFI_MODE_STA));
if (result == ESP_OK) {
result = esp_wifi_start();
p.started = (result == ESP_OK);
}
if (result != ESP_OK) {
stop();
return result;
}
// MicrOBU, from the previous firmware: under the default WIFI_COUNTRY_POLICY_AUTO the driver's
// 5 GHz table does not authorise transmission on the ITS band, which there left RX working and
// TX silent. The colleague's board transmits without this (esp_wifi_80211_tx_custom goes around
// that gate), so it is belt and braces here: manual policy, every 5 GHz channel enabled. Not
// fatal if refused.
wifi_country_t country = {};
country.cc[0] = 'U'; country.cc[1] = 'S';
country.schan = 1;
country.nchan = 11;
country.policy = WIFI_COUNTRY_POLICY_MANUAL;
country.wifi_5g_channel_mask = 0x1FFFFFFE;
if (const auto e = esp_wifi_set_country(&country); e != ESP_OK)
ESP_LOGW(TAG, "esp_wifi_set_country(MANUAL) failed: %s (continuing)", esp_err_to_name(e));
attempt(esp_wifi_set_band_mode(WIFI_BAND_MODE_5G_ONLY));
attempt(esp_wifi_set_ps(WIFI_PS_NONE));
attempt(esp_wifi_set_max_tx_power(static_cast<std::int8_t>(c.transmit_power_dbm * 4)));
wifi_promiscuous_filter_t filter {};
filter.filter_mask = WIFI_PROMIS_FILTER_MASK_DATA;
attempt(esp_wifi_set_promiscuous_filter(&filter));
attempt(esp_wifi_set_promiscuous_rx_cb(Impl::receive));
attempt(esp_wifi_set_promiscuous(true));
if (result != ESP_OK) {
stop();
return result;
}
// 10 MHz channel bandwidth (ITS-G5 / 802.11p)
phy_11p_set(1, 0);
phy_change_channel(5000 + 5 * c.channel_number, 1, 0, 0); // = 5900 MHz
// Enable and arm hardware CCA counters (40 MHz baseband clock timebase) for DCC
phy_enable_cca();
phy_set_cca_cnt(0x07FFFFFF, true);
// MicrOBU, from the previous firmware: the power request is a ceiling, not a promise. The driver
// clamps it to its calibrated table, and 5900 MHz is above the chip's rated range, so log what
// the driver admits to rather than what was asked for.
std::int8_t power_q = 0;
if (esp_wifi_get_max_tx_power(&power_q) == ESP_OK) {
ESP_LOGI(TAG, "tx power: %d quarter-dBm = %d.%02d dBm (%.2f requested)",
power_q, power_q / 4, (power_q % 4) * 25, c.transmit_power_dbm);
}
ESP_LOGI(TAG, "ITS-G5 802.11p radio started on channel %u (5900 MHz), %s",
unsigned(c.channel_number), c.laboratory_transmission ? "TX/RX" : "RX only");
return ESP_OK;
}
void C5Radio::stop() {
if (!impl_) return;
auto& p = *impl_;
if (p.started) esp_wifi_set_promiscuous(false);
{
std::lock_guard<std::mutex> lock(Impl::callback_mutex);
if (Impl::active == &p) Impl::active = nullptr;
if (p.queue) {
vQueueDelete(p.queue);
p.queue = nullptr;
}
}
if (p.started) esp_wifi_stop();
if (p.initialized) esp_wifi_deinit();
if (p.own_event_loop) esp_event_loop_delete_default();
p.started = p.initialized = p.own_event_loop = false;
}
vanetza_idf::Result C5Radio::request(vanetza_idf::AlDataRequest request) {
auto& p = *impl_;
if (!p.started) return vanetza_idf::Result::rejected;
if (!p.config.laboratory_transmission) return vanetza_idf::Result::unsupported;
if (request.bandwidth_mhz != 10 || request.channel_number != p.config.channel_number ||
request.transceiver_id != 0 || request.transceiver_mode || request.datastream_id ||
request.transmit_power_dbm != p.config.transmit_power_dbm) {
return vanetza_idf::Result::unsupported;
}
constexpr wifi_phy_rate_t rates[] = {
WIFI_PHY_RATE_6M, WIFI_PHY_RATE_9M, WIFI_PHY_RATE_12M,
WIFI_PHY_RATE_18M, WIFI_PHY_RATE_24M, WIFI_PHY_RATE_36M,
WIFI_PHY_RATE_48M, WIFI_PHY_RATE_54M
};
const auto index = static_cast<unsigned>(request.mcs);
if (index >= std::size(rates)) return vanetza_idf::Result::invalid_argument;
vanetza::ByteBuffer bytes;
const auto encoded = vanetza_idf::its_g5::encode_frame(request, p.sequence, bytes);
if (encoded != vanetza_idf::Result::accepted) {
ESP_LOGE(TAG, "encode_frame failed: %d", int(encoded));
return encoded;
}
p.sequence = (p.sequence + 1) & 4095;
wifi_tx_rate_config_t rate {};
rate.phymode = WIFI_PHY_MODE_11A;
rate.rate = rates[index];
// Transmit frame via 802.11p driver
const auto result = esp_wifi_80211_tx_custom(
WIFI_IF_STA, bytes.data(), bytes.size(), false,
&rate, WIFI_BAND_5G, WIFI_BW20);
if (result != ESP_OK) {
ESP_LOGW(TAG, "esp_wifi_80211_tx_custom failed: %s (0x%x)", esp_err_to_name(result), result);
}
return result == ESP_OK ? vanetza_idf::Result::accepted :
result == ESP_ERR_NO_MEM ? vanetza_idf::Result::resource_limit :
vanetza_idf::Result::rejected;
}
void C5Radio::poll(const Receive& receive, const Capture& capture) {
auto& p = *impl_;
if (!p.queue) return;
Impl::Raw raw {};
for (unsigned i = 0; i < p.config.receive_queue_length && xQueueReceive(p.queue, &raw, 0) == pdTRUE; ++i) {
if (capture) {
capture(vanetza::ByteBuffer(raw.bytes, raw.bytes + raw.length), raw.rssi, raw.timestamp);
}
vanetza_idf::AlDataIndication ind;
if (vanetza_idf::its_g5::decode_frame(raw.bytes, raw.length, true, ind) != vanetza_idf::Result::accepted) {
continue;
}
ind.channel_number = p.config.channel_number;
ind.received_power_dbm = raw.rssi;
if (receive) receive(std::move(ind));
}
}
std::uint32_t C5Radio::dropped_frames() const {
return impl_->dropped.load();
}
#if CONFIG_MICROBU_TEST_CHANNEL
esp_err_t C5Radio::transmit_burst(std::uint16_t channel, double power_dbm, unsigned mcs,
unsigned count, unsigned interval_ms, std::size_t payload_len) {
auto& p = *impl_;
if (!p.started) return ESP_ERR_INVALID_STATE;
if (channel < 172 || channel > 184 || channel % 2 != 0) return ESP_ERR_INVALID_ARG;
if (power_dbm < 2.0 || power_dbm > 20.0) return ESP_ERR_INVALID_ARG;
if (mcs > 7) return ESP_ERR_INVALID_ARG;
if (count == 0) return ESP_OK;
constexpr wifi_phy_rate_t rates[] = {
WIFI_PHY_RATE_6M, WIFI_PHY_RATE_9M, WIFI_PHY_RATE_12M,
WIFI_PHY_RATE_18M, WIFI_PHY_RATE_24M, WIFI_PHY_RATE_36M,
WIFI_PHY_RATE_48M, WIFI_PHY_RATE_54M
};
// Dynamically retune channel or adjust TX power if different from running config
if (channel != p.config.channel_number) {
phy_11p_set(1, 0);
phy_change_channel(5000 + 5 * channel, 1, 0, 0);
p.config.channel_number = channel;
}
const auto power_quarter_db = static_cast<std::int8_t>(std::round(power_dbm * 4.0)); // esp_wifi power is in 0.25 dBm units
esp_wifi_set_max_tx_power(power_quarter_db);
p.config.transmit_power_dbm = power_dbm;
wifi_tx_rate_config_t rate {};
rate.phymode = WIFI_PHY_MODE_11A;
rate.rate = rates[mcs];
// Assemble a standard IEEE 802.11 QoS data / LLC frame (EtherType 0x8947 GeoNetworking)
// Header: Frame Control (0x0088 QoS Data), Duration (0x0000), Addr1 (Broadcast FF..FF),
// Addr2 (Source 02:00:00:00:00:01), Addr3 (BSSID FF..FF), Sequence, QoS Control (0x0000),
// LLC/SNAP header (AA AA 03 00 00 00 89 47).
std::vector<std::uint8_t> frame;
const std::size_t actual_payload = std::clamp<std::size_t>(payload_len, 32, 1400);
frame.reserve(34 + actual_payload);
// MAC Header (26 bytes with QoS)
frame.push_back(0x88); frame.push_back(0x00); // Frame Control: QoS Data
frame.push_back(0x00); frame.push_back(0x00); // Duration
for (int i = 0; i < 6; ++i) frame.push_back(0xFF); // RA / Destination: Broadcast
frame.push_back(0x02); frame.push_back(0x00); frame.push_back(0x00);
frame.push_back(0x00); frame.push_back(0x00); frame.push_back(0x01); // TA / Source
for (int i = 0; i < 6; ++i) frame.push_back(0xFF); // BSSID: Broadcast
frame.push_back(0x00); frame.push_back(0x00); // Sequence (updated per frame)
frame.push_back(0x00); frame.push_back(0x00); // QoS Control
// LLC/SNAP header (8 bytes)
frame.push_back(0xAA); frame.push_back(0xAA); frame.push_back(0x03);
frame.push_back(0x00); frame.push_back(0x00); frame.push_back(0x00);
frame.push_back(0x89); frame.push_back(0x47); // EtherType 0x8947 (GeoNetworking)
// Test payload with identifiable sequence numbers
const std::size_t header_len = frame.size();
frame.resize(header_len + actual_payload, 0x5A);
esp_err_t last_err = ESP_OK;
for (unsigned i = 0; i < count; ++i) {
p.sequence = (p.sequence + 1) & 4095;
frame[22] = static_cast<std::uint8_t>((p.sequence << 4) & 0xF0);
frame[23] = static_cast<std::uint8_t>((p.sequence >> 4) & 0xFF);
// Put burst counter inside payload
frame[header_len + 0] = static_cast<std::uint8_t>(i & 0xFF);
frame[header_len + 1] = static_cast<std::uint8_t>((i >> 8) & 0xFF);
esp_err_t err = esp_wifi_80211_tx_custom(
WIFI_IF_STA, frame.data(), frame.size(), false,
&rate, WIFI_BAND_5G, WIFI_BW20);
if (err == ESP_ERR_NO_MEM) {
// Buffer briefly full: yield task to allow DMA descriptors to clear
vTaskDelay(pdMS_TO_TICKS(2));
err = esp_wifi_80211_tx_custom(
WIFI_IF_STA, frame.data(), frame.size(), false,
&rate, WIFI_BAND_5G, WIFI_BW20);
}
if (err != ESP_OK) {
last_err = err;
}
const auto delay_ms = std::max<unsigned>(interval_ms, 2);
if (i + 1 < count) {
vTaskDelay(pdMS_TO_TICKS(delay_ms));
}
}
return last_err;
}
CcaSampleResult C5Radio::sample_cca(unsigned duration_ms) {
CcaSampleResult result;
auto& p = *impl_;
if (!p.started) return result;
result.noise_floor_dbm = phy_get_noise_floor();
// Enable CCA hardware and arm the 27-bit cycle counters with full window (0x07FFFFFF).
phy_enable_cca();
phy_set_cca_cnt(0x07FFFFFF, true);
std::int32_t cca_cnt_before[2] = {};
phy_get_cca_cnt(cca_cnt_before);
const auto t_start = esp_timer_get_time();
const auto t_deadline = t_start + static_cast<std::int64_t>(duration_ms) * 1000;
std::int64_t last_t = t_start;
result.min_delta_us = std::numeric_limits<std::uint32_t>::max();
while (esp_timer_get_time() < t_deadline) {
std::int32_t cur_cnt[2] = {};
const auto status = phy_get_cca_cnt(cur_cnt);
const auto cca_threshold = phy_get_cca();
const auto now = esp_timer_get_time();
if (result.samples == 0) {
result.first_cca = cca_threshold;
} else {
const auto delta = static_cast<std::uint32_t>(now - last_t);
result.min_delta_us = std::min(result.min_delta_us, delta);
result.max_delta_us = std::max(result.max_delta_us, delta);
}
last_t = now;
result.last_cca = cca_threshold;
if (status) ++result.busy_count;
++result.samples;
// Cooperative yielding: prevent starving IDLE task, esp_timer, and bb_wdt
// on the single-core C5 during multi-millisecond polling windows.
if ((result.samples & 0x3F) == 0) {
taskYIELD();
}
}
result.duration_us = static_cast<std::uint32_t>(esp_timer_get_time() - t_start);
std::int32_t cca_cnt_after[2] = {};
result.cca_status = phy_get_cca_cnt(cca_cnt_after);
// Both words are 27-bit hardware counters (mask 0x07FFFFFF).
constexpr std::int32_t mask27 = 0x07FFFFFF;
auto delta27 = [](std::int32_t after, std::int32_t before) -> std::int32_t {
std::int32_t diff = (after & mask27) - (before & mask27);
if (diff < 0) diff += (mask27 + 1);
return diff;
};
result.cca_total_cycles_delta = delta27(cca_cnt_after[0], cca_cnt_before[0]);
result.cca_busy_cycles_delta = delta27(cca_cnt_after[1], cca_cnt_before[1]);
if (result.samples < 2) result.min_delta_us = 0;
return result;
}
#endif // CONFIG_MICROBU_TEST_CHANNEL
CcaCounters C5Radio::read_cca_counters() const {
CcaCounters c;
std::int32_t out[2] = {};
phy_get_cca_cnt(out);
constexpr std::int32_t mask27 = 0x07FFFFFF; // remomve the busy/status bit (bit 27) from the 27-bit hardware counters
c.total_cycles = static_cast<std::uint32_t>(out[0] & mask27);
c.busy_cycles = static_cast<std::uint32_t>(out[1] & mask27);
return c;
}
double C5Radio::calculate_cbr(const CcaCounters& current, const CcaCounters& previous) {
constexpr std::uint32_t counter_range = 1u << 27;
auto delta27 = [](std::uint32_t after, std::uint32_t before) -> std::uint32_t {
if (after >= before) {
return after - before;
}
// Counter wrapped from 2^27 - 1 back to zero.
return after + counter_range - before;
};
const std::uint32_t dt = delta27(current.total_cycles, previous.total_cycles);
const std::uint32_t db = delta27(current.busy_cycles, previous.busy_cycles);
if (dt == 0) return 0.0;
return static_cast<double>(db) / static_cast<double>(dt);
}
} // namespace microbu
+117
View File
@@ -0,0 +1,117 @@
#pragma once
#include <vanetza_idf/access.hpp>
#include <esp_err.h>
#include <functional>
#include <memory>
namespace microbu {
struct C5RadioConfig {
std::uint16_t channel_number = 180;
double transmit_power_dbm = 10.0;
unsigned receive_queue_length = 16;
bool laboratory_transmission = true;
};
#if CONFIG_MICROBU_TEST_CHANNEL
/// Result of polling the PHY's own (undocumented) CCA state as fast as possible for a
/// fixed window (feasibility probe for EN 303 797 clause 4.6.2)
/// to determine if the platform can observe channel-busy state at >=1 kHz at all.
struct CcaSampleResult {
std::uint32_t samples = 0;
std::uint32_t duration_us = 0;
std::uint32_t min_delta_us = 0;
std::uint32_t max_delta_us = 0;
std::uint32_t busy_count = 0;
std::int32_t first_cca = 0;
std::int32_t last_cca = 0;
std::int32_t noise_floor_dbm = 0;
std::int32_t cca_total_cycles_delta = 0; // out[0]/0x600a7c5c: 40 MHz free-running counter
std::int32_t cca_busy_cycles_delta = 0; // out[1]/0x600a7c60: increments while channel busy
std::int32_t cca_status = 0;
};
#endif
/// Snapshot of the ESP32-C5 27-bit hardware PHY counters for ETSI TS 102 687 / EN 303 797 DCC.
/// total_cycles: 40 MHz FE clock cycles (register 0x600a7c5c[26:0]).
/// busy_cycles: cycles during which received RF energy exceeded the CCA threshold (register 0x600a7c60[26:0]).
struct CcaCounters {
std::uint32_t total_cycles = 0;
std::uint32_t busy_cycles = 0;
};
/**
* ITS-G5 802.11p Radio Access Adapter for ESP32-C5 (EN 303 797 Annex B.2).
* Direct Wi-Fi promiscuous RX mode on 5.9 GHz (ITS-G5 Channel 180, 10 MHz BW)
* and 802.11p frame transmission via esp_wifi_80211_tx_custom.
*/
class C5Radio final : public vanetza_idf::Access {
public:
using Receive = std::function<void(vanetza_idf::AlDataIndication)>;
using Capture = std::function<void(const vanetza::ByteBuffer&, int, std::uint32_t)>;
/// @brief Constructs a radio adapter with the given configuration (not yet started).
/// @param config Radio configuration; defaults to channel 180, 10 dBm, 16-deep RX queue.
explicit C5Radio(C5RadioConfig config = {});
/// @brief Stops the radio and releases all resources.
~C5Radio() override;
C5Radio(const C5Radio&) = delete;
C5Radio& operator=(const C5Radio&) = delete;
/// @brief Initializes Wi-Fi in promiscuous 802.11p mode and starts the radio.
/// @return ESP_OK on success, otherwise an ESP-IDF error code.
esp_err_t start();
/// @brief Stops the radio and releases all resources.
void stop();
// vanetza_idf::Access
/// @brief Encodes and transmits an ITS-G5 frame requested by the access layer.
/// @param request Frame and transmit parameters from the access layer.
/// @return Result of the transmit attempt.
vanetza_idf::Result request(vanetza_idf::AlDataRequest request) override;
/// @brief Dispatches queued received frames to the owning task callback.
/// @param receive Invoked once per queued frame with its decoded indication.
/// @param capture Optional; invoked with the raw frame bytes, RSSI and timestamp for diagnostics.
void poll(const Receive& receive, const Capture& capture = {});
#if CONFIG_MICROBU_TEST_CHANNEL
/// @brief Sends a burst of raw 802.11p test frames for RF characterization.
/// @param channel ITS-G5 channel number to transmit on.
/// @param power_dbm Transmit power in dBm.
/// @param mcs 802.11p modulation and coding scheme index.
/// @param count Number of frames to send.
/// @param interval_ms Interval between frames in milliseconds.
/// @param payload_len Length of each frame's payload in bytes.
/// @return ESP_OK if all frames were sent, otherwise the last transmit error.
esp_err_t transmit_burst(std::uint16_t channel, double power_dbm, unsigned mcs,
unsigned count, unsigned interval_ms, std::size_t payload_len);
/// @brief Polls the CCA state as fast as possible for a fixed window (see CcaSampleResult); requires start().
/// @note This is a diagnostic tool/ feasibility probe for EN 303 797 clause 4.6.2 to determine if the platform can observe channel-busy state at >=1 kHz at all.
/// @param duration_ms Duration of the sampling window in milliseconds.
/// @return Sampling statistics and counter deltas over the window.
CcaSampleResult sample_cca(unsigned duration_ms);
#endif
/// @brief Non-blocking read of the hardware CCA counters for periodic DCC evaluation.
/// Continuous 40 MHz hardware integration satisfies EN 303 797 Profile-1 (>=1 kHz) with zero CPU busy-wait.
/// @return Snapshot of total and busy cycle counts.
CcaCounters read_cca_counters() const;
/// @brief Calculates Channel Busy Ratio (CBR) between two counter snapshots: delta(busy) / delta(total).
/// @param current More recent counter snapshot.
/// @param previous Earlier counter snapshot.
/// @return CBR in [0, 1].
static double calculate_cbr(const CcaCounters& current, const CcaCounters& previous);
/// @brief Number of frames dropped since start() due to queue overflow or invalid length.
/// @return Dropped frame count.
std::uint32_t dropped_frames() const;
private:
class Impl;
std::unique_ptr<Impl> impl_;
};
} // namespace microbu
+186
View File
@@ -0,0 +1,186 @@
#include "link_protocol.hpp"
namespace microbu::link {
bool encode(const Message& message, Bytes& out) {
if (header_size + message.body.size() > maximum_message) return false;
out.clear();
out.reserve(header_size + message.body.size());
out.push_back(static_cast<std::uint8_t>(message.header.opcode));
out.push_back(message.header.flags);
out.push_back(message.header.sequence & 0xFF);
out.push_back(message.header.sequence >> 8);
out.insert(out.end(), message.body.begin(), message.body.end());
return true;
}
bool decode(const Bytes& octets, Message& out) {
if (octets.size() < header_size || octets.size() > maximum_message) return false;
out.header.opcode = static_cast<Opcode>(octets[0]);
out.header.flags = octets[1];
out.header.sequence = octets[2] | (octets[3] << 8);
out.body.assign(octets.begin() + header_size, octets.end());
return true;
}
namespace {
void put_area(Writer& w, const DestinationArea& a) {
w.u8(a.shape); w.i32(a.latitude); w.i32(a.longitude); w.u16(a.distance_a); w.u16(a.distance_b); w.u16(a.angle);
}
DestinationArea get_area(Reader& r) {
DestinationArea a;
a.shape = r.u8(); a.latitude = r.i32(); a.longitude = r.i32(); a.distance_a = r.u16(); a.distance_b = r.u16(); a.angle = r.u16();
return a;
}
}
// STATION_CONFIGURE
Bytes encode(const StationConfigure& c) {
Writer w;
w.u8(c.station_type); w.u8(c.security); w.u8(c.address_configuration); w.bytes(c.mid, 6); w.u8(c.beaconing);
w.u16(c.channel_number); w.u8(c.transmit_power_dbm); w.u8(c.radio); w.u8(c.default_traffic_class); w.u8(c.default_lifetime);
return w.out;
}
bool decode(const Bytes& body, StationConfigure& c) {
Reader r(body);
c.station_type = r.u8(); c.security = r.u8(); c.address_configuration = r.u8(); r.bytes(c.mid, 6); c.beaconing = r.u8();
c.channel_number = r.u16(); c.transmit_power_dbm = r.u8(); c.radio = r.u8(); c.default_traffic_class = r.u8(); c.default_lifetime = r.u8();
return r.done() && c.security <= 1 && c.address_configuration <= 1 && c.beaconing <= 1 && c.radio <= 2;
}
// POTI_UPDATE
Bytes encode(const PotiUpdate& p) {
Writer w;
w.u64(p.timestamp_ms); w.i32(p.latitude); w.i32(p.longitude); w.u16(p.semi_major_cm); w.u16(p.semi_minor_cm);
w.u16(p.orientation_deci_degree); w.u8(p.flags); w.i32(p.altitude_cm); w.u16(p.speed_cm_s); w.u16(p.heading_deci_degree);
return w.out;
}
bool decode(const Bytes& body, PotiUpdate& p) {
Reader r(body);
p.timestamp_ms = r.u64(); p.latitude = r.i32(); p.longitude = r.i32(); p.semi_major_cm = r.u16(); p.semi_minor_cm = r.u16();
p.orientation_deci_degree = r.u16(); p.flags = r.u8(); p.altitude_cm = r.i32(); p.speed_cm_s = r.u16(); p.heading_deci_degree = r.u16();
return r.done();
}
// BTP_DATA_REQUEST
Bytes encode(const BtpDataRequest& q) {
Writer w;
w.u8(q.btp_type); w.u16(q.destination_port); w.u16(q.destination_port_info); w.u8(q.gn_packet_transport_type);
w.u8(q.gn_communication_profile); w.u8(q.gn_security_profile); w.u8(q.gn_traffic_class); w.u8(q.gn_maximum_packet_lifetime);
w.u8(q.gn_maximum_hop_limit); w.u16(q.gn_repetition_interval_ms); w.u16(q.gn_repetition_maximum_ms); w.u32(q.its_aid);
w.u8(static_cast<std::uint8_t>(q.permissions.size())); w.bytes(q.permissions);
w.u8(static_cast<std::uint8_t>(q.context.size())); w.bytes(q.context);
if (q.gn_packet_transport_type == 3) put_area(w, q.area.value_or(DestinationArea {}));
w.u16(static_cast<std::uint16_t>(q.fl_sdu.size())); w.bytes(q.fl_sdu);
return w.out;
}
bool decode(const Bytes& body, BtpDataRequest& q) {
Reader r(body);
q.btp_type = r.u8(); q.destination_port = r.u16(); q.destination_port_info = r.u16(); q.gn_packet_transport_type = r.u8();
q.gn_communication_profile = r.u8(); q.gn_security_profile = r.u8(); q.gn_traffic_class = r.u8(); q.gn_maximum_packet_lifetime = r.u8();
q.gn_maximum_hop_limit = r.u8(); q.gn_repetition_interval_ms = r.u16(); q.gn_repetition_maximum_ms = r.u16(); q.its_aid = r.u32();
q.permissions = r.bytes(r.u8());
q.context = r.bytes(r.u8());
q.area.reset();
if (q.gn_packet_transport_type == 3) q.area = get_area(r);
q.fl_sdu = r.bytes(r.u16());
return r.done() && q.btp_type <= 1 && q.permissions.size() <= 31;
}
// BTP_DATA_INDICATION
Bytes encode(const BtpDataIndication& i) {
Writer w;
w.u8(i.btp_type); w.u16(i.destination_port); w.u16(i.destination_port_info); w.u8(i.gn_packet_transport_type);
w.u8(i.gn_traffic_class); w.u8(i.gn_remaining_packet_lifetime); w.u8(i.gn_remaining_hop_limit);
w.bytes(i.source_gn_address, 8); w.u32(i.source_timestamp); w.i32(i.source_latitude); w.i32(i.source_longitude);
w.u8(i.security_report); w.u32(i.its_aid);
w.u8(static_cast<std::uint8_t>(i.permissions.size())); w.bytes(i.permissions);
w.u8(i.certificate_present ? 1 : 0); w.bytes(i.certificate_id, 8);
w.u8(i.area ? 1 : 0);
if (i.area) put_area(w, *i.area);
w.u16(static_cast<std::uint16_t>(i.received_fl_sdu.size())); w.bytes(i.received_fl_sdu);
return w.out;
}
bool decode(const Bytes& body, BtpDataIndication& i) {
Reader r(body);
i.btp_type = r.u8(); i.destination_port = r.u16(); i.destination_port_info = r.u16(); i.gn_packet_transport_type = r.u8();
i.gn_traffic_class = r.u8(); i.gn_remaining_packet_lifetime = r.u8(); i.gn_remaining_hop_limit = r.u8();
r.bytes(i.source_gn_address, 8); i.source_timestamp = r.u32(); i.source_latitude = r.i32(); i.source_longitude = r.i32();
i.security_report = r.u8(); i.its_aid = r.u32();
i.permissions = r.bytes(r.u8());
i.certificate_present = r.u8() != 0; r.bytes(i.certificate_id, 8);
i.area.reset();
if (r.u8()) i.area = get_area(r);
i.received_fl_sdu = r.bytes(r.u16());
return r.done();
}
// CREDENTIALS_PROVISION
Bytes encode(const CredentialsProvision& c) {
Writer w;
w.u16(c.total_length); w.u16(c.offset); w.u8(static_cast<std::uint8_t>(c.segment.size())); w.bytes(c.segment);
return w.out;
}
bool decode(const Bytes& body, CredentialsProvision& c) {
Reader r(body);
c.total_length = r.u16(); c.offset = r.u16(); c.segment = r.bytes(r.u8());
return r.done() && !c.segment.empty() && c.offset + c.segment.size() <= c.total_length;
}
// SF_IDCHANGE_EVENT
Bytes encode(const IdChangeEvent& e) {
Writer w;
w.u64(e.subscription); w.u8(e.command); w.bytes(e.id, 8);
w.u8(static_cast<std::uint8_t>(e.subscriber_data.size())); w.bytes(e.subscriber_data);
return w.out;
}
bool decode(const Bytes& body, IdChangeEvent& e) {
Reader r(body);
e.subscription = r.u64(); e.command = r.u8(); r.bytes(e.id, 8); e.subscriber_data = r.bytes(r.u8());
return r.done() && e.command <= 3;
}
Bytes encode(const IdChangeEventResponse& e) { Writer w; w.u64(e.subscription); w.u8(e.return_code); return w.out; }
bool decode(const Bytes& body, IdChangeEventResponse& e) {
Reader r(body);
e.subscription = r.u64(); e.return_code = r.u8();
return r.done() && e.return_code <= 1;
}
// STATUS
Bytes encode(const Status& s) {
Writer w;
w.u32(s.uptime_ms); w.u8(s.configured); w.bytes(s.gn_address, 8); w.bytes(s.identifier, 8); w.u8(s.change_pending); w.u8(s.tickets);
w.u32(s.signed_messages); w.u32(s.refused_no_ticket); w.u32(s.refused_change_pending); w.u32(s.refused_permission);
w.u32(s.sign_failed); w.u32(s.verified); w.u32(s.rejected);
w.u32(s.requests_accepted); w.u32(s.requests_refused); w.u32(s.indications);
w.u32(s.radio_submitted); w.u32(s.radio_failed); w.u32(s.radio_received); w.u32(s.radio_dropped);
w.u32(s.link_rx_frames); w.u32(s.link_crc_errors); w.u32(s.link_malformed); w.u32(s.poti_updates);
w.u64(s.its_time_ms);
return w.out;
}
bool decode(const Bytes& body, Status& s) {
Reader r(body);
s.uptime_ms = r.u32(); s.configured = r.u8(); r.bytes(s.gn_address, 8); r.bytes(s.identifier, 8); s.change_pending = r.u8(); s.tickets = r.u8();
s.signed_messages = r.u32(); s.refused_no_ticket = r.u32(); s.refused_change_pending = r.u32(); s.refused_permission = r.u32();
s.sign_failed = r.u32(); s.verified = r.u32(); s.rejected = r.u32();
s.requests_accepted = r.u32(); s.requests_refused = r.u32(); s.indications = r.u32();
s.radio_submitted = r.u32(); s.radio_failed = r.u32(); s.radio_received = r.u32(); s.radio_dropped = r.u32();
s.link_rx_frames = r.u32(); s.link_crc_errors = r.u32(); s.link_malformed = r.u32(); s.poti_updates = r.u32();
s.its_time_ms = r.u64();
return r.done();
}
// RESULT
Bytes encode(const Result& res) {
Writer w;
w.u8(static_cast<std::uint8_t>(res.code)); w.u8(static_cast<std::uint8_t>(res.detail.size())); w.bytes(res.detail);
return w.out;
}
bool decode(const Bytes& body, Result& res) {
Reader r(body);
res.code = static_cast<Code>(r.u8()); res.detail = r.bytes(r.u8());
return r.done();
}
} // namespace microbu::link
+363
View File
@@ -0,0 +1,363 @@
#pragma once
// micrOBU station-internal link, message layer version 1 (implementation/station-link/README.md).
// Transport independent: the same octets are one GATT attribute value later and one serial
// frame payload today. Little-endian integers; ETSI payloads inside stay opaque.
#include <cstddef>
#include <cstdint>
#include <optional>
#include <string>
#include <vector>
namespace microbu::link {
using Bytes = std::vector<std::uint8_t>;
constexpr std::size_t maximum_message = 512;
constexpr std::size_t header_size = 4;
/// @brief The message opcode, which determines the body type.
enum class Opcode : std::uint8_t {
// phone -> ESP32-C5
STATION_CONFIGURE = 0x01,
POTI_UPDATE = 0x02,
BTP_DATA_REQUEST = 0x03,
CREDENTIALS_PROVISION = 0x04,
CREDENTIALS_ERASE = 0x05,
SF_IDCHANGE_SUBSCRIBE = 0x06,
SF_IDCHANGE_UNSUBSCRIBE = 0x07,
SF_IDCHANGE_EVENT_RESPONSE = 0x08,
SF_IDCHANGE_TRIGGER = 0x09,
SF_ID_LOCK = 0x0A,
SF_ID_UNLOCK = 0x0B,
STATUS_REQUEST = 0x0C,
// ESP32-C5 -> phone
RESULT = 0x80,
BTP_DATA_INDICATION = 0x81,
SF_IDCHANGE_EVENT = 0x82,
STATUS = 0x84,
// MicrOBU extension, not in the colleague's station-link v1: every ITS message heard on air,
// unwrapped by gn_unwrap.c whether or not the security entity could verify it. Body is the
// previous firmware's SERIAL_MSG_V2X_RX payload unchanged: [u16 btp_dest_port][i8 rssi]
// [u8 flags: bit0 geo area valid, bit1 signed but not verified][i32 area_lat][i32 area_lon]
// [u16 area_distance_a], then the UPER bytes. See obu-firmware/NOTES.md.
V2X_RX = 0x85,
};
constexpr std::uint8_t flag_fragment_first = 0x01;
constexpr std::uint8_t flag_fragment_more = 0x02;
// RESULT codes: vanetza_idf::Result values first (same numbering), then link codes.
enum class Code : std::uint8_t {
accepted = 0, invalid_argument = 1, unsupported = 2, wrong_entry_point = 3, security_unavailable = 4,
resource_limit = 5, rejected = 6, time_regression = 7, identity_change_pending = 8,
unknown_opcode = 0x10, malformed = 0x11, not_configured = 0x12, busy = 0x13, no_credentials = 0x14,
};
/// @brief The header of a link message.
struct Header {
Opcode opcode;
std::uint8_t flags = 0;
std::uint16_t sequence = 0;
};
/// @brief A complete link message.
struct Message {
Header header;
Bytes body;
};
/// @brief Serializes header + body.
/// @param message Message to serialize.
/// @param out Receives the serialized bytes.
/// @return false when the result would exceed maximum_message.
bool encode(const Message& message, Bytes& out);
/// @brief Parses header + body.
/// @param octets Raw bytes to parse.
/// @param out Receives the decoded message.
/// @return false when shorter than the header or longer than maximum_message.
bool decode(const Bytes& octets, Message& out);
// ---- bodies ------------------------------------------------------------------------------
// security, address_configuration, default_traffic_class and default_lifetime are the GN protocol
// constants itsGnSecurity, itsGnLocalAddrConfMethod, itsGnDefaultTrafficClass and
// itsGnDefaultPacketLifetime of ETSI TS 103 836-4-1 (GeoNetworking) annex H; channel_number,
// transmit_power_dbm and radio are ITS-G5 access-layer parameters (ETSI EN 303 797).
struct StationConfigure {
std::uint8_t station_type = 2; // TS 102 894-2 StationType (2 = cyclist)
std::uint8_t security = 1; // itsGnSecurity
std::uint8_t address_configuration = 1; // 0 AUTO (mid below), 1 ANONYMOUS (ticket digest)
std::uint8_t mid[6] = {2, 0, 0, 0, 0, 1};
std::uint8_t beaconing = 1;
std::uint16_t channel_number = 180;
std::uint8_t transmit_power_dbm = 10;
std::uint8_t radio = 0; // 0 off, 1 receive only, 2 transmit and receive
std::uint8_t default_traffic_class = 2;
std::uint8_t default_lifetime = 0x05; // 1 s (base One_Second, multiplier 1)
};
/// @param body Raw message body bytes.
/// @param out Receives the decoded configuration.
/// @return false if malformed.
bool decode(const Bytes& body, StationConfigure& out);
/// @param value Configuration to encode.
/// @return Encoded body bytes.
Bytes encode(const StationConfigure& value);
// Position/confidence-ellipse/speed/heading fields are the ETSI TS 102 894-2 (Common Data
// Dictionary) ReferencePosition / PosConfidenceEllipse data types; pai() mirrors the Position
// Accuracy Indicator field of the GeoNetworking Long Position Vector (ETSI TS 103 836-4-1
// clause 9.5.2.2, table 2).
struct PotiUpdate {
std::uint64_t timestamp_ms = 0; // TimestampIts
std::int32_t latitude = 0; // 1/10 microdegree
std::int32_t longitude = 0;
std::uint16_t semi_major_cm = 0;
std::uint16_t semi_minor_cm = 0;
std::uint16_t orientation_deci_degree = 0;
std::uint8_t flags = 0; // bit0 altitude, bit1 speed, bit2 heading, bit3 PAI
std::int32_t altitude_cm = 0;
std::uint16_t speed_cm_s = 0;
std::uint16_t heading_deci_degree = 0;
bool has_altitude() const { return flags & 1; }
bool has_speed() const { return flags & 2; }
bool has_heading() const { return flags & 4; }
bool pai() const { return flags & 8; }
};
/// @param body Raw message body bytes.
/// @param out Receives the decoded fix.
/// @return false if malformed.
bool decode(const Bytes& body, PotiUpdate& out);
/// @param value Fix to encode.
/// @return Encoded body bytes.
Bytes encode(const PotiUpdate& value);
/// @brief The message body of a BTP_DATA_REQUEST (ETSI TS 103 836-4-1 annex J.2) request.
// GeoArea per ETSI TS 103 899 "Geographical Area Definition". shape follows the GEOBROADCAST_*/
// GEOANYCAST_* header sub-type encoding of ETSI TS 103 836-4-1 (GeoNetworking) clause 9.7.4 table 9
// (0 circle, 1 rectangle, 2 ellipse); position/distance_a/distance_b/angle are the GeoArea fields
// carried in the GBC/GAC extended header per clause 9.8.5 table 36.
struct DestinationArea {
std::uint8_t shape = 0; // 0 circle, 1 rectangle, 2 ellipse
std::int32_t latitude = 0;
std::int32_t longitude = 0;
std::uint16_t distance_a = 0;
std::uint16_t distance_b = 0;
std::uint16_t angle = 0;
};
// btp_type/destination_port(_info) are the BTP-A/BTP-B header fields of ETSI TS 103 836-5-1 (Basic
// Transport Protocol); the gn_* fields mirror the TRANSP_CORE.request service primitive parameters
// of ETSI TS 103 836-4-1 (GeoNetworking) annex J.2 (Packet transport type, Traffic class, Maximum
// hop limit, Repetition interval/maximum, Security profile); its_aid follows the ITS-AID registry
// of ETSI TS 102 965.
struct BtpDataRequest {
std::uint8_t btp_type = 1;
std::uint16_t destination_port = 0;
std::uint16_t destination_port_info = 0; // or source_port for BTP-A
std::uint8_t gn_packet_transport_type = 1; // 0 GUC, 1 SHB, 2 TSB, 3 GBC, 4 GAC (TS 103 836-4-1 annex J.2)
std::uint8_t gn_communication_profile = 1;
std::uint8_t gn_security_profile = 0; // 0 not given, 1 unsecured, 2 secured
std::uint8_t gn_traffic_class = 0xFF; // 0xFF = station default
std::uint8_t gn_maximum_packet_lifetime = 0xFF; // 0xFF = station default
std::uint8_t gn_maximum_hop_limit = 0; // 0 = station default
std::uint16_t gn_repetition_interval_ms = 0;
std::uint16_t gn_repetition_maximum_ms = 0;
std::uint32_t its_aid = 0;
Bytes permissions;
Bytes context;
std::optional<DestinationArea> area; // present for GBC
Bytes fl_sdu;
};
/// @param body Raw message body bytes.
/// @param out Receives the decoded request.
/// @return false if malformed.
bool decode(const Bytes& body, BtpDataRequest& out);
/// @param value Request to encode.
/// @return Encoded body bytes.
Bytes encode(const BtpDataRequest& value);
// Mirrors the TRANSP_CORE.indication service primitive parameters of ETSI TS 103 836-4-1
// (GeoNetworking) annex J.4: source_gn_address/source_timestamp/source_latitude/source_longitude
// are the sender's Long Position Vector (clause 9.5.2), security_report/certificate_* the Security
// report/Certificate id parameters, its_aid/permissions the ITS-AID/Security permissions
// parameters.
struct BtpDataIndication {
std::uint8_t btp_type = 1;
std::uint16_t destination_port = 0;
std::uint16_t destination_port_info = 0;
std::uint8_t gn_packet_transport_type = 1; // 0 GUC, 1 SHB, 2 TSB, 3 GBC, 4 GAC (TS 103 836-4-1 annex J.4)
std::uint8_t gn_traffic_class = 0;
std::uint8_t gn_remaining_packet_lifetime = 0xFF;
std::uint8_t gn_remaining_hop_limit = 0xFF;
std::uint8_t source_gn_address[8] = {};
std::uint32_t source_timestamp = 0;
std::int32_t source_latitude = 0;
std::int32_t source_longitude = 0;
std::uint8_t security_report = 0; // 0 unsecured, 1 + VerificationReport ordinal
std::uint32_t its_aid = 0;
Bytes permissions;
bool certificate_present = false;
std::uint8_t certificate_id[8] = {};
std::optional<DestinationArea> area;
Bytes received_fl_sdu;
};
/// @param body Raw message body bytes.
/// @param out Receives the decoded indication.
/// @return false if malformed.
bool decode(const Bytes& body, BtpDataIndication& out);
/// @param value Indication to encode.
/// @return Encoded body bytes.
Bytes encode(const BtpDataIndication& value);
// Segmented upload of an ETSI TS 102 941 (Trust and Privacy Management) credential bundle
// (root CA / enrolment or authorization authority certificates, authorization tickets), whose
// certificate encoding is ETSI TS 103 097.
struct CredentialsProvision {
std::uint16_t total_length = 0;
std::uint16_t offset = 0;
Bytes segment;
};
/// @param body Raw message body bytes.
/// @param out Receives the decoded segment.
/// @return false if malformed.
/// @note One CredentialsProvision carries a single segment of a larger bundle; total_length
/// and offset let the caller reassemble the full bundle across several messages.
bool decode(const Bytes& body, CredentialsProvision& out);
/// @param value Segment to encode.
/// @return Encoded body bytes.
Bytes encode(const CredentialsProvision& value);
// Counts of ETSI TS 102 941 credentials (root CA / EA-AA certificates / authorization tickets)
// applied from a CredentialsProvision bundle.
struct ApplyReport { std::uint8_t roots = 0, authorities = 0, tickets = 0; };
// Mirrors the security entity's pseudonym-change handshake that the GN Core subscribes to via the
// SN-IDCHANGE-SUBSCRIBE/-EVENT/-UNSUBSCRIBE primitives at the CORE_SEC interface (ETSI
// TS 103 836-4-1 clause 10.2.1.4); the pseudonym/Authorization Ticket change itself is governed by
// ETSI TS 102 941. command is the link's own PREPARE/COMMIT/ABORT/DEREG handshake state, not an
// ETSI-defined field.
struct IdChangeEvent {
std::uint64_t subscription = 0;
std::uint8_t command = 0; // 0 PREPARE, 1 COMMIT, 2 ABORT, 3 DEREG
std::uint8_t id[8] = {};
Bytes subscriber_data;
};
/// @param body Raw message body bytes.
/// @param out Receives the decoded event.
/// @return false if malformed.
bool decode(const Bytes& body, IdChangeEvent& out);
/// @param value Event to encode.
/// @return Encoded body bytes.
Bytes encode(const IdChangeEvent& value);
// The link's encoding of the SN-IDCHANGE-EVENT.response primitive (ETSI TS 103 836-4-1
// clause 10.2.1.4), acknowledging an IdChangeEvent.
struct IdChangeEventResponse { std::uint64_t subscription = 0; std::uint8_t return_code = 0; };
/// @param body Raw message body bytes.
/// @param out Receives the decoded response.
/// @return false if malformed.
bool decode(const Bytes& body, IdChangeEventResponse& out);
/// @param value Response to encode.
/// @return Encoded body bytes.
Bytes encode(const IdChangeEventResponse& value);
struct Status {
std::uint32_t uptime_ms = 0;
std::uint8_t configured = 0;
std::uint8_t gn_address[8] = {};
std::uint8_t identifier[8] = {};
std::uint8_t change_pending = 0;
std::uint8_t tickets = 0;
std::uint32_t signed_messages = 0, refused_no_ticket = 0, refused_change_pending = 0, refused_permission = 0,
sign_failed = 0, verified = 0, rejected = 0;
std::uint32_t requests_accepted = 0, requests_refused = 0, indications = 0;
std::uint32_t radio_submitted = 0, radio_failed = 0, radio_received = 0, radio_dropped = 0;
std::uint32_t link_rx_frames = 0, link_crc_errors = 0, link_malformed = 0, poti_updates = 0;
std::uint64_t its_time_ms = 0;
};
/// @param body Raw message body bytes.
/// @param out Receives the decoded status.
/// @return false if malformed.
bool decode(const Bytes& body, Status& out);
/// @param value Status to encode.
/// @return Encoded body bytes.
Bytes encode(const Status& value);
struct Result {
Code code = Code::accepted;
Bytes detail;
};
/// @param body Raw message body bytes.
/// @param out Receives the decoded result.
/// @return false if malformed.
bool decode(const Bytes& body, Result& out);
/// @param value Result to encode.
/// @return Encoded body bytes.
Bytes encode(const Result& value);
// ---- little-endian helpers shared with the test channel ----------------------------------
/// @note All multi-byte values are written little-endian.
class Writer {
public:
/// @brief Output buffer thats apppended to
Bytes out;
/// @brief Appends a single byte to the output.
void u8(std::uint8_t v) { out.push_back(v); }
/// @brief Appends a 16-bit unsigned integer to the output.
void u16(std::uint16_t v) { out.push_back(v & 0xFF); out.push_back(v >> 8); }
/// @brief Appends a 32-bit unsigned integer to the output.
void u32(std::uint32_t v) { for (int i = 0; i < 4; ++i) out.push_back((v >> (8 * i)) & 0xFF); }
/// @brief Appends a 64-bit unsigned integer to the output.
void u64(std::uint64_t v) { for (int i = 0; i < 8; ++i) out.push_back((v >> (8 * i)) & 0xFF); }
/// @brief Appends a 32-bit signed integer to the output.
void i32(std::int32_t v) { u32(static_cast<std::uint32_t>(v)); }
/// @brief Appends a sequence of bytes to the output.
void bytes(const std::uint8_t* p, std::size_t n) { out.insert(out.end(), p, p + n); }
/// @brief Appends a sequence of bytes to the output.
void bytes(const Bytes& b) { out.insert(out.end(), b.begin(), b.end()); }
};
/// @note All multi-byte values are read little-endian. Once a read runs past the end of the
/// buffer, ok() becomes false and all further reads return zero/empty instead of throwing.
class Reader {
public:
/// @param b Buffer to read from; must outlive the Reader.
/// @param at Starting offset into b.
Reader(const Bytes& b, std::size_t at = 0) : b_(b), at_(at) {}
bool ok() const { return ok_; }
bool done() const { return ok_ && at_ == b_.size(); }
std::size_t remaining() const { return b_.size() - at_; }
std::uint8_t u8() { return need(1) ? b_[at_++] : 0; }
std::uint16_t u16() { if (!need(2)) return 0; std::uint16_t v = b_[at_] | (b_[at_ + 1] << 8); at_ += 2; return v; }
std::uint32_t u32() { if (!need(4)) return 0; std::uint32_t v = 0; for (int i = 3; i >= 0; --i) v = (v << 8) | b_[at_ + i]; at_ += 4; return v; }
std::uint64_t u64() { if (!need(8)) return 0; std::uint64_t v = 0; for (int i = 7; i >= 0; --i) v = (v << 8) | b_[at_ + i]; at_ += 8; return v; }
std::int32_t i32() { return static_cast<std::int32_t>(u32()); }
bool bytes(std::uint8_t* p, std::size_t n) { if (!need(n)) return false; for (std::size_t i = 0; i < n; ++i) p[i] = b_[at_ + i]; at_ += n; return true; }
Bytes bytes(std::size_t n) { Bytes r; if (need(n)) { r.assign(b_.begin() + at_, b_.begin() + at_ + n); at_ += n; } return r; }
Bytes rest() { Bytes r(b_.begin() + at_, b_.end()); at_ = b_.size(); return r; }
private:
bool need(std::size_t n) { if (!ok_ || at_ + n > b_.size()) { ok_ = false; return false; } return true; }
const Bytes& b_;
std::size_t at_;
bool ok_ = true;
};
} // namespace microbu::link
+159
View File
@@ -0,0 +1,159 @@
#include "link_service.hpp"
#include "simple_ble.hpp"
#include "serial_link.hpp"
#include <esp_log.h>
#include <esp_timer.h>
namespace microbu {
namespace {
const char* TAG = "link";
}
LinkService::LinkService(Station& station) : station_(station) {
station_.on_indication([this](const link::BtpDataIndication& indication) {
broadcast(link::Opcode::BTP_DATA_INDICATION, ++own_sequence_, link::encode(indication));
});
// MicrOBU: every ITS message heard on air, verified or not (Station::on_raw_its).
station_.on_raw_its([this](const link::Bytes& body) {
broadcast(link::Opcode::V2X_RX, ++own_sequence_, body);
});
station_.on_id_event([this](const link::IdChangeEvent& event) {
// PREPARE/COMMIT expect SF_IDCHANGE_EVENT_RESPONSE with this sequence
broadcast(link::Opcode::SF_IDCHANGE_EVENT, ++own_sequence_, link::encode(event));
});
}
void LinkService::send(LinkTransport transport, link::Opcode opcode, std::uint16_t sequence, const link::Bytes& body) {
link::Bytes octets;
link::Message message {{opcode, 0, sequence}, body};
if (!link::encode(message, octets)) { ESP_LOGE(TAG, "message 0x%02x too long (%u)", unsigned(opcode), unsigned(body.size())); return; }
if (transport == LinkTransport::serial) serial::write(serial::FrameType::LINK, octets);
else ble::write(octets);
}
void LinkService::broadcast(link::Opcode opcode, std::uint16_t sequence, const link::Bytes& body) {
send(LinkTransport::serial, opcode, sequence, body);
if (ble::connected()) send(LinkTransport::ble, opcode, sequence, body);
}
void LinkService::reply(LinkTransport transport, std::uint16_t sequence, link::Code code, const link::Bytes& detail) {
send(transport, link::Opcode::RESULT, sequence, link::encode(link::Result {code, detail}));
}
link::Status LinkService::status() {
auto value = station_.status();
const auto serial_counters = serial::counters();
const auto ble_counters = ble::counters();
value.link_rx_frames = serial_counters.frames + ble_counters.rx_messages;
value.link_crc_errors = serial_counters.crc_errors;
value.link_malformed += ble_counters.malformed;
return value;
}
void LinkService::handle(const link::Bytes& octets, LinkTransport origin) {
link::Message m;
if (!link::decode(octets, m)) return;
const auto seq = m.header.sequence;
using link::Opcode; using link::Code;
switch (m.header.opcode) {
case Opcode::STATION_CONFIGURE: {
link::StationConfigure c;
if (!link::decode(m.body, c)) return reply(origin, seq, Code::malformed);
link::Bytes detail;
const auto result = station_.configure(c, detail);
ESP_LOGI(TAG, "station configured: security %u, radio %u, result %d", unsigned(c.security), unsigned(c.radio), int(result));
return reply(origin, seq, result, detail);
}
case Opcode::POTI_UPDATE: {
link::PotiUpdate p;
if (!link::decode(m.body, p)) return reply(origin, seq, Code::malformed);
const auto result = station_.poti(p);
if (result != Code::accepted) reply(origin, seq, result); // accepted updates are silent
return;
}
case Opcode::BTP_DATA_REQUEST: {
link::BtpDataRequest q;
if (!link::decode(m.body, q)) return reply(origin, seq, Code::malformed);
return reply(origin, seq, station_.btp_request(q));
}
case Opcode::CREDENTIALS_PROVISION: {
link::CredentialsProvision c;
if (!link::decode(m.body, c)) return reply(origin, seq, Code::malformed);
if (c.offset == 0) { bundle_.clear(); bundle_total_ = c.total_length; }
if (c.total_length != bundle_total_ || c.offset != bundle_.size() || c.total_length > 8192) { bundle_.clear(); return reply(origin, seq, Code::malformed); }
bundle_.insert(bundle_.end(), c.segment.begin(), c.segment.end());
if (bundle_.size() < bundle_total_) return reply(origin, seq, Code::accepted);
link::ApplyReport report;
link::Code result = Code::accepted;
try {
result = station_.provision(bundle_, report);
} catch (const std::exception& e) {
ESP_LOGE(TAG, "provision exception: %s", e.what());
result = Code::invalid_argument;
}
bundle_.clear();
link::Bytes detail;
detail.push_back(static_cast<std::uint8_t>(report.roots));
detail.push_back(static_cast<std::uint8_t>(report.authorities));
detail.push_back(static_cast<std::uint8_t>(report.tickets));
return reply(origin, seq, result, detail);
}
case Opcode::CREDENTIALS_ERASE:
return reply(origin, seq, station_.erase_credentials());
case Opcode::SF_IDCHANGE_SUBSCRIBE: {
link::Reader r(m.body);
const auto data = r.bytes(r.u8());
if (!r.done()) {
ESP_LOGW(TAG, "SF_IDCHANGE_SUBSCRIBE malformed (len=%u)", (unsigned)m.body.size());
return reply(origin, seq, Code::malformed);
}
std::uint64_t handle = 0;
const auto result = station_.subscribe(data, handle);
ESP_LOGI(TAG, "SF_IDCHANGE_SUBSCRIBE handle=%llu result=%d", (unsigned long long)handle, int(result));
link::Writer w; w.u64(handle);
return reply(origin, seq, result, result == Code::accepted ? w.out : link::Bytes {});
}
case Opcode::SF_IDCHANGE_UNSUBSCRIBE: {
link::Reader r(m.body);
const auto handle = r.u64();
if (!r.done()) return reply(origin, seq, Code::malformed);
return reply(origin, seq, station_.unsubscribe(handle));
}
case Opcode::SF_IDCHANGE_EVENT_RESPONSE: {
link::IdChangeEventResponse e;
if (!link::decode(m.body, e)) return; // no reply defined for a response
station_.event_response(e.subscription, e.return_code != 0);
return;
}
case Opcode::SF_IDCHANGE_TRIGGER:
return reply(origin, seq, station_.trigger());
case Opcode::SF_ID_LOCK: {
link::Reader r(m.body);
const auto seconds = r.u8();
if (!r.done()) return reply(origin, seq, Code::malformed);
std::uint64_t handle = 0;
const auto result = station_.lock(seconds, handle);
link::Writer w; w.u64(handle);
return reply(origin, seq, result, result == Code::accepted ? w.out : link::Bytes {});
}
case Opcode::SF_ID_UNLOCK: {
link::Reader r(m.body);
const auto handle = r.u64();
if (!r.done()) return reply(origin, seq, Code::malformed);
return reply(origin, seq, station_.unlock(handle));
}
case Opcode::STATUS_REQUEST:
return send(origin, link::Opcode::STATUS, seq, link::encode(status()));
default:
return reply(origin, seq, Code::unknown_opcode);
}
}
void LinkService::tick() {
const auto now = esp_timer_get_time();
if (now - last_status_us_ < 1000000) return;
last_status_us_ = now;
broadcast(link::Opcode::STATUS, ++own_sequence_, link::encode(status()));
}
} // namespace microbu
+51
View File
@@ -0,0 +1,51 @@
#pragma once
// Dispatches station-link messages (phone -> micrOBU) to the station and sends the
// micrOBU -> phone messages (results, indications, identifier-change events, status).
#include "link_protocol.hpp"
#include "station.hpp"
namespace microbu {
/// @brief Represents the transport mechanism for link messages. Either BLE or Serial
enum class LinkTransport : std::uint8_t { serial, ble };
class LinkService {
public:
/// @brief Binds the service to a station and subscribes to its indication/id-event callbacks.
/// @param station Station to bind to; must outlive the LinkService.
explicit LinkService(Station& station);
/// @brief Decodes and dispatches one received message; call from the station task.
/// @param message Raw message bytes as received from the transport.
/// @param origin Transport the message arrived on; replies go back on the same transport.
void handle(const link::Bytes& message, LinkTransport origin);
/// @brief Periodic work: broadcasts STATUS once a second.
void tick();
private:
/// @brief Encodes and writes one message on the given transport.
/// @param transport Transport to write to.
/// @param opcode Message opcode.
/// @param sequence Sequence number to stamp on the header.
/// @param body Encoded message body.
void send(LinkTransport transport, link::Opcode opcode, std::uint16_t sequence, const link::Bytes& body);
/// @brief Sends a message on serial, and also on BLE when a phone is connected.
/// @param opcode Message opcode.
/// @param sequence Sequence number to stamp on the header.
/// @param body Encoded message body.
void broadcast(link::Opcode opcode, std::uint16_t sequence, const link::Bytes& body);
/// @brief Sends a RESULT reply to the message's origin transport.
/// @param origin Transport to reply on.
/// @param sequence Sequence number of the message being replied to.
/// @param code Result code to report.
/// @param detail Optional result detail bytes.
void reply(LinkTransport origin, std::uint16_t sequence, link::Code code, const link::Bytes& detail = {});
/// @brief Builds a STATUS snapshot from the station and transport counters.
/// @return Current status.
link::Status status();
Station& station_;
std::uint16_t own_sequence_ = 0;
link::Bytes bundle_; // credentials being provisioned
std::uint16_t bundle_total_ = 0;
std::int64_t last_status_us_ = 0;
};
} // namespace microbu
+4
View File
@@ -0,0 +1,4 @@
[mapping:newlib_memcmp]
archive: libnewlib.a
entries:
memcmp (noflash)
+175
View File
@@ -0,0 +1,175 @@
// Sourced from OpenTrafficMap's its-g5-receiver-firmware_txenabled, main/tx_custom.c
// (https://codeberg.org/opentrafficmap/its-g5-receiver-firmware_txenabled, pinned commit
// 674e34128279235ba34c9f8d778f43cf1d075397, no stated license). Reproduced here as generated by
// implementation/external/vanetza-idf/ports/esp_idf/radio_c5.cmake (which hashes the upstream file
// so this copy cannot silently drift) and checked in for readability. The struct layouts
// (x_eb_txdesc_t, x_middle_data_t, x_ebuf_t) and the bit-level manipulation of esp_wifi's private
// tx descriptor are OTM's own reverse engineering of the closed esp_wifi/libphy internals -- not
// documented or supported by Espressif, and not verified independently by this project. The one
// deliberate change from upstream is `result = ieee80211_post_hmac_tx(eb);` below: upstream
// discards that return value, but ESP_OK from this function means the driver accepted the frame
// for submission, not that it was independently observed on air (see
// experiments/evidence/c5-radio-characterization/phy-internals-investigation.md).
#include "esp_private/wifi_os_adapter.h"
#include "esp_wifi.h"
#include "otm_tx_custom.h"
#include <assert.h>
#include <stddef.h>
esp_err_t ieee80211_raw_frame_sanity_check(wifi_interface_t ifx, const void *buffer, int32_t len, bool en_sys_seq);
esp_err_t ieee80211_post_hmac_tx(void *ebuf);
void *ic_ebuf_alloc(const void *packet, uint32_t unknown, uint32_t len);
void *ic_get_default_sched(void);
extern wifi_osi_funcs_t *g_osi_funcs_p;
extern void *g_wifi_global_lock;
typedef struct x_eb_txdesc
{
uint32_t flags;
uint32_t field_4;
uint32_t field_8;
uint8_t rate;
uint8_t field_d;
uint8_t field_e;
uint8_t field_f;
uint32_t field_10;
uint32_t field_14;
uint32_t timestamp;
void* sched;
uint32_t field_20;
uint32_t field_24;
uint32_t field_28;
union {
uint32_t field_2c_32;
struct {
uint8_t field_2c;
uint8_t field_2d;
uint8_t field_2e;
uint8_t field_2f;
};
};
union {
uint32_t field_30_32;
struct {
uint8_t field_30;
uint8_t field_31;
uint8_t field_32;
uint8_t field_33;
};
};
uint32_t field_34;
uint32_t field_38;
uint32_t field_3c;
uint32_t field_40;
uint32_t field_44;
} x_eb_txdesc_t;
_Static_assert(sizeof(x_eb_txdesc_t) == 0x48, "eb_txdesc size");
typedef struct x_middle_data
{
uint32_t field_40;
uint8_t* buf;
uint32_t field_48;
uint32_t field_4c;
} x_middle_data_t;
_Static_assert(sizeof(x_middle_data_t) == 0x10, "middle_data size");
typedef struct x_ebuf
{
uint32_t field_0;
x_middle_data_t* ds_head;
x_middle_data_t* ds_tail;
uint16_t field_c;
uint16_t field_e;
uint32_t extra_data_start;
uint16_t header_length;
uint32_t data_length;
uint16_t field_1c;
uint8_t alloc_type;
uint8_t field_1f;
uint32_t field_20;
uint8_t field_24;
uint8_t field_25;
uint8_t field_26;
uint8_t field_27;
uint32_t field_28;
uint8_t field_2c;
uint32_t field_30;
uint32_t next_free;
x_eb_txdesc_t* txdesc;
uint16_t field_3c;
uint8_t field_3e;
uint8_t field_3f;
} x_ebuf_t;
_Static_assert(sizeof(x_ebuf_t) == 0x40, "ebuf size");
_Static_assert(offsetof(x_ebuf_t, txdesc) == 0x38, "ebuf txdesc offset");
_Static_assert(offsetof(x_eb_txdesc_t, rate) == 0x0c, "txdesc rate offset");
esp_err_t esp_wifi_80211_tx_custom(wifi_interface_t ifx, const void *buffer, int32_t len, bool en_sys_seq, wifi_tx_rate_config_t *tx_rate_config, wifi_band_t band, wifi_bandwidth_t bw)
{
esp_err_t result = 0;
if (!result)
{
g_osi_funcs_p->_mutex_lock(g_wifi_global_lock);
x_ebuf_t* eb = ic_ebuf_alloc(buffer, 1, len);
if (eb)
{
eb->data_length = 0;
x_eb_txdesc_t *txdesc_1 = eb->txdesc;
eb->header_length = len;
txdesc_1->flags |= 0x4000;
txdesc_1->sched = ic_get_default_sched();
wifi_phy_rate_t rate = tx_rate_config->rate;
x_eb_txdesc_t *txdesc = eb->txdesc;
if (rate)
txdesc->rate = (char)rate;
else if (band != WIFI_BAND_5G)
txdesc->rate = 0;
else
txdesc->rate = (char)WIFI_PHY_RATE_6M;
wifi_phy_mode_t phymode = tx_rate_config->phymode;
if (phymode == WIFI_PHY_MODE_HE20)
{
txdesc->flags |= 0x80000000;
txdesc->field_2f =
(char)((((uint32_t)tx_rate_config->ersu + 6) & 0xf) << 3)
| (txdesc->field_2f & 0x87);
if ((uint32_t)tx_rate_config->dcm)
txdesc->field_31 |= 0x80;
}
else if (phymode == WIFI_PHY_MODE_VHT20)
txdesc->flags |= 0x1000000;
// OTM's own comment on this line upstream: "No idea if this is correct, but this is
// what the original code does...". This project always passes WIFI_BW20 (see
// c5_radio.cpp), so bw_is_bw40 is always 0 here; see the investigation doc above for
// what is and isn't verified about ITS-G5's 10 MHz channel width on this path.
uint32_t bw_is_bw40 = bw == WIFI_BW40;
txdesc->field_8 = (bw_is_bw40 << 0xf) | (txdesc->field_8 & 0xffff7fff);
if (en_sys_seq)
txdesc->flags |= 1;
txdesc->field_10 =
(txdesc->field_10 & 0xfff3ffff) | ((ifx & WIFI_IF_MAX) << 0x12);
txdesc->field_14 = 0x100;
result = ieee80211_post_hmac_tx(eb);
g_osi_funcs_p->_mutex_unlock(g_wifi_global_lock);
}
else
{
result = ESP_ERR_NO_MEM;
g_osi_funcs_p->_mutex_unlock(g_wifi_global_lock);
}
}
return result;
}
+23
View File
@@ -0,0 +1,23 @@
#pragma once
// Sourced from OpenTrafficMap's its-g5-receiver-firmware_txenabled, main/tx_custom.h
// (https://codeberg.org/opentrafficmap/its-g5-receiver-firmware_txenabled, pinned commit
// 674e34128279235ba34c9f8d778f43cf1d075397, no stated license). That project reverse-engineered
// this ESP32-C5 ROM/libphy entry point -- it is not documented or supported by Espressif.
// See experiments/evidence/c5-radio-characterization/phy-internals-investigation.md for what
// this repository independently verified (by disassembling libphy.a) versus what is still an
// unverified upstream guess.
#include "esp_wifi.h"
#ifdef __cplusplus
extern "C" {
#endif
esp_err_t esp_wifi_80211_tx_custom(wifi_interface_t ifx, const void *buffer, int32_t len,
bool en_sys_seq, wifi_tx_rate_config_t *tx_rate_config,
wifi_band_t band, wifi_bandwidth_t bw);
#ifdef __cplusplus
}
#endif
+160
View File
@@ -0,0 +1,160 @@
#include "serial_link.hpp"
#include "sdkconfig.h"
#include <driver/usb_serial_jtag.h>
#include <esp_log.h>
#include <esp_timer.h>
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <freertos/task.h>
#include <cstdarg>
#include <cstdio>
#include <atomic>
#include <cstring>
namespace microbu::serial {
namespace {
std::uint16_t crc16_ccitt_false_step(std::uint16_t crc, std::uint8_t octet) {
crc ^= static_cast<std::uint16_t>(octet) << 8;
for (int bit = 0; bit < 8; ++bit) crc = (crc & 0x8000) ? static_cast<std::uint16_t>((crc << 1) ^ 0x1021) : static_cast<std::uint16_t>(crc << 1);
return crc;
}
}
std::uint16_t crc16_ccitt_false(const std::uint8_t* data, std::size_t length) {
std::uint16_t crc = 0xFFFF;
for (std::size_t i = 0; i < length; ++i) crc = crc16_ccitt_false_step(crc, data[i]);
return crc;
}
Bytes encode_frame(FrameType type, const Bytes& payload) {
Bytes out;
out.reserve(payload.size() + 7);
out.push_back(0xAA); out.push_back(0x55);
out.push_back(static_cast<std::uint8_t>(type));
out.push_back(payload.size() & 0xFF); out.push_back(payload.size() >> 8);
out.insert(out.end(), payload.begin(), payload.end());
const auto crc = crc16_ccitt_false(out.data() + 2, out.size() - 2);
out.push_back(crc & 0xFF); out.push_back(crc >> 8);
return out;
}
void Decoder::feed(const std::uint8_t* data, std::size_t length, const Handler& handler) {
for (std::size_t i = 0; i < length; ++i) {
const std::uint8_t b = data[i];
switch (state_) {
case State::SYNC0: state_ = (b == 0xAA) ? State::SYNC1 : State::SYNC0; break;
case State::SYNC1: state_ = (b == 0x55) ? State::TYPE : (b == 0xAA ? State::SYNC1 : State::SYNC0); break;
case State::TYPE: type_ = b; state_ = State::LEN_LO; break;
case State::LEN_LO: length_ = b; state_ = State::LEN_HI; break;
case State::LEN_HI:
length_ |= static_cast<std::uint16_t>(b) << 8;
payload_.clear();
if (length_ > maximum_payload) state_ = State::SYNC0; // untrustworthy boundary: resync
else state_ = length_ == 0 ? State::CRC_LO : State::PAYLOAD;
break;
case State::PAYLOAD:
payload_.push_back(b);
if (payload_.size() >= length_) state_ = State::CRC_LO;
break;
case State::CRC_LO: crc_ = b; state_ = State::CRC_HI; break;
case State::CRC_HI: {
crc_ |= static_cast<std::uint16_t>(b) << 8;
// CRC over head then payload without concatenating them: same running value as encode_frame.
const std::uint8_t head[3] = {type_, static_cast<std::uint8_t>(length_ & 0xFF), static_cast<std::uint8_t>(length_ >> 8)};
std::uint16_t crc = 0xFFFF;
for (auto octet : head) crc = crc16_ccitt_false_step(crc, octet);
for (auto octet : payload_) crc = crc16_ccitt_false_step(crc, octet);
if (crc == crc_) { ++frames_; handler(Frame {static_cast<FrameType>(type_), payload_}); }
else ++crc_errors_;
state_ = State::SYNC0;
break;
}
}
}
}
namespace {
SemaphoreHandle_t writer_lock = nullptr;
Counters the_counters;
Decoder::Handler frame_handler;
std::atomic<std::uint32_t> last_frame_at_ms {0}; // 32-bit: no libatomic needed on RV32
#if CONFIG_MICROBU_LOG_OVER_LINK
vprintf_like_t previous_vprintf = nullptr;
#endif
bool write_all(const Bytes& frame) {
std::size_t sent = 0;
while (sent < frame.size()) {
// MicrOBU: 50 ms, not the colleague's 500. A phone that is plugged in but not reading (app
// not running) fills the driver's buffer, and every write then waits out this timeout on
// the station task, which also serves BLE and the radio.
const int count = usb_serial_jtag_write_bytes(frame.data() + sent, frame.size() - sent, pdMS_TO_TICKS(50));
if (count <= 0) return false; // the host detects an incomplete frame by CRC
sent += count;
}
return true;
}
#if CONFIG_MICROBU_LOG_OVER_LINK
// ESP_LOG sink: one LOG frame per call, never a raw write into the frame stream.
int log_to_frame(const char* format, va_list args) {
char line[256];
const int length = std::vsnprintf(line, sizeof line, format, args);
if (length <= 0) return 0;
std::size_t n = static_cast<std::size_t>(length) < sizeof line ? length : sizeof line - 1;
while (n > 0 && (line[n - 1] == '\n' || line[n - 1] == '\r')) --n;
if (n == 0) return length;
// esp_log colour escapes are stripped by the emulator; keep the line verbatim otherwise.
write(FrameType::LOG, Bytes(line, line + n));
return length;
}
#endif
void reader_task(void*) {
Decoder decoder;
std::uint8_t buffer[512];
for (;;) {
const int count = usb_serial_jtag_read_bytes(buffer, sizeof buffer, pdMS_TO_TICKS(20));
if (count > 0) {
decoder.feed(buffer, static_cast<std::size_t>(count), [](Frame frame) {
last_frame_at_ms = static_cast<std::uint32_t>(esp_timer_get_time() / 1000);
frame_handler(std::move(frame));
});
the_counters.frames = decoder.frames();
the_counters.crc_errors = decoder.crc_errors();
}
}
}
}
void start(const Decoder::Handler& on_frame) {
frame_handler = on_frame;
writer_lock = xSemaphoreCreateMutex();
usb_serial_jtag_driver_config_t config {};
config.rx_buffer_size = 8192;
config.tx_buffer_size = 8192;
ESP_ERROR_CHECK(usb_serial_jtag_driver_install(&config));
#if CONFIG_MICROBU_LOG_OVER_LINK
previous_vprintf = esp_log_set_vprintf(log_to_frame);
#endif
xTaskCreate(reader_task, "link_rx", 6144, nullptr, 12, nullptr);
}
bool write(FrameType type, const Bytes& payload) {
if (payload.size() > maximum_payload || !writer_lock) return false;
// MicrOBU: nothing on the native port (phone on BLE, or unplugged). Without this check every
// write, the 1 Hz STATUS included, would block the station task until the timeout.
if (!usb_serial_jtag_is_connected()) { ++the_counters.not_connected; return false; }
const auto frame = encode_frame(type, payload);
if (xSemaphoreTake(writer_lock, pdMS_TO_TICKS(1000)) != pdTRUE) { ++the_counters.write_failures; return false; }
const bool ok = write_all(frame);
xSemaphoreGive(writer_lock);
if (!ok) ++the_counters.write_failures;
return ok;
}
Counters counters() { return the_counters; }
std::uint32_t last_frame_ms() { return last_frame_at_ms.load(); }
} // namespace microbu::serial
+75
View File
@@ -0,0 +1,75 @@
#pragma once
// Serial transport of the station-internal link over the ESP32-C5 native USB Serial/JTAG port.
// Framing is the app's Phase 03 one: [AA][55][type][len LE][payload][crc16 LE] with
// CRC-16/CCITT-FALSE over type+len+payload (implementation/station-link/README.md, "Serial").
// One writer serialises complete frames. The byte stream never carries plain text: ESP_LOG stays on
// UART0 here, or travels as LOG frames with CONFIG_MICROBU_LOG_OVER_LINK.
#include <cstddef>
#include <cstdint>
#include <functional>
#include <vector>
namespace microbu::serial {
/// @brief Byte buffer type for frame payloads.
using Bytes = std::vector<std::uint8_t>;
/// @brief The type of a frame. Either Link (link protocol), Test (test channel), or Log (ESP_LOG output).
enum class FrameType : std::uint8_t { LINK = 0x10, TEST = 0x11, LOG = 0x7F };
constexpr std::size_t maximum_payload = 1536;
/// @brief A complete frame with a type and a payload.
struct Frame { FrameType type; Bytes payload; };
/// @brief Computes the CRC-16/CCITT-FALSE checksum over a byte range.
/// @param data Pointer to the first byte to checksum.
/// @param length Number of bytes to checksum.
/// @return Checksum value.
std::uint16_t crc16_ccitt_false(const std::uint8_t* data, std::size_t length);
/// @brief Frames a payload as [AA][55][type][len LE][payload][crc16 LE].
/// @param type Frame type tag.
/// @param payload Payload bytes; must not exceed maximum_payload.
/// @return Encoded frame bytes.
Bytes encode_frame(FrameType type, const Bytes& payload);
/// Byte-at-a-time decoder, same state machine as the app's SerialFrameDecoder.
class Decoder {
public:
using Handler = std::function<void(Frame)>;
/// @brief Feeds raw bytes through the frame state machine, invoking the handler per complete frame.
/// @param data Pointer to the first byte to feed.
/// @param length Number of bytes to feed.
/// @param handler Invoked once per complete, CRC-valid frame; may be called zero or more times.
void feed(const std::uint8_t* data, std::size_t length, const Handler& handler);
/// @return Number of frames rejected for a CRC mismatch so far.
std::uint32_t crc_errors() const { return crc_errors_; }
/// @return Number of frames decoded successfully so far.
std::uint32_t frames() const { return frames_; }
private:
enum class State { SYNC0, SYNC1, TYPE, LEN_LO, LEN_HI, PAYLOAD, CRC_LO, CRC_HI } state_ = State::SYNC0;
std::uint8_t type_ = 0;
std::uint16_t length_ = 0, crc_ = 0;
Bytes payload_;
std::uint32_t crc_errors_ = 0, frames_ = 0;
};
struct Counters { std::uint32_t frames = 0, crc_errors = 0, write_failures = 0, not_connected = 0; };
/// @brief Installs the USB Serial/JTAG driver and starts the reader task. ESP_LOG goes into LOG frames
/// only with CONFIG_MICROBU_LOG_OVER_LINK; on this board it stays on the UART0 console.
/// @param on_frame Invoked once per complete, CRC-valid frame.
/// @note The handler runs on the reader task; it must only enqueue, never block.
void start(const Decoder::Handler& on_frame);
/// @brief Writes one complete frame (blocking, mutex-protected). Safe from any task.
/// @param type Frame type tag.
/// @param payload Payload bytes; must not exceed maximum_payload.
/// @return false on a full payload, missing driver, lock timeout, or write failure.
bool write(FrameType type, const Bytes& payload);
/// @return Current frame/error counters.
Counters counters();
/// @return esp_timer time in ms (wrapping) at which the last CRC-valid frame arrived from the host,
/// 0 if none yet. MicrOBU: app_main pauses BLE advertising while this is recent.
std::uint32_t last_frame_ms();
} // namespace microbu::serial
+493
View File
@@ -0,0 +1,493 @@
#include "simple_ble.hpp"
#include "sdkconfig.h"
#include <esp_log.h>
#include <esp_mac.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <nimble/ble.h>
#include <host/ble_att.h>
#include <host/ble_gap.h>
#include <host/ble_gatt.h>
#include <host/ble_hs.h>
#include <host/ble_hs_mbuf.h>
#include <host/ble_sm.h>
#include <host/ble_store.h>
#include <host/ble_uuid.h>
#include <host/util/util.h>
#include <nimble/nimble_port.h>
#include <nimble/nimble_port_freertos.h>
#include <services/gap/ble_svc_gap.h>
#include <services/gatt/ble_svc_gatt.h>
#include <cstdio>
#include <cstring>
extern "C" void ble_store_config_init(void);
namespace microbu::ble {
namespace {
const char* TAG = "cits_ble";
constexpr uint32_t FIXED_PASSKEY = 123456;
// Base UUID: 0000xxxx-ba5e-4c17-8000-00805f9b34fb
// Little-endian byte order for NimBLE (16 bytes, index 15 down to 0):
#define CITS_UUID_BASE(id_lo, id_hi) \
0xfb, 0x34, 0x9b, 0x5f, 0x80, 0x00, 0x00, 0x80, \
0x17, 0x4c, 0x5e, 0xba, (id_lo), (id_hi), 0x00, 0x00
static const ble_uuid128_t s_svc_uuid = BLE_UUID128_INIT(CITS_UUID_BASE(0x75, 0xc1));
static const ble_uuid128_t s_chr_btp_req_uuid = BLE_UUID128_INIT(CITS_UUID_BASE(0x76, 0xc1));
static const ble_uuid128_t s_chr_btp_ind_uuid = BLE_UUID128_INIT(CITS_UUID_BASE(0x77, 0xc1));
static const ble_uuid128_t s_chr_poti_uuid = BLE_UUID128_INIT(CITS_UUID_BASE(0x78, 0xc1));
static const ble_uuid128_t s_chr_status_uuid = BLE_UUID128_INIT(CITS_UUID_BASE(0x79, 0xc1));
static const ble_uuid128_t s_chr_id_uuid = BLE_UUID128_INIT(CITS_UUID_BASE(0x7a, 0xc1));
static const ble_uuid128_t s_chr_config_uuid = BLE_UUID128_INIT(CITS_UUID_BASE(0x7b, 0xc1));
static const ble_uuid128_t s_chr_result_uuid = BLE_UUID128_INIT(CITS_UUID_BASE(0x7c, 0xc1));
enum class ChrId : uintptr_t {
BtpRequest = 1,
BtpIndication = 2,
Poti = 3,
Status = 4,
IdChange = 5,
Configure = 6,
Result = 7,
};
Receiver s_receiver;
Counters statistics;
char s_dev_name[32] = "micrOBU";
uint16_t s_btp_ind_val_handle = 0;
uint16_t s_status_val_handle = 0;
uint16_t s_id_val_handle = 0;
uint16_t s_result_val_handle = 0;
volatile uint16_t s_conn_handle = BLE_HS_CONN_HANDLE_NONE;
int s_notify_count = 0; // number of characteristics with active CCCD subscription
bool s_subscribed = false;
uint8_t s_own_addr_type = 0;
volatile bool s_synced = false;
volatile bool s_adv_allowed = true;
// GATT characteristic read/write callback, shared by all characteristics (arg identifies which).
int chr_access(uint16_t conn_handle, uint16_t attr_handle,
struct ble_gatt_access_ctxt *ctxt, void *arg) {
uintptr_t id = reinterpret_cast<uintptr_t>(arg);
if (ctxt->op == BLE_GATT_ACCESS_OP_WRITE_CHR) {
uint16_t total = OS_MBUF_PKTLEN(ctxt->om);
if (total == 0 || total > link::maximum_message) {
ESP_LOGW(TAG, "rx invalid length: %u", (unsigned)total);
++statistics.malformed;
return BLE_ATT_ERR_INVALID_ATTR_VALUE_LEN;
}
link::Bytes buf(total);
uint16_t copied = 0;
int rc = ble_hs_mbuf_to_flat(ctxt->om, buf.data(), total, &copied);
if (rc != 0 || copied != total) {
++statistics.malformed;
return BLE_ATT_ERR_UNLIKELY;
}
++statistics.rx_messages;
if (s_receiver) {
// Require valid link header (opcode + flags + seq).
// Host->station opcodes are 0x01..0x0F; station->host are 0x80+.
if (total >= link::header_size && buf[0] >= 0x01 && buf[0] <= 0x0F) {
ESP_LOGD(TAG, "rx chr %u op=0x%02x len=%u", (unsigned)id, buf[0], (unsigned)total);
s_receiver(std::move(buf));
} else {
ESP_LOGW(TAG, "rx chr %u unframed or invalid op=0x%02x len=%u", (unsigned)id, buf[0], (unsigned)total);
++statistics.malformed;
return BLE_ATT_ERR_INVALID_ATTR_VALUE_LEN;
}
}
return 0;
} else if (ctxt->op == BLE_GATT_ACCESS_OP_READ_CHR) {
if (id == static_cast<uintptr_t>(ChrId::Status)) {
return 0; // empty read; status is pushed via notify
}
}
return BLE_ATT_ERR_READ_NOT_PERMITTED;
}
#define CHR_FLAG_RW_ENC (BLE_GATT_CHR_F_READ_ENC | BLE_GATT_CHR_F_READ_AUTHEN | \
BLE_GATT_CHR_F_WRITE_ENC | BLE_GATT_CHR_F_WRITE_AUTHEN)
#define CHR_FLAG_NOTIFY_ENC (BLE_GATT_CHR_F_NOTIFY_INDICATE_ENC | \
BLE_GATT_CHR_F_NOTIFY_INDICATE_AUTHEN)
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wmissing-field-initializers"
static struct ble_gatt_chr_def s_chr_defs[] = {
{
// NF-SAP: BTP-DATA.request (Central -> Station Write)
.uuid = &s_chr_btp_req_uuid.u,
.access_cb = chr_access,
.arg = reinterpret_cast<void*>(ChrId::BtpRequest),
.flags = BLE_GATT_CHR_F_WRITE | CHR_FLAG_RW_ENC,
},
{
// NF-SAP: BTP-DATA.indication (Station -> Central Notify)
.uuid = &s_chr_btp_ind_uuid.u,
.access_cb = chr_access,
.arg = reinterpret_cast<void*>(ChrId::BtpIndication),
.flags = BLE_GATT_CHR_F_NOTIFY | CHR_FLAG_NOTIFY_ENC,
.val_handle = &s_btp_ind_val_handle,
},
{
// PoTi: Position & Time Fix Update (Central -> Station Write)
.uuid = &s_chr_poti_uuid.u,
.access_cb = chr_access,
.arg = reinterpret_cast<void*>(ChrId::Poti),
.flags = BLE_GATT_CHR_F_WRITE | CHR_FLAG_RW_ENC,
},
{
// Station Status (Station -> Central Read/Notify)
.uuid = &s_chr_status_uuid.u,
.access_cb = chr_access,
.arg = reinterpret_cast<void*>(ChrId::Status),
.flags = BLE_GATT_CHR_F_READ | BLE_GATT_CHR_F_NOTIFY | CHR_FLAG_RW_ENC | CHR_FLAG_NOTIFY_ENC,
.val_handle = &s_status_val_handle,
},
{
// SF-SAP: Identity Change Event (Station -> Central Notify)
.uuid = &s_chr_id_uuid.u,
.access_cb = chr_access,
.arg = reinterpret_cast<void*>(ChrId::IdChange),
.flags = BLE_GATT_CHR_F_WRITE | BLE_GATT_CHR_F_NOTIFY | CHR_FLAG_RW_ENC | CHR_FLAG_NOTIFY_ENC,
.val_handle = &s_id_val_handle,
},
{
// Station Configure / Credentials (Central -> Station Write)
.uuid = &s_chr_config_uuid.u,
.access_cb = chr_access,
.arg = reinterpret_cast<void*>(ChrId::Configure),
.flags = BLE_GATT_CHR_F_WRITE | CHR_FLAG_RW_ENC,
},
{
// Result / Response (Station -> Central Notify)
.uuid = &s_chr_result_uuid.u,
.access_cb = chr_access,
.arg = reinterpret_cast<void*>(ChrId::Result),
.flags = BLE_GATT_CHR_F_NOTIFY | CHR_FLAG_NOTIFY_ENC,
.val_handle = &s_result_val_handle,
},
{0}
};
static struct ble_gatt_svc_def s_svc_defs[] = {
{
.type = BLE_GATT_SVC_TYPE_PRIMARY,
.uuid = &s_svc_uuid.u,
.characteristics = s_chr_defs,
},
{0}
};
#pragma GCC diagnostic pop
int start_advertising();
// NimBLE GAP callback: connect/disconnect, pairing (fixed-passkey), and notify subscriptions.
int gap_event(struct ble_gap_event *event, void *arg) {
struct ble_gap_conn_desc desc;
switch (event->type) {
case BLE_GAP_EVENT_CONNECT:
ESP_LOGI(TAG, "connect status=%d handle=%d", event->connect.status, event->connect.conn_handle);
if (event->connect.status == 0) {
s_conn_handle = event->connect.conn_handle;
s_subscribed = false;
struct ble_gap_upd_params params = {};
params.itvl_min = 12; // 15 ms
params.itvl_max = 16; // 20 ms
params.latency = 0;
params.supervision_timeout = 400; // 4 s
params.min_ce_len = 0;
params.max_ce_len = 0;
ble_gap_update_params(event->connect.conn_handle, &params);
ble_gap_security_initiate(event->connect.conn_handle);
} else {
start_advertising();
}
return 0;
case BLE_GAP_EVENT_DISCONNECT:
ESP_LOGI(TAG, "disconnect reason=%d", event->disconnect.reason);
s_conn_handle = BLE_HS_CONN_HANDLE_NONE;
s_subscribed = false;
s_notify_count = 0;
start_advertising();
return 0;
case BLE_GAP_EVENT_REPEAT_PAIRING:
// MicrOBU: logged, since a phone that pairs again while we hold its bond means one side
// lost the keys; the phone's side then usually needs "forget device" as well.
ESP_LOGW(TAG, "phone started pairing again although bonded: dropping the old bond");
if (ble_gap_conn_find(event->repeat_pairing.conn_handle, &desc) == 0) {
ble_store_util_delete_peer(&desc.peer_id_addr);
}
return BLE_GAP_REPEAT_PAIRING_RETRY;
case BLE_GAP_EVENT_ENC_CHANGE:
// MicrOBU: the one place the board side says whether the link got secure. 0 = encrypted.
if (ble_gap_conn_find(event->enc_change.conn_handle, &desc) == 0) {
ESP_LOGI(TAG, "encryption change status=%d encrypted=%d authenticated=%d bonded=%d",
event->enc_change.status, desc.sec_state.encrypted, desc.sec_state.authenticated,
desc.sec_state.bonded);
} else {
ESP_LOGI(TAG, "encryption change status=%d", event->enc_change.status);
}
return 0;
case BLE_GAP_EVENT_MTU:
ESP_LOGI(TAG, "ATT MTU %u", unsigned(event->mtu.value));
return 0;
case BLE_GAP_EVENT_PASSKEY_ACTION:
if (event->passkey.params.action == BLE_SM_IOACT_DISP) {
struct ble_sm_io pkey = {};
pkey.action = BLE_SM_IOACT_DISP;
pkey.passkey = FIXED_PASSKEY;
ESP_LOGW(TAG, "BLE PAIRING FIXED PASSKEY: %06lu", static_cast<unsigned long>(FIXED_PASSKEY));
int rc = ble_sm_inject_io(event->passkey.conn_handle, &pkey);
if (rc != 0) {
ESP_LOGW(TAG, "ble_sm_inject_io rc=%d", rc);
}
} else {
ESP_LOGW(TAG, "unhandled passkey action %d", event->passkey.params.action);
}
return 0;
case BLE_GAP_EVENT_SUBSCRIBE:
if (event->subscribe.attr_handle == s_btp_ind_val_handle ||
event->subscribe.attr_handle == s_status_val_handle ||
event->subscribe.attr_handle == s_id_val_handle ||
event->subscribe.attr_handle == s_result_val_handle) {
if (event->subscribe.cur_notify && !event->subscribe.prev_notify)
++s_notify_count;
else if (!event->subscribe.cur_notify && event->subscribe.prev_notify)
--s_notify_count;
if (s_notify_count < 0) s_notify_count = 0;
s_subscribed = (s_notify_count > 0);
ESP_LOGI(TAG, "subscription handle %u changed: notify=%d count=%d",
event->subscribe.attr_handle, (int)event->subscribe.cur_notify, s_notify_count);
}
return 0;
case BLE_GAP_EVENT_ADV_COMPLETE:
start_advertising();
return 0;
default:
return 0;
}
}
// (Re)starts undirected advertising with the device name and service UUID, unless paused.
int start_advertising() {
if (!s_adv_allowed || !s_synced || s_conn_handle != BLE_HS_CONN_HANDLE_NONE || ble_gap_adv_active()) return 0;
const char* name = s_dev_name;
size_t name_len = strlen(name);
struct ble_hs_adv_fields adv = {};
adv.flags = BLE_HS_ADV_F_DISC_GEN | BLE_HS_ADV_F_BREDR_UNSUP;
adv.tx_pwr_lvl_is_present = 1;
adv.tx_pwr_lvl = BLE_HS_ADV_TX_PWR_LVL_AUTO;
adv.name = reinterpret_cast<const uint8_t*>(name);
adv.name_len = static_cast<uint8_t>(name_len);
adv.name_is_complete = 1;
int rc = ble_gap_adv_set_fields(&adv);
if (rc != 0) {
ESP_LOGE(TAG, "adv_set_fields rc=%d", rc);
return rc;
}
struct ble_hs_adv_fields rsp = {};
rsp.uuids128 = &s_svc_uuid;
rsp.num_uuids128 = 1;
rsp.uuids128_is_complete = 1;
rc = ble_gap_adv_rsp_set_fields(&rsp);
if (rc != 0) {
ESP_LOGE(TAG, "adv_rsp_set_fields rc=%d", rc);
return rc;
}
struct ble_gap_adv_params params = {};
params.conn_mode = BLE_GAP_CONN_MODE_UND;
params.disc_mode = BLE_GAP_DISC_MODE_GEN;
rc = ble_gap_adv_start(s_own_addr_type, nullptr, BLE_HS_FOREVER,
&params, gap_event, nullptr);
if (rc != 0) {
ESP_LOGE(TAG, "adv_start rc=%d", rc);
return rc;
}
ESP_LOGI(TAG, "BLE advertising started as '%s'", name);
return 0;
}
// NimBLE host reset callback.
void on_reset(int reason) {
ESP_LOGE(TAG, "NimBLE reset, reason=%d", reason);
}
// NimBLE host sync callback: resolves our address and starts advertising.
void on_sync() {
int rc = ble_hs_util_ensure_addr(0);
assert(rc == 0);
rc = ble_hs_id_infer_auto(0, &s_own_addr_type);
if (rc != 0) {
ESP_LOGE(TAG, "ble_hs_id_infer_auto rc=%d", rc);
return;
}
s_synced = true;
// MicrOBU: a bond that did not persist (full NVS) looks like "the phone has to pair every time".
int bonds = 0;
if (ble_store_util_count(BLE_STORE_OBJ_TYPE_OUR_SEC, &bonds) == 0)
ESP_LOGI(TAG, "%d bonded phone(s) in NVS", bonds);
start_advertising();
}
void nimble_host_task(void* param) {
ESP_LOGI(TAG, "NimBLE host task running");
nimble_port_run();
nimble_port_freertos_deinit();
}
} // namespace
bool start(Receiver receiver) {
s_receiver = std::move(receiver);
uint8_t mac[6] = {};
if (esp_read_mac(mac, ESP_MAC_BT) == ESP_OK) {
std::snprintf(s_dev_name, sizeof(s_dev_name), "micrOBU-%02X%02X", mac[4], mac[5]);
}
esp_err_t err = nimble_port_init();
if (err != ESP_OK) {
ESP_LOGE(TAG, "nimble_port_init failed: %d", err);
return false;
}
ble_hs_cfg.reset_cb = on_reset;
ble_hs_cfg.sync_cb = on_sync;
ble_hs_cfg.store_status_cb = ble_store_util_status_rr;
// Fixed passkey LE Secure Connections + Bonding
ble_hs_cfg.sm_io_cap = BLE_HS_IO_DISPLAY_ONLY;
ble_hs_cfg.sm_sc = 1;
ble_hs_cfg.sm_bonding = 1;
ble_hs_cfg.sm_mitm = 1;
ble_hs_cfg.sm_our_key_dist = BLE_SM_PAIR_KEY_DIST_ENC | BLE_SM_PAIR_KEY_DIST_ID;
ble_hs_cfg.sm_their_key_dist = BLE_SM_PAIR_KEY_DIST_ENC | BLE_SM_PAIR_KEY_DIST_ID;
ble_svc_gap_init();
ble_svc_gatt_init();
ble_svc_gap_device_name_set(s_dev_name);
int rc = ble_gatts_count_cfg(s_svc_defs);
if (rc != 0) {
ESP_LOGE(TAG, "ble_gatts_count_cfg failed: %d", rc);
nimble_port_deinit();
return false;
}
rc = ble_gatts_add_svcs(s_svc_defs);
if (rc != 0) {
ESP_LOGE(TAG, "ble_gatts_add_svcs failed: %d", rc);
nimble_port_deinit();
return false;
}
ble_store_config_init();
nimble_port_freertos_init(nimble_host_task);
ESP_LOGI(TAG, "simple_ble initialized (fixed passkey %06lu)", static_cast<unsigned long>(FIXED_PASSKEY));
return true;
}
bool write(const link::Bytes& message) {
uint16_t conn_handle = s_conn_handle;
if (conn_handle == BLE_HS_CONN_HANDLE_NONE) {
++statistics.tx_failed;
return false;
}
if (message.empty() || message.size() > link::maximum_message) {
++statistics.tx_failed;
return false;
}
uint16_t val_handle = s_result_val_handle;
if (message.size() >= link::header_size) {
link::Opcode op = static_cast<link::Opcode>(message[0]);
switch (op) {
case link::Opcode::V2X_RX:
case link::Opcode::BTP_DATA_INDICATION:
val_handle = s_btp_ind_val_handle;
break;
case link::Opcode::SF_IDCHANGE_EVENT:
val_handle = s_id_val_handle;
break;
case link::Opcode::STATUS:
// Route unsolicited or requested STATUS over the subscribed result/reply characteristic
val_handle = s_result_val_handle;
break;
case link::Opcode::RESULT:
default:
val_handle = s_result_val_handle;
break;
}
}
// MicrOBU: one notification is one whole message (no fragmentation on this GATT layout), and
// NimBLE cuts a value longer than ATT_MTU - 3 short without an error. The phone asks for an
// MTU of 517; until it has, drop rather than deliver a truncated message.
if (message.size() + 3 > ble_att_mtu(conn_handle)) {
++statistics.tx_failed;
return false;
}
struct os_mbuf *om = ble_hs_mbuf_from_flat(message.data(), message.size());
if (!om) {
++statistics.tx_failed;
return false;
}
int rc = ble_gatts_notify_custom(conn_handle, val_handle, om);
if (rc != 0) {
ESP_LOGW(TAG, "notify failed handle=%u op=0x%02x rc=%d", (unsigned)val_handle, (unsigned)(message.empty() ? 0 : message[0]), rc);
if (rc == BLE_HS_ENOTSUP) os_mbuf_free_chain(om);
++statistics.tx_failed;
return false;
}
++statistics.tx_messages;
return true;
}
bool connected() {
return s_conn_handle != BLE_HS_CONN_HANDLE_NONE && s_subscribed;
}
void set_advertising_allowed(bool allowed) {
if (s_adv_allowed == allowed) return;
s_adv_allowed = allowed;
if (!s_synced) return;
if (allowed) {
start_advertising();
ESP_LOGI(TAG, "USB link idle: BLE advertising resumed");
} else if (ble_gap_adv_active()) {
ble_gap_adv_stop();
ESP_LOGI(TAG, "USB link in use: BLE advertising paused");
}
}
bool advertising_allowed() {
return s_adv_allowed;
}
bool is_subscribed() {
return s_subscribed;
}
Counters counters() {
return statistics;
}
} // namespace microbu::ble
+58
View File
@@ -0,0 +1,58 @@
#pragma once
#include "link_protocol.hpp"
#include <cstdint>
#include <functional>
namespace microbu::ble {
using Receiver = std::function<void(link::Bytes)>;
/// @brief A set of counters for tracking BLE message traffic.
struct Counters {
std::uint32_t rx_messages = 0;
std::uint32_t tx_messages = 0;
std::uint32_t tx_failed = 0;
std::uint32_t malformed = 0;
};
/**
* @brief Starts a simple, bonded BLE GATT peripheral on the NimBLE host.
* Uses structured ETSI C-ITS Station Service & Characteristics:
* Service: 0000C175-BA5E-4C17-8000-00805F9B34FB
* BTP-DATA.request: 0000C176-BA5E-4C17-8000-00805F9B34FB (Write, Authenticated/Encrypted)
* BTP-DATA.indication: 0000C177-BA5E-4C17-8000-00805F9B34FB (Notify, Authenticated/Encrypted)
* PoTi Fix Update: 0000C178-BA5E-4C17-8000-00805F9B34FB (Write, Authenticated/Encrypted)
* Station Status: 0000C179-BA5E-4C17-8000-00805F9B34FB (Read / Notify, Authenticated/Encrypted)
* SF-SAP Identity: 0000C17A-BA5E-4C17-8000-00805F9B34FB (Notify, Authenticated/Encrypted)
* Station Configure: 0000C17B-BA5E-4C17-8000-00805F9B34FB (Write, Authenticated/Encrypted)
* Result: 0000C17C-BA5E-4C17-8000-00805F9B34FB (Notify, Authenticated/Encrypted)
*
* Fixed passkey bonding: 123456.
* Single ATT message transmission/reception up to 512 bytes without fragmentation chunk delays.
* @param receiver Invoked with each decoded station-link message written by the phone.
* @return false if the NimBLE host or GATT service failed to start.
*/
bool start(Receiver receiver);
/**
* @brief Sends one complete station-link message via GATT notification.
* Dispatches immediately without queuing delay.
* @param message Encoded station-link message to notify.
* @return false if not connected/subscribed or the notification could not be sent.
*/
bool write(const link::Bytes& message);
/// @return true if a phone is connected.
bool connected();
/// @brief MicrOBU: allows or stops advertising (an existing connection is never dropped). Called
/// with false while the phone uses the USB link, so BLE takes no airtime from ITS-G5.
void set_advertising_allowed(bool allowed);
/// @return true while advertising is allowed (see set_advertising_allowed).
bool advertising_allowed();
/// @return true if the phone has subscribed to notifications.
bool is_subscribed();
/// @return Current message/error counters.
Counters counters();
} // namespace microbu::ble
+653
View File
@@ -0,0 +1,653 @@
#include "station.hpp"
#include <vanetza_idf/nf_sap.hpp>
#include <vanetza_idf/sf_sap.hpp>
#include <vanetza_idf/nvs_credential_store.hpp>
#include <vanetza/geonet/serialization_buffer.hpp>
#include <vanetza/geonet/areas.hpp>
#include <vanetza/units/angle.hpp>
#include <vanetza/units/length.hpp>
#include <vanetza/units/velocity.hpp>
#include <esp_log.h>
#include <esp_timer.h>
#include "c5_radio.hpp"
extern "C" {
#include "geonet.h"
#include "gn_unwrap.h"
}
#include <algorithm>
#include <iterator>
#include <chrono>
#include <cmath>
namespace microbu {
using namespace vanetza_idf;
using vanetza::ByteBuffer;
namespace gn = vanetza::geonet;
namespace {
const char* TAG = "station";
link::Code code(Result result) { return static_cast<link::Code>(result); }
// Numbering of the link's "Packet transport type" field matches the TRANSP_CORE.request/.indication
// service primitive parameter of ETSI TS 103 836-4-1 annex J.2/J.4 (0 GUC, 1 SHB, 2 TSB, 3 GBC,
// 4 GAC); it does not reuse the HT wire encoding of clause 9.7.4 table 9, which numbers these
// differently (GEOUNICAST=2, GEOANYCAST=3, GEOBROADCAST=4, TSB=5, no separate SHB value there).
std::uint8_t transport_number(gn::TransportType t) {
switch (t) {
case gn::TransportType::GUC: return 0;
case gn::TransportType::SHB: return 1;
case gn::TransportType::TSB: return 2;
case gn::TransportType::GBC: return 3;
case gn::TransportType::GAC: return 4;
}
return 0;
}
std::optional<gn::TransportType> transport_from(std::uint8_t n) {
switch (n) {
case 0: return gn::TransportType::GUC;
case 1: return gn::TransportType::SHB;
case 2: return gn::TransportType::TSB;
case 3: return gn::TransportType::GBC;
case 4: return gn::TransportType::GAC;
}
return std::nullopt;
}
// Wire DestinationArea -> vanetza gn::Area (GBC destination).
gn::Area area_from(const link::DestinationArea& a) {
gn::Area area;
switch (a.shape) {
case 0: { gn::Circle c; c.r = double(a.distance_a) * vanetza::units::si::meter; area.shape = c; break; }
case 1: { gn::Rectangle r; r.a = double(a.distance_a) * vanetza::units::si::meter; r.b = double(a.distance_b) * vanetza::units::si::meter; area.shape = r; break; }
default: { gn::Ellipse e; e.a = double(a.distance_a) * vanetza::units::si::meter; e.b = double(a.distance_b) * vanetza::units::si::meter; area.shape = e; break; }
}
area.position = gn::GeodeticPosition(a.latitude / 1.0e7 * vanetza::units::degree, a.longitude / 1.0e7 * vanetza::units::degree);
area.angle = vanetza::units::Angle(double(a.angle) * vanetza::units::degree);
return area;
}
// gn::Area -> wire DestinationArea (the inverse of area_from).
struct AreaToLink : boost::static_visitor<link::DestinationArea> {
const gn::Area& area;
explicit AreaToLink(const gn::Area& a) : area(a) {}
link::DestinationArea common() const {
link::DestinationArea out;
out.latitude = static_cast<std::int32_t>(area.position.latitude.value() * 1.0e7);
out.longitude = static_cast<std::int32_t>(area.position.longitude.value() * 1.0e7);
out.angle = static_cast<std::uint16_t>(area.angle.value());
return out;
}
link::DestinationArea operator()(const gn::Circle& c) const { auto o = common(); o.shape = 0; o.distance_a = c.r.value(); return o; }
link::DestinationArea operator()(const gn::Rectangle& r) const { auto o = common(); o.shape = 1; o.distance_a = r.a.value(); o.distance_b = r.b.value(); return o; }
link::DestinationArea operator()(const gn::Ellipse& e) const { auto o = common(); o.shape = 2; o.distance_a = e.a.value(); o.distance_b = e.b.value(); return o; }
};
// 0 unsecured (no security envelope), 1 + VerificationReport ordinal otherwise
struct ReportToLink : boost::static_visitor<std::uint8_t> {
ReportToLink() = default;
std::uint8_t operator()(boost::blank) const { return 0; }
std::uint8_t operator()(vanetza::security::VerificationReport r) const { return 1 + static_cast<std::uint8_t>(r); }
};
}
// Credentials, backend, trust configuration, ticket pool and the entity built on them.
struct Station::Security {
BackendMbedTls backend;
security::TrustConfiguration trust;
security::CertificatePool pool {backend};
std::unique_ptr<security::SecurityEntity> entity;
bool loaded = false;
security::ApplyReport report;
};
std::int64_t Station::Clock::now_us() const {
return base_its_us + (esp_timer_get_time() - base_esp_us);
}
Station::Station() = default;
Station::~Station() { teardown(); }
void Station::teardown() {
rebuilding_ = true; // the DEREG of TS 102 723-8 Figure 15 is ours, not the phone's
stack_.reset(); // router timers before the runtime
// AccessStack::Dcc's Limeric holds a reference to *runtime_ and calls into it from its
// destructor (Runtime::cancel): it must be destroyed before runtime_ is.
access_stack_.reset();
security_.reset();
runtime_.reset();
dcc_cca_last_.reset();
dcc_last_sample_its_us_ = 0;
pending_responders_.clear();
rebuilding_ = false;
}
// TS 102 894-2 station config -> the GeoNetworking MIB (TS 103 836-4-1).
void Station::apply_mib(StackConfig& config, const link::StationConfigure& c) const {
config.mib.itsGnLocalGnAddr.mid(vanetza::MacAddress {c.mid[0], c.mid[1], c.mid[2], c.mid[3], c.mid[4], c.mid[5]});
config.mib.itsGnLocalGnAddr.station_type(static_cast<gn::StationType>(c.station_type & 0x1F));
config.mib.itsGnLocalGnAddr.is_manually_configured(c.address_configuration == 0);
config.mib.itsGnLocalAddrConfMethod = c.address_configuration == 1 ? gn::AddrConfMethod::Anonymous : gn::AddrConfMethod::Auto;
config.mib.itsGnSecurity = c.security == 1;
config.mib.vanetzaDisableBeaconing = c.beaconing == 0;
config.mib.itsGnDefaultTrafficClass = gn::TrafficClass(c.default_traffic_class);
gn::Lifetime lifetime; lifetime.raw(c.default_lifetime);
config.mib.itsGnDefaultPacketLifetime = lifetime;
config.mib.itsGnMaxPacketLifetime = lifetime < config.mib.itsGnMaxPacketLifetime ? config.mib.itsGnMaxPacketLifetime : lifetime;
config.radio_parameters.channel_number = c.channel_number;
config.radio_parameters.transmit_power_dbm = c.transmit_power_dbm;
}
// Carry the phone's SF-SAP pseudonym-change subscriptions (TS 102 723-8 clause 6.3)
// over to the identity manager of a freshly rebuilt security entity.
void Station::resubscribe_id_change() {
std::map<std::uint64_t, std::uint64_t> renewed;
for (const auto& [handle, old_service] : link_subscriptions_) {
(void)old_service;
renewed[handle] = security_->entity->id_change().subscribe(
[this, handle](security::IdChangeCommand command, const security::Identifier& id, const ByteBuffer& data,
std::shared_ptr<security::IdChangeResponder> responder) {
link::IdChangeEvent event;
event.subscription = handle;
event.command = static_cast<std::uint8_t>(command);
std::copy(id.begin(), id.end(), event.id);
event.subscriber_data = data;
if (responder) pending_responders_[handle] = responder;
if (id_event_ && !rebuilding_) id_event_(event);
});
}
link_subscriptions_ = renewed;
}
// (Re)build stack and security entity from config_, the stored credentials and the current clock.
link::Code Station::build() {
teardown();
const auto& c = *config_;
runtime_ = std::make_unique<vanetza::ManualRuntime>(vanetza::Clock::time_point(std::chrono::microseconds(clock_.synchronised ? clock_.now_us() : 0)));
StackConfig config;
apply_mib(config, c);
radio_parameters_ = config.radio_parameters;
vanetza::security::SecurityEntity* entity = nullptr;
if (config.mib.itsGnSecurity) {
security_ = std::make_unique<Security>();
security::Credentials credentials;
security::NvsCredentialStore store;
const auto load = store.load(credentials);
if (load == Result::accepted) {
security_->report = security::apply(credentials, security_->trust, security_->pool);
security_->loaded = security_->report.result == Result::accepted && !security_->pool.empty();
ESP_LOGI(TAG, "credentials from NVS: %u roots, %u authorities, %u tickets (result %d)",
unsigned(security_->report.roots), unsigned(security_->report.authorities), unsigned(security_->report.tickets),
int(security_->report.result));
} else if (load == Result::rejected) {
ESP_LOGW(TAG, "no credentials in NVS: secured requests will be refused until provisioned");
} else {
ESP_LOGE(TAG, "stored credentials do not decode (result %d)", int(load));
}
security_->entity = std::make_unique<security::SecurityEntity>(*runtime_, *this, security_->backend, security_->pool, security_->trust);
entity = security_->entity.get();
resubscribe_id_change();
}
stack_ = std::make_unique<Stack>(config, *runtime_, *this, entity);
stack_->on_receive([this](BtpIndication indication) { deliver(std::move(indication)); });
#if CONFIG_MICROBU_TEST_CHANNEL
stack_->on_receive_gn([this](GnIndication indication) { record(3, std::move(indication.data)); });
#endif
stack_->on_access_result([](Result) { /* counted in Station::request, the adapter itself */ });
// DCC_ACC gate in front of whatever radio_ currently is (rebuilt here rather than in
// Station::configure() because it must reference the fresh runtime_, not a torn-down one --
// see the destruction-order comment in Station::teardown()).
if (radio_) {
access_stack_ = std::make_unique<AccessStack>(*radio_);
access_stack_->enable_dcc(*runtime_);
stack_->report_tx_power(static_cast<unsigned>(std::lround(radio_parameters_.transmit_power_dbm)));
}
dcc_cca_last_.reset();
dcc_last_sample_its_us_ = clock_.synchronised ? clock_.now_us() : 0;
if (have_fix_) apply_position();
return link::Code::accepted;
}
link::Code Station::configure(const link::StationConfigure& c, link::Bytes& detail) {
const bool radio_changed = !config_ || config_->radio != c.radio || config_->channel_number != c.channel_number ||
config_->transmit_power_dbm != c.transmit_power_dbm;
if (radio_changed) {
radio_.reset();
if (c.radio != 0) {
C5RadioConfig rc;
rc.channel_number = c.channel_number;
rc.transmit_power_dbm = c.transmit_power_dbm;
rc.laboratory_transmission = c.radio == 2;
radio_ = std::make_unique<C5Radio>(rc);
const auto error = radio_->start();
if (error != ESP_OK) {
ESP_LOGE(TAG, "radio start failed: %s", esp_err_to_name(error));
radio_.reset();
return link::Code::rejected;
}
ESP_LOGI(TAG, "radio on channel %u, %s", unsigned(c.channel_number), c.radio == 2 ? "transmit and receive" : "receive only");
}
}
config_ = c;
link_subscriptions_.clear(); // a configuration starts a phone session: earlier subscriptions are void
counters_ = link::Status {}; // and the session's counters start at zero
const auto result = build();
if (result != link::Code::accepted) return result;
link::Writer w;
ByteBuffer address;
gn::serialize_into_buffer(stack_->address(), address);
w.bytes(address);
security::Identifier identifier {};
if (security_ && security_->entity) identifier = security_->entity->id_change().current_identifier();
w.bytes(identifier.data(), 8);
w.u8(security_ && security_->loaded ? 1 : 0);
w.u8(security_ ? static_cast<std::uint8_t>(std::min<std::size_t>(security_->pool.size(), 255)) : 0);
detail = w.out;
counters_.configured = 1;
return link::Code::accepted;
}
void Station::apply_position() {
if (!stack_ || !have_fix_) return;
// never ahead of the station clock (Stack::update_position rejects that)
const auto now = runtime_->now();
if (fix_.timestamp > now) fix_.timestamp = now;
const auto result = stack_->update_position(fix_);
if (result != Result::accepted) ESP_LOGW(TAG, "position rejected: %d", int(result));
}
link::Code Station::poti(const link::PotiUpdate& p) {
if (p.latitude < -900000000 || p.latitude > 900000000 || p.longitude < -1800000000 || p.longitude > 1800000000)
return link::Code::invalid_argument;
// ITS clock: the phone's PoTi time is the reference; the local esp_timer runs between updates.
const std::int64_t its_us = static_cast<std::int64_t>(p.timestamp_ms) * 1000;
const std::int64_t esp_us = esp_timer_get_time();
link::Code result = link::Code::accepted;
if (!clock_.synchronised) {
clock_ = Clock {true, its_us, esp_us};
if (config_) build(); // the runtime started at 0: restart it at real time
} else {
const std::int64_t drift = its_us - clock_.now_us();
if (drift >= 0) {
clock_.base_its_us = its_us; clock_.base_esp_us = esp_us; // forward: step immediately
} else if (drift > -1000000) {
// small backward drift: hold the local clock, it catches up with the next updates
} else {
// the reference moved back by more than a second: restart the station at that time
ESP_LOGW(TAG, "ITS time moved back by %lld ms, restarting the station", static_cast<long long>(-drift / 1000));
clock_ = Clock {true, its_us, esp_us};
if (config_) build();
result = link::Code::time_regression;
}
}
fix_ = vanetza::PositionFix {};
fix_.timestamp = vanetza::Clock::time_point(std::chrono::microseconds(std::min(its_us, clock_.now_us())));
fix_.latitude = p.latitude / 1.0e7 * vanetza::units::degree;
fix_.longitude = p.longitude / 1.0e7 * vanetza::units::degree;
fix_.confidence.semi_major = p.semi_major_cm / 100.0 * vanetza::units::si::meter;
fix_.confidence.semi_minor = p.semi_minor_cm / 100.0 * vanetza::units::si::meter;
fix_.confidence.orientation = p.orientation_deci_degree / 10.0 * vanetza::units::true_north_degrees;
fix_.speed = (p.has_speed() ? p.speed_cm_s / 100.0 : 0.0) * vanetza::units::si::meters_per_second;
fix_.course = (p.has_heading() ? p.heading_deci_degree / 10.0 : 0.0) * vanetza::units::true_north_degrees;
if (p.has_altitude()) fix_.altitude = vanetza::ConfidentQuantity<vanetza::units::Length>(p.altitude_cm / 100.0 * vanetza::units::si::meter);
have_fix_ = true;
last_poti_ = p;
++counters_.poti_updates;
tick();
apply_position();
return result;
}
link::Code Station::btp_request(const link::BtpDataRequest& q) {
if (!stack_) return link::Code::not_configured;
if (!clock_.synchronised || !have_fix_) { ++counters_.requests_refused; return link::Code::rejected; }
NF_SAP::BTP_DATA_request request;
request.fl_sdu = q.fl_sdu;
request.length = q.fl_sdu.size();
request.btp_type = q.btp_type == 0 ? BtpType::a : BtpType::b;
request.destination_port = q.destination_port;
if (request.btp_type == BtpType::a) request.source_port = q.destination_port_info;
else request.destination_port_info = q.destination_port_info;
const auto transport = transport_from(q.gn_packet_transport_type);
if (!transport) { ++counters_.requests_refused; return link::Code::invalid_argument; }
request.gn_packet_transport_type = *transport;
switch (q.gn_communication_profile) {
case 0: request.gn_communication_profile = gn::CommunicationProfile::Unspecified; break;
case 1: request.gn_communication_profile = gn::CommunicationProfile::ITS_G5; break;
case 2: request.gn_communication_profile = gn::CommunicationProfile::LTE_V2X; break;
default: ++counters_.requests_refused; return link::Code::invalid_argument;
}
// MicrOBU: the colleague's firmware refuses unsecured requests outright. Here the phone's
// "Sign outgoing messages" setting decides, per request; 0 means the station's configuration.
if (q.gn_security_profile > 2) {
++counters_.requests_refused;
return link::Code::invalid_argument;
}
const bool secured = q.gn_security_profile == 2 || (q.gn_security_profile == 0 && config_->security == 1);
if (!secured) return unsecured_request(q);
request.gn_security_profile = NF_SAP::SecurityProfile::SECURED;
request.gn_traffic_class = q.gn_traffic_class == 0xFF ? stack_->config().mib.itsGnDefaultTrafficClass : gn::TrafficClass(q.gn_traffic_class);
if (q.gn_maximum_packet_lifetime != 0xFF) { gn::Lifetime l; l.raw(q.gn_maximum_packet_lifetime); request.gn_maximum_packet_lifetime = l; }
if (q.gn_maximum_hop_limit) request.gn_maximum_hop_limit = q.gn_maximum_hop_limit;
if (q.gn_repetition_interval_ms) {
gn::DataRequest::Repetition repetition;
repetition.interval = (q.gn_repetition_interval_ms / 1000.0) * vanetza::units::si::seconds;
repetition.maximum = (q.gn_repetition_maximum_ms / 1000.0) * vanetza::units::si::seconds;
request.gn_repetition = repetition;
}
if (*transport == gn::TransportType::GBC) {
if (!q.area) { ++counters_.requests_refused; return link::Code::invalid_argument; }
request.gn_destination_address = area_from(*q.area);
}
request.its_aid = q.its_aid;
request.permissions = q.permissions;
request.context_information = q.context;
tick(); // the packet carries the current time and position
ESP_LOGI(TAG, "heap before sign: free=%lu min=%lu dma=%lu stack_hwm=%u",
(unsigned long)esp_get_free_heap_size(),
(unsigned long)esp_get_minimum_free_heap_size(),
(unsigned long)heap_caps_get_free_size(MALLOC_CAP_DMA),
(unsigned)uxTaskGetStackHighWaterMark(nullptr));
Result result = Result::rejected;
try {
result = NF_SAP::BTP_DATA_request_submit(*stack_, std::move(request));
if (result == Result::accepted) ++counters_.requests_accepted; else ++counters_.requests_refused;
} catch (const std::exception& e) {
ESP_LOGE(TAG, "BTP_DATA_request_submit exception: %s", e.what());
++counters_.requests_refused;
return link::Code::rejected;
}
return code(result);
}
void Station::forward_raw(const ByteBuffer& frame, int rssi) {
if (!raw_its_) return;
// Most captured frames are not ITS traffic this handles; false is the common case, not an error.
gn_rx_t rx;
if (!gn_unwrap_its(frame.data(), static_cast<int>(frame.size()), &rx)) return;
constexpr std::size_t prefix = 14;
if (rx.truncated || rx.payload_len <= 0 ||
static_cast<std::size_t>(rx.payload_len) + prefix + link::header_size > link::maximum_message) {
++raw_oversize_;
static std::int64_t last_report = 0; // one line per 10 s at most, the counter has the rest
const auto now = esp_timer_get_time();
if (now - last_report > 10000000) {
last_report = now;
ESP_LOGW(TAG, "V2X_RX: port %u message of %d bytes does not fit a link message (%lu dropped so far)",
unsigned(rx.btp_dest_port), rx.payload_len, (unsigned long)raw_oversize_);
}
return;
}
link::Writer w;
w.u16(rx.btp_dest_port);
w.u8(static_cast<std::uint8_t>(static_cast<std::int8_t>(std::clamp(rssi, -128, 127))));
w.u8((rx.has_geo_area ? 0x01 : 0) | (rx.signed_unverified ? 0x02 : 0));
w.i32(rx.geo_area_lat_tenmicrodeg);
w.i32(rx.geo_area_lon_tenmicrodeg);
w.u16(rx.geo_area_distance_a_m);
w.bytes(rx.payload, static_cast<std::size_t>(rx.payload_len));
++raw_forwarded_;
raw_its_(w.out);
}
link::Code Station::unsecured_request(const link::BtpDataRequest& q) {
// geonet.c builds exactly one packet shape: BTP-B inside a GN single-hop broadcast. That is
// what CAM and VAM are; anything else still needs the stack, which here only signs.
if (q.btp_type != 1 || q.gn_packet_transport_type != 1) {
++counters_.requests_refused;
return link::Code::unsupported;
}
gn_lpv_t lpv {};
std::copy(std::begin(config_->mid), std::end(config_->mid), lpv.mac);
lpv.station_type = config_->station_type;
lpv.pai = last_poti_.pai();
lpv.tst_ms = static_cast<std::uint32_t>(last_poti_.timestamp_ms); // TimestampIts mod 2^32
lpv.lat_tenmicrodeg = last_poti_.latitude;
lpv.lon_tenmicrodeg = last_poti_.longitude;
lpv.speed_cms = static_cast<std::int16_t>(last_poti_.has_speed() ? std::min<unsigned>(last_poti_.speed_cm_s, 16383) : 0);
lpv.heading_decideg = last_poti_.has_heading() ? last_poti_.heading_deci_degree : 0;
ByteBuffer pdu(q.fl_sdu.size() + 64);
const int length = geonet_wrap_shb(q.fl_sdu.data(), static_cast<int>(q.fl_sdu.size()), &lpv,
q.destination_port, pdu.data(), pdu.size());
if (length <= 0) {
++counters_.requests_refused;
return link::Code::invalid_argument;
}
pdu.resize(static_cast<std::size_t>(length));
AlDataRequest frame = radio_parameters_;
frame.source = vanetza::MacAddress {lpv.mac[0], lpv.mac[1], lpv.mac[2], lpv.mac[3], lpv.mac[4], lpv.mac[5]};
frame.destination = vanetza::cBroadcastMacAddress;
frame.data = std::move(pdu);
const auto result = request(std::move(frame));
if (result == Result::accepted) ++counters_.requests_accepted; else ++counters_.requests_refused;
return code(result);
}
void Station::deliver(BtpIndication received) {
auto indication = NF_SAP::BTP_DATA_indication_from(std::move(received));
++counters_.indications;
#if CONFIG_MICROBU_TEST_CHANNEL
// The test channel sees every BTP indication as well (kind 2, the HIL SUT layout).
if (mirror_ != Mirror::off) {
link::Writer w;
w.u8(indication.btp_type == BtpType::b ? 1 : 0);
const auto port = indication.destination_port;
const auto info = indication.source_port.value_or(indication.destination_port_info.value_or(0));
w.out.push_back(port >> 8); w.out.push_back(port & 0xFF); // big-endian like hil_sut.cpp
w.out.push_back(info >> 8); w.out.push_back(info & 0xFF);
w.bytes(indication.received_fl_sdu);
record(2, w.out);
}
#endif
if (!indication_) return;
link::BtpDataIndication out;
out.btp_type = indication.btp_type == BtpType::b ? 1 : 0;
out.destination_port = indication.destination_port;
out.destination_port_info = indication.source_port.value_or(indication.destination_port_info.value_or(0));
out.gn_packet_transport_type = transport_number(indication.gn.transport_type);
out.gn_traffic_class = indication.gn.traffic_class.raw();
out.gn_remaining_packet_lifetime = indication.gn.remaining_packet_lifetime ? indication.gn.remaining_packet_lifetime->raw() : 0xFF;
out.gn_remaining_hop_limit = indication.gn.remaining_hop_limit ? static_cast<std::uint8_t>(std::min(*indication.gn.remaining_hop_limit, 254u)) : 0xFF;
ByteBuffer address;
gn::serialize_into_buffer(indication.gn.source_position.gn_addr, address);
std::copy_n(address.begin(), std::min<std::size_t>(address.size(), 8), out.source_gn_address);
out.source_timestamp = indication.gn.source_position.timestamp.raw();
out.source_latitude = indication.gn.source_position.latitude.value();
out.source_longitude = indication.gn.source_position.longitude.value();
out.security_report = boost::apply_visitor(ReportToLink {}, indication.gn.security_report);
out.its_aid = indication.gn.its_aid.value_or(0);
if (indication.gn.permissions) out.permissions = *indication.gn.permissions;
if (indication.gn.certificate_id) { out.certificate_present = true; std::copy(indication.gn.certificate_id->begin(), indication.gn.certificate_id->end(), out.certificate_id); }
if (const auto* area = boost::get<gn::Area>(&indication.gn.destination)) out.area = boost::apply_visitor(AreaToLink {*area}, area->shape);
out.received_fl_sdu = std::move(indication.received_fl_sdu);
indication_(out);
}
link::Code Station::provision(const link::Bytes& bundle, link::ApplyReport& report) {
try {
security::Credentials credentials;
if (!security::decode(bundle, credentials) || credentials.roots.empty() || credentials.tickets.empty()) {
return link::Code::invalid_argument;
}
security::NvsCredentialStore store;
const auto saved = store.save(credentials);
if (saved != Result::accepted) return code(saved);
if (config_ && config_->security) {
const auto result = build();
if (result != link::Code::accepted) return result;
if (security_) {
report.roots = security_->report.roots;
report.authorities = security_->report.authorities;
report.tickets = security_->report.tickets;
ESP_LOGI(TAG, "credentials provisioned: %u roots, %u authorities, %u tickets",
unsigned(report.roots), unsigned(report.authorities), unsigned(report.tickets));
}
return link::Code::accepted;
}
report.roots = credentials.roots.size();
report.authorities = credentials.authorities.size();
report.tickets = credentials.tickets.size();
return link::Code::accepted;
} catch (const std::bad_alloc&) {
ESP_LOGE(TAG, "out of memory provisioning credentials");
return link::Code::resource_limit;
} catch (const std::exception& e) {
ESP_LOGE(TAG, "failed provisioning credentials: %s", e.what());
return link::Code::invalid_argument;
}
}
link::Code Station::erase_credentials() {
security::NvsCredentialStore store;
const auto erased = store.erase();
if (erased != Result::accepted && erased != Result::rejected) return code(erased);
if (config_ && config_->security) return build();
return link::Code::accepted;
}
link::Code Station::subscribe(const link::Bytes& subscriber_data, std::uint64_t& subscription) {
if (!security_ || !security_->entity) return link::Code::security_unavailable;
static std::uint64_t next_handle = 1;
const auto handle = next_handle++;
const auto service = security_->entity->id_change().subscribe(
[this, handle](security::IdChangeCommand command, const security::Identifier& id, const ByteBuffer& data,
std::shared_ptr<security::IdChangeResponder> responder) {
link::IdChangeEvent event;
event.subscription = handle;
event.command = static_cast<std::uint8_t>(command);
std::copy(id.begin(), id.end(), event.id);
event.subscriber_data = data;
if (responder) pending_responders_[handle] = responder;
if (id_event_ && !rebuilding_) id_event_(event);
}, subscriber_data);
link_subscriptions_[handle] = service;
subscription = handle;
return link::Code::accepted;
}
link::Code Station::unsubscribe(std::uint64_t subscription) {
const auto it = link_subscriptions_.find(subscription);
if (it == link_subscriptions_.end()) return link::Code::invalid_argument;
Result result = Result::accepted;
if (security_ && security_->entity) result = security_->entity->id_change().unsubscribe(it->second);
link_subscriptions_.erase(it);
pending_responders_.erase(subscription);
return code(result);
}
link::Code Station::event_response(std::uint64_t subscription, bool return_code) {
const auto it = pending_responders_.find(subscription);
if (it == pending_responders_.end()) return link::Code::invalid_argument;
it->second->respond(return_code);
pending_responders_.erase(it);
return link::Code::accepted;
}
link::Code Station::trigger() {
if (!security_ || !security_->entity) return link::Code::security_unavailable;
return code(security_->entity->id_change().trigger());
}
link::Code Station::lock(std::uint8_t seconds, std::uint64_t& handle) {
if (!security_ || !security_->entity) return link::Code::security_unavailable;
handle = security_->entity->id_change().lock(seconds);
return link::Code::accepted;
}
link::Code Station::unlock(std::uint64_t handle) {
if (!security_ || !security_->entity) return link::Code::security_unavailable;
return code(security_->entity->id_change().unlock(handle));
}
void Station::tick() {
// MicrOBU: the stack needs the ITS clock, which only the phone's first PoTi sets. The radio
// does not: its queue (16 frames) must be drained and raw frames forwarded from the moment the
// station is configured. The colleague's version returned early here, so a phone that had
// configured the station but not yet sent a PoTi (no trip recording, no pinger) received
// nothing and every frame on air overflowed the queue into radio_dropped.
const bool running = stack_ && clock_.synchronised;
if (running) {
const auto now = vanetza::Clock::time_point(std::chrono::microseconds(clock_.now_us()));
if (now > runtime_->now()) stack_->advance(now);
}
if (radio_) {
radio_->poll([this, running](AlDataIndication indication) {
++counters_.radio_received;
if (received_) received_();
if (running && stack_) stack_->indicate(std::move(indication));
}, [this](const ByteBuffer& frame, int rssi, std::uint32_t) { forward_raw(frame, rssi); });
counters_.radio_dropped = radio_->dropped_frames();
}
if (running) sample_dcc_channel_load();
}
// Sample the hardware LCBR counters at the T_Cbr cadence (TS 102 687 clause 5.4 /
// TS 103 836-4-2 clause 5.2, both 100 ms) and feed both DCC entities. The first sample after
// (re)build only establishes the baseline counter snapshot -- a CBR delta needs two samples.
void Station::sample_dcc_channel_load() {
if (!radio_ || !access_stack_) return;
const auto its_us = clock_.now_us();
if (its_us - dcc_last_sample_its_us_ < 100000) return;
const auto counters = radio_->read_cca_counters();
if (dcc_cca_last_) {
const vanetza::dcc::ChannelLoad local_cbr(C5Radio::calculate_cbr(counters, *dcc_cca_last_));
stack_->report_local_channel_load(local_cbr);
const auto global_cbr = stack_->global_channel_busy_ratio();
// Consume Release-2 CBR_G for DCC_ACC when available, else LCBR.
access_stack_->report_channel_load(global_cbr ? *global_cbr : local_cbr);
}
dcc_cca_last_ = counters;
dcc_last_sample_its_us_ = its_us;
}
link::Status Station::status() {
link::Status s = counters_;
s.uptime_ms = static_cast<std::uint32_t>(esp_timer_get_time() / 1000);
s.configured = stack_ ? 1 : 0;
if (stack_) {
ByteBuffer address;
gn::serialize_into_buffer(stack_->address(), address);
std::copy_n(address.begin(), std::min<std::size_t>(address.size(), 8), s.gn_address);
s.change_pending = stack_->identity_change_pending() ? 1 : 0;
}
if (security_ && security_->entity) {
const auto id = security_->entity->id_change().current_identifier();
std::copy(id.begin(), id.end(), s.identifier);
s.tickets = static_cast<std::uint8_t>(std::min<std::size_t>(security_->pool.size(), 255));
const auto& st = security_->entity->statistics();
s.signed_messages = st.signed_messages; s.refused_no_ticket = st.refused_no_ticket;
s.refused_change_pending = st.refused_change_pending; s.refused_permission = st.refused_permission;
s.sign_failed = st.failed; s.verified = st.verified;
s.rejected = st.rejected_profile + st.rejected_signer + st.rejected_certificate + st.rejected_signature + st.rejected_time + st.replayed;
}
s.its_time_ms = clock_.synchronised ? static_cast<std::uint64_t>(clock_.now_us() / 1000) : 0;
return s;
}
// ---- access adapter: radio, mirrored or diverted to the software lower tester ----
Result Station::request(AlDataRequest request) {
#if CONFIG_MICROBU_TEST_CHANNEL
if (mirror_ != Mirror::off) record(1, request.data);
if (mirror_ == Mirror::divert) { ++counters_.radio_submitted; return overflow_ ? Result::resource_limit : Result::accepted; }
#endif
if (radio_) {
// Adaptive DCC_ACC gates here when access_stack_ has it enabled (build()); it falls
// through to radio_->request() unchanged otherwise.
const auto result = access_stack_ ? access_stack_->request(std::move(request)) : radio_->request(std::move(request));
if (result == Result::accepted) {
++counters_.radio_submitted;
if (disseminated_) disseminated_();
}
else {
++counters_.radio_failed;
static std::int64_t last_report = 0; // one line per second, the counter has the rest
const auto now = esp_timer_get_time();
if (now - last_report > 1000000) { last_report = now; ESP_LOGW(TAG, "radio refused a frame: result %d", int(result)); }
}
return result;
}
++counters_.radio_failed;
return Result::unsupported; // no radio configured and nothing diverting: the request has nowhere to go
}
} // namespace microbu
+266
View File
@@ -0,0 +1,266 @@
#pragma once
// The ESP32-C5 half of the VRU ITS-S: BTP-B, GeoNetworking, the SN-SAP
// security entity with the station's provisioned credentials, and the ITS-G5 access adapter.
// Everything here runs on one task (the station task).
#include "c5_radio.hpp"
#include "link_protocol.hpp"
#include <vanetza_idf/stack.hpp>
#include <vanetza_idf/security.hpp>
#include <vanetza_idf/credentials.hpp>
#include <vanetza_idf/backend_mbedtls.hpp>
#include <vanetza/common/manual_runtime.hpp>
#include <vanetza/common/position_provider.hpp>
#include <deque>
#include <functional>
#include <map>
#include <memory>
#include <optional>
namespace microbu {
#if CONFIG_MICROBU_TEST_CHANNEL
/// Records the software lower tester collects (test channel, test firmware only).
struct TestRecord { std::uint8_t kind; link::Bytes bytes; };
#endif
/// @note All public members except the on_*() callback setters must be called from the station task.
class Station final : public vanetza_idf::Access, public vanetza::PositionProvider {
public:
using Indication = std::function<void(const link::BtpDataIndication&)>;
using IdEvent = std::function<void(const link::IdChangeEvent&)>;
using Disseminated = std::function<void()>;
using Received = std::function<void()>;
/// MicrOBU: body of one link V2X_RX message (see link_protocol.hpp).
using RawIts = std::function<void(const link::Bytes&)>;
/// @brief Constructs an unconfigured station (call configure() before use).
Station();
/// @brief Tears down the stack, security entity and radio in dependency order.
~Station() override;
// ---- link primitives (station task) ----
/// @brief (Re)builds the stack and security entity for a new station configuration.
/// @param config Requested configuration.
/// @param detail Receives the assigned GN address, identifier, and ticket count.
/// @return Result code.
link::Code configure(const link::StationConfigure& config, link::Bytes& detail);
/// @brief Applies a position/time fix from the phone and advances the ITS clock.
/// @param fix Position/time fix to apply.
/// @return Result code.
link::Code poti(const link::PotiUpdate& fix);
/// @brief Submits a BTP data request to the stack.
/// @param request Request to submit.
/// @return Result code.
link::Code btp_request(const link::BtpDataRequest& request);
/// @brief Decodes and stores a credential bundle, rebuilding the security entity if configured.
/// @param bundle Raw credential bundle bytes.
/// @param report Receives the applied root/authority/ticket counts.
/// @return Result code.
link::Code provision(const link::Bytes& bundle, link::ApplyReport& report);
/// @brief Erases stored credentials and rebuilds if security is configured.
/// @return Result code.
link::Code erase_credentials();
/// @brief Subscribes to pseudonym-change events.
/// @param subscriber_data Opaque data echoed back with events for this subscription.
/// @param subscription Receives the assigned subscription handle.
/// @return Result code.
link::Code subscribe(const link::Bytes& subscriber_data, std::uint64_t& subscription);
/// @brief Cancels a pseudonym-change subscription.
/// @param subscription Handle returned by subscribe().
/// @return Result code.
link::Code unsubscribe(std::uint64_t subscription);
/// @brief Resolves a pending PREPARE/COMMIT identity-change event.
/// @param subscription Handle the event was delivered for.
/// @param return_code Caller's response (accept/reject) to the pending event.
/// @return Result code.
link::Code event_response(std::uint64_t subscription, bool return_code);
/// @brief Triggers an immediate pseudonym change.
/// @return Result code.
link::Code trigger();
/// @brief Locks the current pseudonym for the given duration.
/// @param seconds Lock duration in seconds.
/// @param handle Receives the assigned lock handle.
/// @return Result code.
link::Code lock(std::uint8_t seconds, std::uint64_t& handle);
/// @brief Releases a pseudonym lock.
/// @param handle Handle returned by lock().
/// @return Result code.
link::Code unlock(std::uint64_t handle);
/// @return Current station status snapshot.
link::Status status();
/// @brief Advances the ITS clock, runs timers, polls the radio; call every few milliseconds.
void tick();
/// @brief Registers the callback invoked when a BTP data indication arrives.
/// @param f Callback to invoke; replaces any previously registered callback.
void on_indication(Indication f) { indication_ = std::move(f); }
/// @brief Registers the callback invoked when a pseudonym-change event fires.
/// @param f Callback to invoke; replaces any previously registered callback.
void on_id_event(IdEvent f) { id_event_ = std::move(f); }
/// @brief Registers the callback invoked after a frame is disseminated.
/// @param f Callback to invoke; replaces any previously registered callback.
void on_disseminated(Disseminated f) { disseminated_ = std::move(f); }
/// @brief Registers the callback invoked after a frame is received.
/// @param f Callback to invoke; replaces any previously registered callback.
void on_received(Received f) { received_ = std::move(f); }
/// @brief MicrOBU: registers the callback for every ITS message heard on air, unwrapped by
/// gn_unwrap.c before (and independently of) the stack's security checks. The stack drops
/// what it cannot verify (itsGnSnDecapResultHandling is STRICT in vanetza-idf): unsigned
/// traffic, and anything signed under a root other than the provisioned demo root, i.e. every
/// RSU. This path is how those still reach the phone, exactly as with the previous firmware.
/// @param f Callback to invoke with a V2X_RX body; replaces any previously registered callback.
void on_raw_its(RawIts f) { raw_its_ = std::move(f); }
#if CONFIG_MICROBU_TEST_CHANNEL
// ---- test channel (software lower tester; not part of the phone interface -- see test_channel.hpp) ----
enum class Mirror : std::uint8_t { off = 0, mirror = 1, divert = 2 };
/// @brief Sets whether requests/indications are mirrored to, or diverted through, the test channel.
/// @param mode Off, mirror (copy) or divert (intercept) mode.
void test_mirror(Mirror mode);
/// @brief Injects a raw GN PDU as if received over the air.
/// @param source Source MAC address to report for the injected frame.
/// @param destination Destination MAC address to report for the injected frame.
/// @param gnpdu Raw GeoNetworking PDU bytes.
/// @return Result code.
link::Code test_inject(const vanetza::MacAddress& source, const vanetza::MacAddress& destination, link::Bytes gnpdu);
/// @brief Submits a raw GeoNetworking request bypassing BTP.
/// @param traffic_class GeoNetworking traffic class to submit with.
/// @param payload Raw payload bytes.
/// @return Result code.
link::Code test_gn_request(std::uint8_t traffic_class, link::Bytes payload);
/// @brief Pops queued mirror/divert records fitting into budget octets (3 per record header); the rest stays queued.
/// @param overflow Receives true if records were dropped because the queue budget was exceeded.
/// @param budget Maximum number of octets of records to drain.
/// @return Drained records.
std::deque<TestRecord> test_drain(bool& overflow, std::size_t budget);
/// @brief Clears mirror mode and any queued records.
void test_reset();
/// @brief Starts the radio if needed and sends a burst of raw test frames.
/// @param channel ITS-G5 channel number to transmit on.
/// @param power_dbm Transmit power in dBm.
/// @param mcs 802.11p modulation and coding scheme index.
/// @param count Number of frames to send.
/// @param interval_ms Interval between frames in milliseconds.
/// @param payload_len Length of each frame's payload in bytes.
/// @return Result code.
link::Code test_radio_burst(std::uint16_t channel, double power_dbm, unsigned mcs,
unsigned count, unsigned interval_ms, std::size_t payload_len);
/// @brief Starts the radio if needed and samples the CCA state for duration_ms.
/// @param duration_ms Sampling window duration in milliseconds.
/// @param detail Receives the sampling statistics.
/// @return Result code.
link::Code test_cca_sample(unsigned duration_ms, link::Bytes& detail);
#endif
// vanetza_idf::Access
/// @brief Submits a frame to the radio (through DCC_ACC when enabled), mirroring/diverting for the test channel.
/// @param request Frame and transmit parameters from the access layer.
/// @return Result code.
vanetza_idf::Result request(vanetza_idf::AlDataRequest request) override;
// vanetza::PositionProvider
/// @return The most recently applied position fix.
const vanetza::PositionFix& position_fix() override { return fix_; }
private:
struct Security;
struct Clock {
bool synchronised = false;
std::int64_t base_its_us = 0; // ITS time at base_esp_us
std::int64_t base_esp_us = 0;
std::int64_t now_us() const;
};
/// @brief (Re)builds the stack and security entity from config_, stored credentials and the clock.
/// @return Result code.
link::Code build();
/// @brief Destroys the stack, security entity and runtime in dependency order.
void teardown();
/// @brief Maps a station configuration onto the GeoNetworking MIB.
/// @param mib MIB to populate.
/// @param config Station configuration to map from.
void apply_mib(vanetza_idf::StackConfig& mib, const link::StationConfigure& config) const;
/// @brief Re-subscribes existing pseudonym-change handles to a freshly built security entity.
void resubscribe_id_change();
/// @brief Pushes the current position fix into the stack, clamped to not precede the station clock.
void apply_position();
/// @brief Samples the hardware CCA counters at the DCC cadence and feeds both DCC entities.
void sample_dcc_channel_load();
#if CONFIG_MICROBU_TEST_CHANNEL
/// @brief Queues bytes for the test channel, subject to the mirror-buffer budget.
/// @param kind Record kind tag.
/// @param bytes Record payload bytes.
void record(std::uint8_t kind, link::Bytes bytes);
#endif
/// @brief MicrOBU: unwraps one raw received frame with gn_unwrap.c and hands it to raw_its_.
/// @param frame Complete 802.11 frame as captured (FCS included; gn_unwrap reads declared lengths).
/// @param rssi Received signal strength, dBm.
void forward_raw(const vanetza::ByteBuffer& frame, int rssi);
/// @brief MicrOBU: transmits an unsecured BTP-B/SHB request the way the previous firmware did,
/// with geonet.c's GN header and the Source Position Vector of the last PoTi, through request().
/// @param request The phone's request (already checked: configured, clock and fix present).
/// @return Result code.
link::Code unsecured_request(const link::BtpDataRequest& request);
/// @brief Translates and forwards a stack BTP indication to the link service and test channel.
/// @param indication Indication received from the stack.
void deliver(vanetza_idf::BtpIndication indication);
link::Status counters_;
std::optional<link::StationConfigure> config_;
Clock clock_;
vanetza::PositionFix fix_;
bool have_fix_ = false;
link::PotiUpdate last_poti_; // MicrOBU: the GN Source Position Vector of unsecured_request()
std::uint32_t raw_forwarded_ = 0, raw_oversize_ = 0;
std::unique_ptr<vanetza::ManualRuntime> runtime_;
std::unique_ptr<Security> security_;
std::unique_ptr<vanetza_idf::Stack> stack_;
std::unique_ptr<C5Radio> radio_;
std::unique_ptr<vanetza_idf::AccessStack> access_stack_; // DCC_ACC gate in front of radio_
vanetza_idf::AlDataRequest radio_parameters_;
std::optional<CcaCounters> dcc_cca_last_; // previous read_cca_counters() sample, for the 100 ms LCBR delta
std::int64_t dcc_last_sample_its_us_ = 0;
Indication indication_;
IdEvent id_event_;
Disseminated disseminated_;
Received received_;
RawIts raw_its_;
std::map<std::uint64_t, std::shared_ptr<vanetza_idf::security::IdChangeResponder>> pending_responders_;
std::map<std::uint64_t, std::uint64_t> link_subscriptions_; // subscription -> service handle (identity)
bool rebuilding_ = false;
#if CONFIG_MICROBU_TEST_CHANNEL
Mirror mirror_ = Mirror::off;
std::deque<TestRecord> records_;
std::size_t record_bytes_ = 0;
bool overflow_ = false;
#endif
};
} // namespace microbu
+130
View File
@@ -0,0 +1,130 @@
// Station's test-channel API (software lower tester; not part of the phone interface, see
// test_channel.hpp). Kept in its own translation unit so the productive station.cpp stays free
// of test-only code; compiles to nothing when CONFIG_MICROBU_TEST_CHANNEL is off.
#include "station.hpp"
#if CONFIG_MICROBU_TEST_CHANNEL
#include <vanetza_idf/nf_sap.hpp>
#include <vanetza_idf/sf_sap.hpp>
#include <esp_log.h>
#include "c5_radio.hpp"
namespace microbu {
using namespace vanetza_idf;
namespace gn = vanetza::geonet;
namespace {
const char* TAG = "station";
link::Code code(Result result) { return static_cast<link::Code>(result); }
}
void Station::test_mirror(Mirror mode) { mirror_ = mode; }
link::Code Station::test_inject(const vanetza::MacAddress& source, const vanetza::MacAddress& destination, link::Bytes gnpdu) {
if (!stack_) return link::Code::not_configured;
tick();
AlDataIndication indication;
indication.source = source;
indication.destination = destination;
indication.channel_number = radio_parameters_.channel_number;
indication.data = std::move(gnpdu);
return code(stack_->indicate(std::move(indication)));
}
link::Code Station::test_gn_request(std::uint8_t traffic_class, link::Bytes payload) {
if (!stack_) return link::Code::not_configured;
if (!clock_.synchronised || !have_fix_) return link::Code::rejected;
tick();
GnRequest request;
request.traffic_class = gn::TrafficClass(traffic_class);
request.data = std::move(payload);
return code(stack_->request(std::move(request)));
}
std::deque<TestRecord> Station::test_drain(bool& overflow, std::size_t budget) {
std::deque<TestRecord> out;
std::size_t used = 0;
while (!records_.empty() && used + records_.front().bytes.size() + 3 <= budget) {
used += records_.front().bytes.size() + 3;
record_bytes_ -= records_.front().bytes.size();
out.push_back(std::move(records_.front()));
records_.pop_front();
}
overflow = overflow_ && records_.empty();
if (records_.empty()) overflow_ = false;
return out;
}
void Station::test_reset() {
mirror_ = Mirror::off;
records_.clear();
record_bytes_ = 0;
overflow_ = false;
}
link::Code Station::test_radio_burst(std::uint16_t channel, double power_dbm, unsigned mcs,
unsigned count, unsigned interval_ms, std::size_t payload_len) {
if (!radio_) {
// Automatically start the radio for testing if not yet started
C5RadioConfig rc;
rc.channel_number = channel;
rc.transmit_power_dbm = power_dbm;
rc.laboratory_transmission = true;
radio_ = std::make_unique<C5Radio>(rc);
const auto error = radio_->start();
if (error != ESP_OK) {
ESP_LOGE(TAG, "radio start failed for test burst: %s", esp_err_to_name(error));
radio_.reset();
return link::Code::rejected;
}
}
const auto err = radio_->transmit_burst(channel, power_dbm, mcs, count, interval_ms, payload_len);
if (err == ESP_OK) {
counters_.radio_submitted += count;
if (disseminated_) disseminated_();
return link::Code::accepted;
}
if (err == ESP_ERR_INVALID_ARG) return link::Code::invalid_argument;
if (err == ESP_ERR_NO_MEM) return link::Code::resource_limit;
return link::Code::rejected;
}
link::Code Station::test_cca_sample(unsigned duration_ms, link::Bytes& detail) {
// Capped: this busy-loops with interrupts otherwise free, so keep it short enough that
// the task watchdog and the WiFi/BLE stack's own housekeeping are not starved for long.
duration_ms = std::min(duration_ms, 500u);
if (!radio_) {
C5RadioConfig rc; // default channel 180 / 10 dBm is fine: this probes PHY polling
rc.laboratory_transmission = true; // capability, not a specific channel's RF content
radio_ = std::make_unique<C5Radio>(rc);
const auto error = radio_->start();
if (error != ESP_OK) {
ESP_LOGE(TAG, "radio start failed for CCA sample: %s", esp_err_to_name(error));
radio_.reset();
return link::Code::rejected;
}
}
const auto result = radio_->sample_cca(duration_ms);
link::Writer w;
w.u32(result.samples);
w.u32(result.duration_us);
w.u32(result.min_delta_us);
w.u32(result.max_delta_us);
w.u32(result.busy_count);
w.i32(result.first_cca);
w.i32(result.last_cca);
w.i32(result.noise_floor_dbm);
w.i32(result.cca_total_cycles_delta);
w.i32(result.cca_busy_cycles_delta);
w.i32(result.cca_status);
detail = w.out;
return result.samples > 0 ? link::Code::accepted : link::Code::rejected;
}
void Station::record(std::uint8_t kind, link::Bytes bytes) {
if (mirror_ == Mirror::off) return;
if (record_bytes_ + bytes.size() > 8192 || records_.size() >= 32) { overflow_ = true; return; }
record_bytes_ += bytes.size();
records_.push_back(TestRecord {kind, std::move(bytes)});
}
} // namespace microbu
#endif // CONFIG_MICROBU_TEST_CHANNEL
+79
View File
@@ -0,0 +1,79 @@
#include "test_channel.hpp"
#if CONFIG_MICROBU_TEST_CHANNEL
#include <algorithm>
namespace microbu {
link::Bytes test_channel_execute(Station& station, const link::Bytes& request) {
using link::Code;
Code result = Code::malformed;
std::deque<TestRecord> records;
link::Bytes extra;
if (!request.empty()) {
link::Reader r(request, 1);
switch (request[0]) {
case 0x01: { // MIRROR mode
const auto mode = r.u8();
if (r.done() && mode <= 2) { station.test_mirror(static_cast<Station::Mirror>(mode)); result = Code::accepted; }
break;
}
case 0x02: { // INJECT source destination gnpdu
vanetza::MacAddress source, destination;
r.bytes(source.octets.data(), 6);
r.bytes(destination.octets.data(), 6);
auto gnpdu = r.rest();
if (r.ok() && !gnpdu.empty()) result = station.test_inject(source, destination, std::move(gnpdu));
break;
}
case 0x03: { // GN_REQUEST traffic class payload
const auto tc = r.u8();
auto payload = r.rest();
if (r.ok() && !payload.empty()) result = station.test_gn_request(tc, std::move(payload));
break;
}
case 0x04: { // DRAIN
bool overflow = false;
records = station.test_drain(overflow, 1536 - 2);
result = overflow ? Code::resource_limit : Code::accepted;
break;
}
case 0x05: // RESET
station.test_reset();
result = Code::accepted;
break;
case 0x06: { // RADIO_BURST [channel u16 LE][power_quarter_db u8][mcs u8][count u16 LE][interval_ms u16 LE][payload_len u16 LE]
const auto channel = r.u16();
const auto power_quarter_db = r.u8();
const auto mcs = r.u8();
const auto count = r.u16();
const auto interval_ms = r.u16();
const auto payload_len = r.u16();
if (r.done()) {
const double power_dbm = power_quarter_db / 4.0;
result = station.test_radio_burst(channel, power_dbm, mcs, count, interval_ms, payload_len);
}
break;
}
case 0x07: { // CCA_SAMPLE [duration_ms u16 LE] -- CCA polling feasibility probe
const auto duration_ms = r.u16();
if (r.done()) result = station.test_cca_sample(duration_ms, extra);
break;
}
default:
result = Code::unknown_opcode;
}
}
link::Writer w;
w.u8(static_cast<std::uint8_t>(result));
w.u8(static_cast<std::uint8_t>(std::min<std::size_t>(records.size(), 255)));
for (const auto& record : records) {
w.u8(record.kind);
w.u16(static_cast<std::uint16_t>(record.bytes.size()));
w.bytes(record.bytes);
}
w.bytes(extra);
return w.out;
}
} // namespace microbu
#endif // CONFIG_MICROBU_TEST_CHANNEL
+15
View File
@@ -0,0 +1,15 @@
#pragma once
// Test channel (serial frame type 0x11, test firmware only): software lower tester and the
// diagnostic hooks the ETSI campaigns need. Not part of the phone interface, never on BLE.
#include "link_protocol.hpp"
#include "station.hpp"
namespace microbu {
/// @brief Executes one software-lower-tester request against the station.
/// @param station Station to execute the request against.
/// @param request Raw test-channel request bytes.
/// @return Reply bytes: [result][record count]{[kind][length u16 LE][bytes]}.
link::Bytes test_channel_execute(Station& station, const link::Bytes& request);
} // namespace microbu
+8
View File
@@ -0,0 +1,8 @@
# Name, Type, SubType, Offset, Size, Flags
# MicrOBU: NVS 80 KB instead of the colleague's 24 KB (their board has 4 MB of flash, this one 16 MB).
# NVS holds the BLE bond, the credential bundle and PHY calibration; at 24 KB, with Wi-Fi settings the
# previous firmware had left in it, there was no room for the bond, so the board forgot the phone
# after every connection. The app therefore moves from 0x10000 to 0x20000 (64 KB aligned).
nvs, data, nvs, 0x9000, 0x14000,
phy_init, data, phy, 0x1D000, 0x1000,
factory, app, factory, 0x20000, 0x300000,
1 # Name, Type, SubType, Offset, Size, Flags
2 # MicrOBU: NVS 80 KB instead of the colleague's 24 KB (their board has 4 MB of flash, this one 16 MB).
3 # NVS holds the BLE bond, the credential bundle and PHY calibration; at 24 KB, with Wi-Fi settings the
4 # previous firmware had left in it, there was no room for the bond, so the board forgot the phone
5 # after every connection. The app therefore moves from 0x10000 to 0x20000 (64 KB aligned).
6 nvs, data, nvs, 0x9000, 0x14000,
7 phy_init, data, phy, 0x1D000, 0x1000,
8 factory, app, factory, 0x20000, 0x300000,
+817 -986
View File
File diff suppressed because it is too large Load Diff
+53
View File
@@ -1 +1,54 @@
# obu-firmware defaults (ESP32-C5, ESP-IDF 6.0.2). Based on microbu-esp32c5/firmware/sdkconfig.defaults;
# the differences for this board are marked "MicrOBU:".
CONFIG_IDF_TARGET="esp32c5" CONFIG_IDF_TARGET="esp32c5"
CONFIG_COMPILER_CXX_EXCEPTIONS=y
CONFIG_COMPILER_CXX_RTTI=y
CONFIG_COMPILER_OPTIMIZATION_SIZE=y
CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192
# MicrOBU: the production board has 16 MB of flash (esptool reports it); the partition table
# only uses the first 4 MB.
CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y
CONFIG_PARTITION_TABLE_CUSTOM=y
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv"
# Network profile: BTP + GeoNetworking, no facilities codecs (the phone builds CAM/VAM)
CONFIG_VANETZA_IDF_PROFILE_NETWORK=y
CONFIG_VANETZA_IDF_SECURITY=y
CONFIG_VANETZA_IDF_SECURITY_VERIFY=y
CONFIG_VANETZA_IDF_NVS_CREDENTIALS=y
CONFIG_VANETZA_IDF_PKI=n
CONFIG_VANETZA_IDF_HIL=n
CONFIG_VANETZA_IDF_RADIO_C5=y
CONFIG_ESP_WIFI_STATIC_RX_BUFFER_NUM=6
CONFIG_ESP_WIFI_DYNAMIC_RX_BUFFER_NUM=16
CONFIG_ESP_WIFI_DYNAMIC_TX_BUFFER_NUM=16
CONFIG_ESP_WIFI_MGMT_SBUF_NUM=16
# MicrOBU: the console and ESP_LOG stay on UART0, i.e. the CH343 bridge port (COM3 on the bench),
# exactly as in the previous firmware. The native USB Serial/JTAG port belongs to the phone and
# carries only station-link frames. The colleague's board has only the native port and routes the
# console there instead.
CONFIG_ESP_CONSOLE_UART_DEFAULT=y
CONFIG_ESP_CONSOLE_SECONDARY_NONE=y
CONFIG_ESP_PANIC_HANDLER_IRAM=y
CONFIG_LOG_DEFAULT_LEVEL_INFO=y
CONFIG_LOG_COLORS=n
# MicrOBU: 20 dBm ceiling, as the previous firmware (the phone configures 20).
CONFIG_ESP_PHY_MAX_WIFI_TX_POWER=20
# Station-internal BLE GATT link: NimBLE, maximum ATT value, persistent bonds.
CONFIG_BT_ENABLED=y
CONFIG_BT_BLUEDROID_ENABLED=n
CONFIG_BT_NIMBLE_ENABLED=y
CONFIG_BT_NIMBLE_ATT_PREFERRED_MTU=517
CONFIG_BT_NIMBLE_SM_SC=y
CONFIG_BT_NIMBLE_NVS_PERSIST=y
CONFIG_BT_NIMBLE_MAX_CONNECTIONS=1
CONFIG_BT_NIMBLE_MAX_BONDS=1
CONFIG_BT_NIMBLE_MAX_CCCDS=8
CONFIG_BT_NIMBLE_MSYS_1_BLOCK_COUNT=12
CONFIG_BT_NIMBLE_MSYS_2_BLOCK_COUNT=12
CONFIG_BT_NIMBLE_ROLE_CENTRAL=n
CONFIG_BT_NIMBLE_ROLE_OBSERVER=n
# Hardware ECDSA/ECC/SHA
CONFIG_MBEDTLS_HARDWARE_ECC=y
CONFIG_MBEDTLS_HARDWARE_ECDSA_VERIFY=y
CONFIG_MBEDTLS_HARDWARE_SHA=y
CONFIG_MBEDTLS_HARDWARE_AES=n