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

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

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

Verified on the bench board (COM3) with the phone over USB and BLE: CAM and
VAM, signed and unsigned, go out; reception of the sim car and the RSU's
CAM/SPATEM/MAPEM continues; the board survives app restarts and reconnects.
See docs/06-signed-its-vam-ble.md.
This commit is contained in:
Ashin Walpola
2026-09-23 17:27:54 +02:00
parent 7285fa19b7
commit d2fd222a62
31 changed files with 4837 additions and 1022 deletions
+266
View File
@@ -0,0 +1,266 @@
#pragma once
// The ESP32-C5 half of the VRU ITS-S: BTP-B, GeoNetworking, the SN-SAP
// security entity with the station's provisioned credentials, and the ITS-G5 access adapter.
// Everything here runs on one task (the station task).
#include "c5_radio.hpp"
#include "link_protocol.hpp"
#include <vanetza_idf/stack.hpp>
#include <vanetza_idf/security.hpp>
#include <vanetza_idf/credentials.hpp>
#include <vanetza_idf/backend_mbedtls.hpp>
#include <vanetza/common/manual_runtime.hpp>
#include <vanetza/common/position_provider.hpp>
#include <deque>
#include <functional>
#include <map>
#include <memory>
#include <optional>
namespace microbu {
#if CONFIG_MICROBU_TEST_CHANNEL
/// Records the software lower tester collects (test channel, test firmware only).
struct TestRecord { std::uint8_t kind; link::Bytes bytes; };
#endif
/// @note All public members except the on_*() callback setters must be called from the station task.
class Station final : public vanetza_idf::Access, public vanetza::PositionProvider {
public:
using Indication = std::function<void(const link::BtpDataIndication&)>;
using IdEvent = std::function<void(const link::IdChangeEvent&)>;
using Disseminated = std::function<void()>;
using Received = std::function<void()>;
/// MicrOBU: body of one link V2X_RX message (see link_protocol.hpp).
using RawIts = std::function<void(const link::Bytes&)>;
/// @brief Constructs an unconfigured station (call configure() before use).
Station();
/// @brief Tears down the stack, security entity and radio in dependency order.
~Station() override;
// ---- link primitives (station task) ----
/// @brief (Re)builds the stack and security entity for a new station configuration.
/// @param config Requested configuration.
/// @param detail Receives the assigned GN address, identifier, and ticket count.
/// @return Result code.
link::Code configure(const link::StationConfigure& config, link::Bytes& detail);
/// @brief Applies a position/time fix from the phone and advances the ITS clock.
/// @param fix Position/time fix to apply.
/// @return Result code.
link::Code poti(const link::PotiUpdate& fix);
/// @brief Submits a BTP data request to the stack.
/// @param request Request to submit.
/// @return Result code.
link::Code btp_request(const link::BtpDataRequest& request);
/// @brief Decodes and stores a credential bundle, rebuilding the security entity if configured.
/// @param bundle Raw credential bundle bytes.
/// @param report Receives the applied root/authority/ticket counts.
/// @return Result code.
link::Code provision(const link::Bytes& bundle, link::ApplyReport& report);
/// @brief Erases stored credentials and rebuilds if security is configured.
/// @return Result code.
link::Code erase_credentials();
/// @brief Subscribes to pseudonym-change events.
/// @param subscriber_data Opaque data echoed back with events for this subscription.
/// @param subscription Receives the assigned subscription handle.
/// @return Result code.
link::Code subscribe(const link::Bytes& subscriber_data, std::uint64_t& subscription);
/// @brief Cancels a pseudonym-change subscription.
/// @param subscription Handle returned by subscribe().
/// @return Result code.
link::Code unsubscribe(std::uint64_t subscription);
/// @brief Resolves a pending PREPARE/COMMIT identity-change event.
/// @param subscription Handle the event was delivered for.
/// @param return_code Caller's response (accept/reject) to the pending event.
/// @return Result code.
link::Code event_response(std::uint64_t subscription, bool return_code);
/// @brief Triggers an immediate pseudonym change.
/// @return Result code.
link::Code trigger();
/// @brief Locks the current pseudonym for the given duration.
/// @param seconds Lock duration in seconds.
/// @param handle Receives the assigned lock handle.
/// @return Result code.
link::Code lock(std::uint8_t seconds, std::uint64_t& handle);
/// @brief Releases a pseudonym lock.
/// @param handle Handle returned by lock().
/// @return Result code.
link::Code unlock(std::uint64_t handle);
/// @return Current station status snapshot.
link::Status status();
/// @brief Advances the ITS clock, runs timers, polls the radio; call every few milliseconds.
void tick();
/// @brief Registers the callback invoked when a BTP data indication arrives.
/// @param f Callback to invoke; replaces any previously registered callback.
void on_indication(Indication f) { indication_ = std::move(f); }
/// @brief Registers the callback invoked when a pseudonym-change event fires.
/// @param f Callback to invoke; replaces any previously registered callback.
void on_id_event(IdEvent f) { id_event_ = std::move(f); }
/// @brief Registers the callback invoked after a frame is disseminated.
/// @param f Callback to invoke; replaces any previously registered callback.
void on_disseminated(Disseminated f) { disseminated_ = std::move(f); }
/// @brief Registers the callback invoked after a frame is received.
/// @param f Callback to invoke; replaces any previously registered callback.
void on_received(Received f) { received_ = std::move(f); }
/// @brief MicrOBU: registers the callback for every ITS message heard on air, unwrapped by
/// gn_unwrap.c before (and independently of) the stack's security checks. The stack drops
/// what it cannot verify (itsGnSnDecapResultHandling is STRICT in vanetza-idf): unsigned
/// traffic, and anything signed under a root other than the provisioned demo root, i.e. every
/// RSU. This path is how those still reach the phone, exactly as with the previous firmware.
/// @param f Callback to invoke with a V2X_RX body; replaces any previously registered callback.
void on_raw_its(RawIts f) { raw_its_ = std::move(f); }
#if CONFIG_MICROBU_TEST_CHANNEL
// ---- test channel (software lower tester; not part of the phone interface -- see test_channel.hpp) ----
enum class Mirror : std::uint8_t { off = 0, mirror = 1, divert = 2 };
/// @brief Sets whether requests/indications are mirrored to, or diverted through, the test channel.
/// @param mode Off, mirror (copy) or divert (intercept) mode.
void test_mirror(Mirror mode);
/// @brief Injects a raw GN PDU as if received over the air.
/// @param source Source MAC address to report for the injected frame.
/// @param destination Destination MAC address to report for the injected frame.
/// @param gnpdu Raw GeoNetworking PDU bytes.
/// @return Result code.
link::Code test_inject(const vanetza::MacAddress& source, const vanetza::MacAddress& destination, link::Bytes gnpdu);
/// @brief Submits a raw GeoNetworking request bypassing BTP.
/// @param traffic_class GeoNetworking traffic class to submit with.
/// @param payload Raw payload bytes.
/// @return Result code.
link::Code test_gn_request(std::uint8_t traffic_class, link::Bytes payload);
/// @brief Pops queued mirror/divert records fitting into budget octets (3 per record header); the rest stays queued.
/// @param overflow Receives true if records were dropped because the queue budget was exceeded.
/// @param budget Maximum number of octets of records to drain.
/// @return Drained records.
std::deque<TestRecord> test_drain(bool& overflow, std::size_t budget);
/// @brief Clears mirror mode and any queued records.
void test_reset();
/// @brief Starts the radio if needed and sends a burst of raw test frames.
/// @param channel ITS-G5 channel number to transmit on.
/// @param power_dbm Transmit power in dBm.
/// @param mcs 802.11p modulation and coding scheme index.
/// @param count Number of frames to send.
/// @param interval_ms Interval between frames in milliseconds.
/// @param payload_len Length of each frame's payload in bytes.
/// @return Result code.
link::Code test_radio_burst(std::uint16_t channel, double power_dbm, unsigned mcs,
unsigned count, unsigned interval_ms, std::size_t payload_len);
/// @brief Starts the radio if needed and samples the CCA state for duration_ms.
/// @param duration_ms Sampling window duration in milliseconds.
/// @param detail Receives the sampling statistics.
/// @return Result code.
link::Code test_cca_sample(unsigned duration_ms, link::Bytes& detail);
#endif
// vanetza_idf::Access
/// @brief Submits a frame to the radio (through DCC_ACC when enabled), mirroring/diverting for the test channel.
/// @param request Frame and transmit parameters from the access layer.
/// @return Result code.
vanetza_idf::Result request(vanetza_idf::AlDataRequest request) override;
// vanetza::PositionProvider
/// @return The most recently applied position fix.
const vanetza::PositionFix& position_fix() override { return fix_; }
private:
struct Security;
struct Clock {
bool synchronised = false;
std::int64_t base_its_us = 0; // ITS time at base_esp_us
std::int64_t base_esp_us = 0;
std::int64_t now_us() const;
};
/// @brief (Re)builds the stack and security entity from config_, stored credentials and the clock.
/// @return Result code.
link::Code build();
/// @brief Destroys the stack, security entity and runtime in dependency order.
void teardown();
/// @brief Maps a station configuration onto the GeoNetworking MIB.
/// @param mib MIB to populate.
/// @param config Station configuration to map from.
void apply_mib(vanetza_idf::StackConfig& mib, const link::StationConfigure& config) const;
/// @brief Re-subscribes existing pseudonym-change handles to a freshly built security entity.
void resubscribe_id_change();
/// @brief Pushes the current position fix into the stack, clamped to not precede the station clock.
void apply_position();
/// @brief Samples the hardware CCA counters at the DCC cadence and feeds both DCC entities.
void sample_dcc_channel_load();
#if CONFIG_MICROBU_TEST_CHANNEL
/// @brief Queues bytes for the test channel, subject to the mirror-buffer budget.
/// @param kind Record kind tag.
/// @param bytes Record payload bytes.
void record(std::uint8_t kind, link::Bytes bytes);
#endif
/// @brief MicrOBU: unwraps one raw received frame with gn_unwrap.c and hands it to raw_its_.
/// @param frame Complete 802.11 frame as captured (FCS included; gn_unwrap reads declared lengths).
/// @param rssi Received signal strength, dBm.
void forward_raw(const vanetza::ByteBuffer& frame, int rssi);
/// @brief MicrOBU: transmits an unsecured BTP-B/SHB request the way the previous firmware did,
/// with geonet.c's GN header and the Source Position Vector of the last PoTi, through request().
/// @param request The phone's request (already checked: configured, clock and fix present).
/// @return Result code.
link::Code unsecured_request(const link::BtpDataRequest& request);
/// @brief Translates and forwards a stack BTP indication to the link service and test channel.
/// @param indication Indication received from the stack.
void deliver(vanetza_idf::BtpIndication indication);
link::Status counters_;
std::optional<link::StationConfigure> config_;
Clock clock_;
vanetza::PositionFix fix_;
bool have_fix_ = false;
link::PotiUpdate last_poti_; // MicrOBU: the GN Source Position Vector of unsecured_request()
std::uint32_t raw_forwarded_ = 0, raw_oversize_ = 0;
std::unique_ptr<vanetza::ManualRuntime> runtime_;
std::unique_ptr<Security> security_;
std::unique_ptr<vanetza_idf::Stack> stack_;
std::unique_ptr<C5Radio> radio_;
std::unique_ptr<vanetza_idf::AccessStack> access_stack_; // DCC_ACC gate in front of radio_
vanetza_idf::AlDataRequest radio_parameters_;
std::optional<CcaCounters> dcc_cca_last_; // previous read_cca_counters() sample, for the 100 ms LCBR delta
std::int64_t dcc_last_sample_its_us_ = 0;
Indication indication_;
IdEvent id_event_;
Disseminated disseminated_;
Received received_;
RawIts raw_its_;
std::map<std::uint64_t, std::shared_ptr<vanetza_idf::security::IdChangeResponder>> pending_responders_;
std::map<std::uint64_t, std::uint64_t> link_subscriptions_; // subscription -> service handle (identity)
bool rebuilding_ = false;
#if CONFIG_MICROBU_TEST_CHANNEL
Mirror mirror_ = Mirror::off;
std::deque<TestRecord> records_;
std::size_t record_bytes_ = 0;
bool overflow_ = false;
#endif
};
} // namespace microbu