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:
@@ -1,18 +1,22 @@
|
||||
# wifi_patches.c is intentionally NOT in this list anymore - superseded by
|
||||
# tx_custom.c (see that file for why). Left on disk, unused, for history.
|
||||
# C++ station (from microbu-esp32c5/firmware/main) plus the C files of the previous firmware that
|
||||
# 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
|
||||
# phone and sent down over serial_link, so this firmware never encodes CAM itself. cam.c/.h are
|
||||
# left on disk as the byte-exact reference the Kotlin encoder was ported from - do not delete.
|
||||
#
|
||||
# serial_link.c/.h - binary phone<->ESP32 framing over the native USB Serial/JTAG port
|
||||
# (Phase 03; changed from a GPIO UART1 wire to native USB because neither
|
||||
# USB-C port on the ESP32-C5-WIFI6-KIT was actually routed to that UART).
|
||||
# gn_unwrap.c/.h - strips 802.11/LLC-SNAP/GeoNetworking/BTP-B off received frames down to CAM
|
||||
# UPER bytes, for forwarding to the phone over serial_link.
|
||||
idf_component_register(
|
||||
SRCS "main.c" "denm.c" "geonet.c" "dot11p.c" "tx_custom.c" "serial_link.c" "gn_unwrap.c"
|
||||
INCLUDE_DIRS "."
|
||||
REQUIRES esp_event esp_netif nvs_flash driver esp_phy esp_driver_gpio esp_driver_usb_serial_jtag
|
||||
PRIV_REQUIRES esp_wifi
|
||||
)
|
||||
# Left on disk and NOT built, like cam.c before them:
|
||||
# main.c, serial_link.c/.h - the previous firmware's entry point and phone link (frame types
|
||||
# 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.
|
||||
# Still compiled by the host tests in test/host.
|
||||
# tx_custom.c/.h - previous copy of the OpenTrafficMap raw TX; otm_tx_custom.c replaces it.
|
||||
# denm.c/.h, cam.c/.h, wifi_patches.c - reference only, as before.
|
||||
idf_component_register(SRCS "app_main.cpp" "board_controls.cpp" "simple_ble.cpp" "c5_radio.cpp" "otm_tx_custom.c"
|
||||
"link_protocol.cpp" "serial_link.cpp" "station.cpp" "station_test.cpp"
|
||||
"link_service.cpp" "test_channel.cpp"
|
||||
"gn_unwrap.c" "geonet.c"
|
||||
INCLUDE_DIRS "."
|
||||
LDFRAGMENTS "linker.lf"
|
||||
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
|
||||
Reference in New Issue
Block a user