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:
+13
@@ -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/
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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, ©, 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, ©, 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);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
[mapping:newlib_memcmp]
|
||||||
|
archive: libnewlib.a
|
||||||
|
entries:
|
||||||
|
memcmp (noflash)
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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, ¶ms);
|
||||||
|
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,
|
||||||
|
¶ms, 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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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,
|
||||||
|
+817
-986
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user