From 919065f37745b301145f0af8409538c295d140a8 Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Mon, 11 May 2026 23:52:35 +0200 Subject: [PATCH 01/49] feat: Add L76K GNSS --- Cargo.lock | 69 ++++++++++++++++++++++++++ Cargo.toml | 2 + src/main.rs | 140 +++++++++++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 204 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 212dbff..3810bc6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,12 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c583acf993cf4245c4acb0a2cc2ab1f9cc097de73411bb6d3647ff6af2b1013d" +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + [[package]] name = "autocfg" version = "1.5.0" @@ -92,6 +98,15 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "num-traits", +] + [[package]] name = "const-default" version = "1.0.0" @@ -425,6 +440,21 @@ dependencies = [ "heapless 0.9.2", ] +[[package]] +name = "embassy_gps" +version = "0.1.0" +source = "git+https://github.com/jbeyerstedt/embassy_gps.git?branch=feat%2Frework-gpsfix#75f1a67a69372234330c0c9618a953c415380b99" +dependencies = [ + "chrono", + "embassy-futures", + "embassy-time", + "embedded-io-async 0.7.0", + "esp-hal", + "heapless 0.9.2", + "log", + "nmea", +] + [[package]] name = "embedded-can" version = "0.4.1" @@ -903,9 +933,11 @@ dependencies = [ name = "esp32-c_its-companion" version = "0.1.0" dependencies = [ + "chrono", "critical-section", "embassy-executor", "embassy-time", + "embassy_gps", "embedded-io 0.7.1", "embedded-io-async 0.7.0", "esp-alloc", @@ -1183,6 +1215,12 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "linked_list_allocator" version = "0.10.6" @@ -1229,6 +1267,12 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "nb" version = "0.1.3" @@ -1244,6 +1288,30 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8d5439c4ad607c3c23abf66de8c8bf57ba8adcd1f129e699851a6e43935d339d" +[[package]] +name = "nmea" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2086c773d18da556c05ca235596d163c00379027189bafe2209ae40ebd19717c" +dependencies = [ + "arrayvec", + "cfg-if", + "chrono", + "heapless 0.8.0", + "nom", + "num-traits", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1271,6 +1339,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index ba3f56e..0f12b56 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,6 +43,8 @@ esp-radio = { version = "0.18.0", features = [ "sniffer", ] } +embassy_gps = { version = "0.1.0", default-features = false, features = ["esp", "log-04"], git = "https://github.com/jbeyerstedt/embassy_gps.git", branch = "feat/rework-gpsfix" } +chrono = { version = "0.4.44", default-features = false, features = ["alloc"] } # For fine tuning these settings, please refer to https://doc.rust-lang.org/cargo/reference/profiles.html [profile.dev] diff --git a/src/main.rs b/src/main.rs index 6c740ad..fe4d827 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,10 +5,13 @@ reason = "mem::forget is generally not safe to do with esp_hal types, especially those \ holding buffers for the duration of a data transfer." )] -#![deny(clippy::large_stack_frames)] +// #![deny(clippy::large_stack_frames)] +use core::cell::RefCell; + +use critical_section::Mutex; use embassy_executor::Spawner; -use embassy_time::{Duration, Timer}; +use embassy_gps::gps::l76k; use esp_backtrace as _; use esp_hal::clock::CpuClock; use esp_hal::timer::timg::TimerGroup; @@ -21,10 +24,68 @@ mod radio; const WIFI_CHANNEL: radio::Channel = 180; +#[derive(Debug, Default)] +struct State { + initialized: bool, + time: chrono::NaiveDateTime, + + lat_deg: f32, + lon_deg: f32, + heading_deg: Option, + speed_mps: Option, +} + +impl State { + fn update_with_gpsfix(&mut self, fix: &embassy_gps::types::GpsFix) { + #[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)] + let lat_deg = fix.latitude_deg as f32; + #[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)] + let lon_deg = fix.longitude_deg as f32; + + self.lat_deg = lat_deg; + self.lon_deg = lon_deg; + + if let Some(heading) = fix.true_course_deg { + self.heading_deg = Some(heading); + } else { + warn!("State: Course missing from GpsFix"); + } + + if let Some(speed) = fix.speed_over_ground { + self.speed_mps = Some(speed); + } else { + warn!("State: Speed missing from GpsFix"); + } + + if let Some(time) = fix.get_timestamp() { + self.time = time; + + if !self.initialized { + info!("GNSS fix: {}", self.print_fix()); + } + self.initialized = true; + } + } + + fn print_fix(&self) -> alloc::string::String { + alloc::format!( + "{:?}, {:.4} N, {:.4} E, {:.0}°, {:.2} m/s", + self.time, + self.lat_deg, + self.lon_deg, + self.heading_deg.unwrap_or_default(), + self.speed_mps.unwrap_or_default(), + ) + } +} + // This creates a default app-descriptor required by the esp-idf bootloader. // For more information see: esp_bootloader_esp_idf::esp_app_desc!(); +static GNSS_UPDATE: Mutex>> = + Mutex::new(RefCell::new(None)); + #[allow( clippy::large_stack_frames, reason = "it's not unusual to allocate larger buffers etc. in main" @@ -70,13 +131,39 @@ async fn main(spawner: Spawner) -> ! { info!("WiFi promiscuous mode on channel {WIFI_CHANNEL} running"); - // TODO: Spawn some tasks - let _ = spawner; + // Seeed XIAO L67K: + // RX: D7/ GPIO12 + // TX: D6/ GPIO11 + // WAKEUP: D0/ GPIO1 -> HIGH for active, LOW for Sleep + // RESET: D2/ GPIO25 -> HIGH for normal, LOW for Reset + spawner.spawn( + gnss_task( + peripherals.GPIO1, + peripherals.GPIO25, + peripherals.UART0, + peripherals.GPIO12, + peripherals.GPIO11, + ) + .expect("Failed to spawn GNSS task"), + ); + info!("GNSS task started, waiting for GNSS fix..."); + let mut state = State::default(); loop { - // nothing to do here, all data is handled in RX callback for now - // but use a timer so that this is yields to other tasks - Timer::after(Duration::from_secs(1)).await; + embassy_time::Timer::after(embassy_time::Duration::from_millis(100)).await; + + critical_section::with(|cs| { + let gnss_update_ref = GNSS_UPDATE.borrow(cs); + + if let Some(fix) = gnss_update_ref.replace(None) { + state.update_with_gpsfix(&fix); + + if let Some(time) = fix.get_timestamp() { + state.time = time; + info!("{}", state.print_fix()); // TODO: degrate to debug later? + } + } + }); } } @@ -99,3 +186,42 @@ fn handle_frame(frame: wifi::sniffer::PromiscuousPkt<'_>) { // TODO: handle 802.11 frame info!("Received frame with {} bytes", frame.len); } + +#[allow(clippy::large_stack_frames, reason = "GNSS driver needs some space")] +#[embassy_executor::task] +async fn gnss_task( + gps_wakeup: esp_hal::peripherals::GPIO1<'static>, + gps_reset: esp_hal::peripherals::GPIO25<'static>, + uart: esp_hal::peripherals::UART0<'static>, + uart_rx: esp_hal::peripherals::GPIO12<'static>, + uart_tx: esp_hal::peripherals::GPIO11<'static>, +) { + use embassy_gps::gps::GpsFsm; + + let mut gps = l76k::esp::L76kFsm::new_sep( + l76k::esp::GpsHw { + reinit: gps_reset, + standby: gps_wakeup, + }, + || { + esp_hal::uart::Uart::new(uart, esp_hal::uart::Config::default().with_baudrate(9600)) + .expect("Failed to create UART for GNSS") + .with_rx(uart_rx) + .with_tx(uart_tx) + .into_async() + }, + ) + .await; + + loop { + if let Ok(Some(embassy_gps::types::GpsEvent::Fix(fix))) = gps.step().await { + // send to main thread + critical_section::with(|cs| { + let gnss_update_ref = GNSS_UPDATE.borrow(cs); + gnss_update_ref.replace(Some(fix)); + }); + } else { + // FSM will recover automatically from errors and ignore other event types + } + } +} From 9bf8b8e4de620c8cf818daa8a015f6d3b84b4e6b Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Sat, 25 Apr 2026 16:39:39 +0200 Subject: [PATCH 02/49] feat: Add some message parsing --- Cargo.lock | 582 ++++++++++++++++++++++++++++++++++++++++++- Cargo.toml | 19 ++ src/applogic/cam.rs | 65 +++++ src/applogic/denm.rs | 182 ++++++++++++++ src/applogic/mod.rs | 8 + src/applogic/tlc.rs | 51 ++++ src/main.rs | 103 +++++++- src/radio.rs | 2 +- 8 files changed, 1006 insertions(+), 6 deletions(-) create mode 100644 src/applogic/cam.rs create mode 100644 src/applogic/denm.rs create mode 100644 src/applogic/mod.rs create mode 100644 src/applogic/tlc.rs diff --git a/Cargo.lock b/Cargo.lock index 3810bc6..6ef3d5d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,12 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c583acf993cf4245c4acb0a2cc2ab1f9cc097de73411bb6d3647ff6af2b1013d" +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + [[package]] name = "arrayvec" version = "0.7.6" @@ -61,6 +67,28 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "bitvec-nom2" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d988fcc40055ceaa85edc55875a08f8abd29018582647fd82ad6128dba14a5f0" +dependencies = [ + "bitvec", + "nom", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -70,6 +98,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + [[package]] name = "bytemuck" version = "1.25.0" @@ -82,6 +116,29 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "c-its-parser" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d343eab948bdc3745a9abd17ccc33fe3a0df236888bc460fe9655043fc853581" +dependencies = [ + "chrono", + "etherparse", + "geo-types", + "geonetworking", + "hex", + "nom", + "num-traits", + "rasn", + "wasm-bindgen", +] + [[package]] name = "cc" version = "1.2.62" @@ -129,6 +186,21 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "crypto-common" version = "0.1.7" @@ -292,6 +364,12 @@ dependencies = [ "litrs", ] +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "embassy-embedded-hal" version = "0.6.0" @@ -933,8 +1011,10 @@ dependencies = [ name = "esp32-c_its-companion" version = "0.1.0" dependencies = [ + "c-its-parser", "chrono", "critical-section", + "crossbeam-queue", "embassy-executor", "embassy-time", "embassy_gps", @@ -1013,6 +1093,15 @@ dependencies = [ "vcell", ] +[[package]] +name = "etherparse" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ac016aaf11dfe643edcd088a166234bfcb72e7f06691abfcf21e9af524037f4" +dependencies = [ + "arrayvec", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1025,6 +1114,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "fugit" version = "0.3.9" @@ -1034,6 +1129,12 @@ dependencies = [ "gcd", ] +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + [[package]] name = "futures-core" version = "0.3.32" @@ -1094,6 +1195,42 @@ dependencies = [ "version_check", ] +[[package]] +name = "geo-types" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94776032c45f950d30a13af6113c2ad5625316c9abfbccee4dd5a6695f8fe0f5" +dependencies = [ + "num-traits", +] + +[[package]] +name = "geonetworking" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea4f48e7b1cdf91ad77ec1e4c5617aee3666a929288f4a3f11e473b67d3a6314" +dependencies = [ + "bitvec", + "bitvec-nom2", + "bytes", + "nom", + "num", + "num-traits", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + [[package]] name = "hash32" version = "0.3.1" @@ -1103,6 +1240,15 @@ dependencies = [ "byteorder", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + [[package]] name = "hashbrown" version = "0.17.0" @@ -1135,6 +1281,18 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ident_case" version = "1.0.1" @@ -1148,7 +1306,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.0", + "serde", + "serde_core", ] [[package]] @@ -1173,6 +1333,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -1209,6 +1378,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.186" @@ -1321,6 +1496,38 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-derive" version = "0.4.2" @@ -1332,6 +1539,36 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1405,6 +1642,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -1432,6 +1679,18 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + [[package]] name = "rand_core" version = "0.6.4" @@ -1450,6 +1709,54 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rasn" +version = "0.28.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88235e585f2f3fc26ad809c79a84dd77e6a7203eb7d12e5bcaab89fc036d3d90" +dependencies = [ + "bitvec", + "bitvec-nom2", + "bytes", + "cfg-if", + "chrono", + "either", + "nom", + "num-bigint", + "num-integer", + "num-traits", + "once_cell", + "rasn-derive", + "serde_json", + "snafu", + "xml-no-std", +] + +[[package]] +name = "rasn-derive" +version = "0.28.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11dcd4dab09ceadedb3e2bedf340deb583f0e25bba9ad966b5655c2fb62b1392" +dependencies = [ + "proc-macro2", + "rasn-derive-impl", + "syn 2.0.117", +] + +[[package]] +name = "rasn-derive-impl" +version = "0.28.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f121c276d3f2fba8caabc870288de3f81abdb0bb94d5510651e58e1e00a7b9e7" +dependencies = [ + "either", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.117", + "uuid", +] + [[package]] name = "regex-automata" version = "0.4.14" @@ -1557,6 +1864,12 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.228" @@ -1587,6 +1900,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "serde_yaml" version = "0.9.34+deprecated" @@ -1621,6 +1947,27 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "snafu" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e84b3f4eacbf3a1ce05eac6763b4d629d60cbc94d632e4092c54ade71f1e1a2" +dependencies = [ + "snafu-derive", +] + +[[package]] +name = "snafu-derive" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "somni-expr" version = "0.2.0" @@ -1704,6 +2051,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + [[package]] name = "termcolor" version = "1.4.1" @@ -1837,12 +2190,27 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "unsafe-libyaml" version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom", +] + [[package]] name = "valuable" version = "0.1.1" @@ -1867,6 +2235,103 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "winapi-util" version = "0.1.11" @@ -1909,6 +2374,115 @@ dependencies = [ "memchr", ] +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "xml-no-std" +version = "0.8.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd223bc94c615fc02bf2f4bbc22a4a9bfe489c2add3ec10b1038df3aca44cac7" + [[package]] name = "xtensa-lx" version = "0.13.0" @@ -1939,3 +2513,9 @@ dependencies = [ "quote", "syn 2.0.117", ] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index 0f12b56..6bd60e9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,17 @@ version = "0.1.0" name = "esp32-c_its-companion" path = "./src/main.rs" +[features] +default = ["spat"] + +# echo CAM data on serial +cam = ["c-its-parser/cam"] +# echo DENM events on serial and BLE +denm = ["c-its-parser/denm"] +# evaluate SPAT information +spat = ["c-its-parser/spatem", "c-its-parser/mapem"] + + [dependencies] esp-hal = { version = "~1.1.0", features = ["esp32c5", "log-04", "unstable"] } @@ -44,7 +55,15 @@ esp-radio = { version = "0.18.0", features = [ ] } embassy_gps = { version = "0.1.0", default-features = false, features = ["esp", "log-04"], git = "https://github.com/jbeyerstedt/embassy_gps.git", branch = "feat/rework-gpsfix" } +c-its-parser = { version = "2.2.1", default-features = false, features = [ + "time", + "geo", + "libm", + "transport", +] } chrono = { version = "0.4.44", default-features = false, features = ["alloc"] } +crossbeam-queue = { version = "0.3.12", default-features = false, features = ["alloc"] } + # For fine tuning these settings, please refer to https://doc.rust-lang.org/cargo/reference/profiles.html [profile.dev] diff --git a/src/applogic/cam.rs b/src/applogic/cam.rs new file mode 100644 index 0000000..cef3a91 --- /dev/null +++ b/src/applogic/cam.rs @@ -0,0 +1,65 @@ +//! C-ITS CAM Evaluation + +use c_its_parser::standards::cam_1_4_1::cam_pdu_descriptions::{self, HighFrequencyContainer}; +use log::info; + +pub fn handle_cam(etsi: &cam_pdu_descriptions::CAM) { + // only POC output + match &etsi.cam.cam_parameters.high_frequency_container { + HighFrequencyContainer::basicVehicleContainerHighFrequency(obu) => { + let station_id = etsi.header.station_id.0; + let lat_deg = etsi + .cam + .cam_parameters + .basic_container + .reference_position + .latitude + .as_deg(); + let lon_deg = etsi + .cam + .cam_parameters + .basic_container + .reference_position + .longitude + .as_deg(); + + if obu.speed.speed_value.is_unavailable() { + info!( + "Got CAM from {station_id} at {lat_deg:.3}°N, {lon_deg:.3}°E, speed unavailable", + ); + } else { + info!( + "Got CAM from {station_id} at {lat_deg:.3}°N, {lon_deg:.3}°E, speed {:.2} km/h", + obu.speed.speed_value.as_kmh() + ); + } + } + HighFrequencyContainer::rsuContainerHighFrequency(rsu) => { + info!( + "Got RSU from {} at {:.3}°N, {:.3}°E, {} protected zones", + etsi.header.station_id.0, + etsi.cam + .cam_parameters + .basic_container + .reference_position + .latitude + .as_deg(), + etsi.cam + .cam_parameters + .basic_container + .reference_position + .longitude + .as_deg(), + rsu.protected_communication_zones_rsu + .as_ref() + .map(|d| d.0.len()) + .unwrap_or_default() + ); + } + + _ => { + // enum is non-exhaustive, so we need a default case + // but this should never be reached in pactical applications + } + } +} diff --git a/src/applogic/denm.rs b/src/applogic/denm.rs new file mode 100644 index 0000000..6ecd3c8 --- /dev/null +++ b/src/applogic/denm.rs @@ -0,0 +1,182 @@ +//! C-ITS DENM Evaluation + +use c_its_parser::standards::cdd_2_2_1::etsi_its_cdd; +use c_its_parser::standards::denm_2_2_1::denm_pdu_description; +use log::info; + +pub fn handle_denm(etsi: &denm_pdu_description::DENM) { + let mgmt = &etsi.denm.management; + + if mgmt.termination.is_some() { + return; + } + + if let (Some(sit), Some(loc)) = (&etsi.denm.situation, &etsi.denm.location) { + // let timestamp: chrono::DateTime = mgmt.reference_time.clone().into(); + let end_time = chrono::DateTime::::from(mgmt.reference_time.clone()) + + chrono::Duration::seconds(mgmt.validity_duration.0.into()); + let station_id = &mgmt.action_id.originating_station_id.0; + let seq_num = mgmt.action_id.sequence_number.0; + + let lat_deg = mgmt.event_position.latitude.as_deg(); + let lon_deg = mgmt.event_position.longitude.as_deg(); + + // TODO: use `as_deg()` when in new c-its-parser release + let heading = loc + .event_position_heading + .as_ref() + .map(|v| f32::from(v.value.0) / 10f32); + + // let direction = mgmt.traffic_direction; + + let (cc, scc) = cause_code_choice_to_numbers(&sit.event_type.cc_and_scc); + + if let Some(hdg) = heading { + info!( + "DENN: {station_id}/{seq_num} CC {cc}/{scc} at {lat_deg:.3}°N, {lon_deg:.3}°E, hdg {hdg:.1}° until {end_time:?}" + ) + } else { + info!( + "DENN: {station_id}/{seq_num} CC {cc}/{scc} at {lat_deg:.3}°N, {lon_deg:.3}°E until {end_time:?}" + ) + } + } else { + info!("DENM: One of situation or location container not available"); + } +} + +fn cause_code_choice_to_numbers(choice: &etsi_its_cdd::CauseCodeChoice) -> (u8, u8) { + match choice { + etsi_its_cdd::CauseCodeChoice::reserved0(scc) => (0, scc.0), + etsi_its_cdd::CauseCodeChoice::trafficCondition1(scc) => (1, scc.0), + etsi_its_cdd::CauseCodeChoice::accident2(scc) => (2, scc.0), + etsi_its_cdd::CauseCodeChoice::roadworks3(scc) => (3, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved4(scc) => (4, scc.0), + etsi_its_cdd::CauseCodeChoice::impassability5(scc) => (5, scc.0), + etsi_its_cdd::CauseCodeChoice::adverseWeatherCondition_Adhesion6(scc) => (6, scc.0), + etsi_its_cdd::CauseCodeChoice::aquaplaning7(scc) => (7, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved8(scc) => (8, scc.0), + etsi_its_cdd::CauseCodeChoice::hazardousLocation_SurfaceCondition9(scc) => (9, scc.0), + etsi_its_cdd::CauseCodeChoice::hazardousLocation_ObstacleOnTheRoad10(scc) => (10, scc.0), + etsi_its_cdd::CauseCodeChoice::hazardousLocation_AnimalOnTheRoad11(scc) => (11, scc.0), + etsi_its_cdd::CauseCodeChoice::humanPresenceOnTheRoad12(scc) => (12, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved13(scc) => (13, scc.0), + etsi_its_cdd::CauseCodeChoice::wrongWayDriving14(scc) => (14, scc.0), + etsi_its_cdd::CauseCodeChoice::rescueAndRecoveryWorkInProgress15(scc) => (15, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved16(scc) => (16, scc.0), + etsi_its_cdd::CauseCodeChoice::adverseWeatherCondition_ExtremeWeatherCondition17(scc) => { + (17, scc.0) + } + etsi_its_cdd::CauseCodeChoice::adverseWeatherCondition_Visibility18(scc) => (18, scc.0), + etsi_its_cdd::CauseCodeChoice::adverseWeatherCondition_Precipitation19(scc) => (19, scc.0), + etsi_its_cdd::CauseCodeChoice::violence20(scc) => (20, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved21(scc) => (21, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved22(scc) => (22, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved23(scc) => (23, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved24(scc) => (24, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved25(scc) => (25, scc.0), + etsi_its_cdd::CauseCodeChoice::slowVehicle26(scc) => (26, scc.0), + etsi_its_cdd::CauseCodeChoice::dangerousEndOfQueue27(scc) => (27, scc.0), + etsi_its_cdd::CauseCodeChoice::publicTransportVehicleApproaching28(scc) => (28, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved29(scc) => (29, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved30(scc) => (30, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved31(scc) => (31, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved32(scc) => (32, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved33(scc) => (33, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved34(scc) => (34, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved35(scc) => (35, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved36(scc) => (36, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved37(scc) => (37, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved38(scc) => (38, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved39(scc) => (39, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved40(scc) => (40, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved41(scc) => (41, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved42(scc) => (42, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved43(scc) => (43, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved44(scc) => (44, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved45(scc) => (45, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved46(scc) => (46, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved47(scc) => (47, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved48(scc) => (48, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved49(scc) => (49, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved50(scc) => (50, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved51(scc) => (51, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved52(scc) => (52, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved53(scc) => (53, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved54(scc) => (54, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved55(scc) => (55, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved56(scc) => (56, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved57(scc) => (57, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved58(scc) => (58, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved59(scc) => (59, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved60(scc) => (60, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved61(scc) => (61, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved62(scc) => (62, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved63(scc) => (63, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved64(scc) => (64, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved65(scc) => (65, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved66(scc) => (66, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved67(scc) => (67, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved68(scc) => (68, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved69(scc) => (69, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved70(scc) => (70, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved71(scc) => (71, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved72(scc) => (72, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved73(scc) => (73, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved74(scc) => (74, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved75(scc) => (75, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved76(scc) => (76, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved77(scc) => (77, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved78(scc) => (78, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved79(scc) => (79, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved80(scc) => (80, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved81(scc) => (81, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved82(scc) => (82, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved83(scc) => (83, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved84(scc) => (84, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved85(scc) => (85, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved86(scc) => (86, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved87(scc) => (87, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved88(scc) => (88, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved89(scc) => (89, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved90(scc) => (90, scc.0), + etsi_its_cdd::CauseCodeChoice::vehicleBreakdown91(scc) => (91, scc.0), + etsi_its_cdd::CauseCodeChoice::postCrash92(scc) => (92, scc.0), + etsi_its_cdd::CauseCodeChoice::humanProblem93(scc) => (93, scc.0), + etsi_its_cdd::CauseCodeChoice::stationaryVehicle94(scc) => (94, scc.0), + etsi_its_cdd::CauseCodeChoice::emergencyVehicleApproaching95(scc) => (95, scc.0), + etsi_its_cdd::CauseCodeChoice::hazardousLocation_DangerousCurve96(scc) => (96, scc.0), + etsi_its_cdd::CauseCodeChoice::collisionRisk97(scc) => (97, scc.0), + etsi_its_cdd::CauseCodeChoice::signalViolation98(scc) => (98, scc.0), + etsi_its_cdd::CauseCodeChoice::dangerousSituation99(scc) => (99, scc.0), + etsi_its_cdd::CauseCodeChoice::railwayLevelCrossing100(scc) => (100, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved101(scc) => (101, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved102(scc) => (102, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved103(scc) => (103, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved104(scc) => (104, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved105(scc) => (105, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved106(scc) => (106, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved107(scc) => (107, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved108(scc) => (108, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved109(scc) => (109, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved110(scc) => (110, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved111(scc) => (111, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved112(scc) => (112, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved113(scc) => (113, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved114(scc) => (114, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved115(scc) => (115, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved116(scc) => (116, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved117(scc) => (117, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved118(scc) => (118, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved119(scc) => (119, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved120(scc) => (120, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved121(scc) => (121, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved122(scc) => (122, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved123(scc) => (123, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved124(scc) => (124, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved125(scc) => (125, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved126(scc) => (126, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved127(scc) => (127, scc.0), + etsi_its_cdd::CauseCodeChoice::reserved128(scc) => (128, scc.0), + } +} diff --git a/src/applogic/mod.rs b/src/applogic/mod.rs new file mode 100644 index 0000000..1e61aa8 --- /dev/null +++ b/src/applogic/mod.rs @@ -0,0 +1,8 @@ +//! Application Logic + +#[cfg(feature = "cam")] +pub mod cam; +#[cfg(feature = "denm")] +pub mod denm; +#[cfg(feature = "spat")] +pub mod tlc; diff --git a/src/applogic/tlc.rs b/src/applogic/tlc.rs new file mode 100644 index 0000000..ed03922 --- /dev/null +++ b/src/applogic/tlc.rs @@ -0,0 +1,51 @@ +//! C-ITS Traffic Light Data (MAPEM/ SPATEM) Evaluation + +use c_its_parser::standards::mapem_2_2_1::mapem_pdu_descriptions; +use c_its_parser::standards::spatem_2_2_1::spatem_pdu_descriptions; +use chrono::Datelike; +use esp_println::println; + +pub fn handle_mapem(etsi: &mapem_pdu_descriptions::MAPEM) { + // TODO: proof of concept only -- replace with actual evaluation + if let Some(map) = &etsi.map.intersections { + for int in &map.0 { + println!("got MAPEM for intersection {}", int.id.id.0); + } + } +} + +pub fn handle_spatem(etsi: &spatem_pdu_descriptions::SPATEM, now: &chrono::NaiveDateTime) { + // TODO: proof of concept only -- replace with actual evaluation + for int in &etsi.spat.intersections.0 { + println!("got SPATEM for intersection {}", int.id.id.0); + + if let (Some(moy), Some(second)) = (&int.moy, &int.time_stamp) { + let year = now.year(); + + let ref_time = c_its_parser::time_utils::time_from_moy_and_dsecond(moy, second, year); + println!("- ref time: {ref_time:?}"); + + for state in &int.states.0 { + let sig_grp = state.signal_group.0; + + if let Some(event) = state.state_time_speed.0.first() { + if let Some(timing) = &event.timing { + let min_end_time = timing.min_end_time.to_datetime_from_moy(moy, year); + + println!( + "- sig grp. {sig_grp:03}: until {min_end_time:?} at {:?}", + event.event_state + ); + } else { + println!( + "- sig grp. {sig_grp:03}: {:?} until unknown", + event.event_state + ); + } + } else { + println!("- sig grp. {sig_grp:03}: no events"); + } + } + } + } +} diff --git a/src/main.rs b/src/main.rs index fe4d827..807561e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,19 +7,22 @@ )] // #![deny(clippy::large_stack_frames)] +use alloc::vec::Vec; use core::cell::RefCell; use critical_section::Mutex; +use crossbeam_queue::ArrayQueue; use embassy_executor::Spawner; use embassy_gps::gps::l76k; use esp_backtrace as _; use esp_hal::clock::CpuClock; use esp_hal::timer::timg::TimerGroup; use esp_radio::wifi; -use log::{debug, info, warn}; +use log::{debug, error, info, warn}; extern crate alloc; +mod applogic; mod radio; const WIFI_CHANNEL: radio::Channel = 180; @@ -85,6 +88,7 @@ esp_bootloader_esp_idf::esp_app_desc!(); static GNSS_UPDATE: Mutex>> = Mutex::new(RefCell::new(None)); +static WIFI_RX_QUEUE: Mutex>>>> = Mutex::new(RefCell::new(None)); #[allow( clippy::large_stack_frames, @@ -111,6 +115,7 @@ async fn main(spawner: Spawner) -> ! { // - GPIO28 esp_alloc::heap_allocator!(#[esp_hal::ram(reclaimed)] size: 65536); + esp_alloc::psram_allocator!(peripherals.PSRAM, esp_hal::psram); let timg0 = TimerGroup::new(peripherals.TIMG0); let sw_interrupt = @@ -125,6 +130,10 @@ async fn main(spawner: Spawner) -> ! { ) .expect("Failed to initialize Wi-Fi controller"); + critical_section::with(|cs| { + WIFI_RX_QUEUE.borrow(cs).replace(Some(ArrayQueue::new(2))); + }); + let mut sniffer = interfaces.sniffer; radio::setup_wifi_sniffer(WIFI_CHANNEL, &mut sniffer, handle_frame) .expect("Fatal error initializing 802.11p sniffer"); @@ -150,7 +159,7 @@ async fn main(spawner: Spawner) -> ! { let mut state = State::default(); loop { - embassy_time::Timer::after(embassy_time::Duration::from_millis(100)).await; + embassy_time::Timer::after(embassy_time::Duration::from_millis(5)).await; critical_section::with(|cs| { let gnss_update_ref = GNSS_UPDATE.borrow(cs); @@ -164,6 +173,68 @@ async fn main(spawner: Spawner) -> ! { } } }); + + critical_section::with(|cs| { + let queue_rc = WIFI_RX_QUEUE.borrow(cs).borrow(); + // unwrap is fine b/c we stored something in it before + let queue = queue_rc.as_ref().unwrap(); + + if let Some(data) = queue.pop() { + match c_its_parser::de::decode(&data, c_its_parser::Headers::IEEE802LlcGnBtp) { + Ok(msg) => { + use c_its_parser::ItsMessage; + use c_its_parser::standards::extensions::ItsMessageId; + + debug!("Got new {:?} message", ItsMessageId::from(&msg)); + + match msg { + #[cfg(feature = "spat")] + ItsMessage::Mapem { + geonetworking: _, + transport: _, + etsi, + } => applogic::tlc::handle_mapem(&etsi), + #[cfg(feature = "spat")] + ItsMessage::Spatem { + geonetworking: _, + transport: _, + etsi, + } => { + if state.initialized { + applogic::tlc::handle_spatem(&etsi, &state.time); + } + } + + #[cfg(feature = "cam")] + ItsMessage::Cam { + geonetworking: _, + transport: _, + etsi, + } => applogic::cam::handle_cam(&etsi), + + #[cfg(feature = "denm")] + ItsMessage::DenmV2 { + geonetworking: _, + transport: _, + etsi, + } => applogic::denm::handle_denm(&etsi), + #[cfg(feature = "denm")] + ItsMessage::DenmV1 { + geonetworking: _, + transport: _, + etsi: _, + } => { + // irrelevant since all received DENMs should be parsed as v2 + } + } + } + Err(err) => { + // will give false-positives when a message is received which wasn't enabled + debug!("Failed to parse V2X message: {err}"); + } + } + } + }); } } @@ -183,8 +254,32 @@ fn handle_frame(frame: wifi::sniffer::PromiscuousPkt<'_>) { return; } - // TODO: handle 802.11 frame - info!("Received frame with {} bytes", frame.len); + // Ignore non-broadcast frames + if frame.len < (4 + 6) { + warn!( + "Received frame too small to be a valid broadcast frame: {} bytes", + frame.len + ); + return; + } + let dest_mac = &frame.data[4..10]; + if dest_mac != [0xff; 6] { + warn!("Received frame is not broadcast frame: Dest MAC {dest_mac:02x?}"); + return; + } + + debug!("Received frame with {} bytes", frame.len); + + // Send data to main thread + critical_section::with(|cs| { + let queue_rc = WIFI_RX_QUEUE.borrow(cs).borrow(); + // unwrap is fine b/c we stored something in it before + let queue = queue_rc.as_ref().unwrap(); + + if queue.push(frame.data.to_vec()).is_err() { + error!("V2X RX queue is full"); + } + }); } #[allow(clippy::large_stack_frames, reason = "GNSS driver needs some space")] diff --git a/src/radio.rs b/src/radio.rs index 83a5c27..652dbdf 100644 --- a/src/radio.rs +++ b/src/radio.rs @@ -67,7 +67,7 @@ impl PromiscuousPktType { core::ffi::c_uint::from(self.as_repr()) } - /// Determines if + /// Determines if we are the same frame type as `esp_radio::wifi::sniffer::PromiscuousPkt::frame_type` pub fn matches(self, frame_type: core::ffi::c_uint) -> bool { self.as_frame_type() == frame_type } From 9a6491c393bcf5e7c56f9e1ce02fd3635e41d19c Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Sat, 13 Jun 2026 12:01:58 +0200 Subject: [PATCH 03/49] denm: Use conversion from c-its-parser --- Cargo.lock | 4 ++-- Cargo.toml | 2 +- src/applogic/denm.rs | 3 +-- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6ef3d5d..f183605 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -124,9 +124,9 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "c-its-parser" -version = "2.2.1" +version = "2.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d343eab948bdc3745a9abd17ccc33fe3a0df236888bc460fe9655043fc853581" +checksum = "ee4515d183029336d4c1ccb4b5e66dfaf167407b83c1729a2b53fdded1e68a24" dependencies = [ "chrono", "etherparse", diff --git a/Cargo.toml b/Cargo.toml index 6bd60e9..233f358 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,7 +55,7 @@ esp-radio = { version = "0.18.0", features = [ ] } embassy_gps = { version = "0.1.0", default-features = false, features = ["esp", "log-04"], git = "https://github.com/jbeyerstedt/embassy_gps.git", branch = "feat/rework-gpsfix" } -c-its-parser = { version = "2.2.1", default-features = false, features = [ +c-its-parser = { version = "2.2.2", default-features = false, features = [ "time", "geo", "libm", diff --git a/src/applogic/denm.rs b/src/applogic/denm.rs index 6ecd3c8..095720f 100644 --- a/src/applogic/denm.rs +++ b/src/applogic/denm.rs @@ -21,11 +21,10 @@ pub fn handle_denm(etsi: &denm_pdu_description::DENM) { let lat_deg = mgmt.event_position.latitude.as_deg(); let lon_deg = mgmt.event_position.longitude.as_deg(); - // TODO: use `as_deg()` when in new c-its-parser release let heading = loc .event_position_heading .as_ref() - .map(|v| f32::from(v.value.0) / 10f32); + .map(|v| v.value.as_deg()); // let direction = mgmt.traffic_direction; From 933106b7ea5d89b95b84cb44628de5cbddf98419 Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Mon, 1 Jun 2026 13:53:42 +0200 Subject: [PATCH 04/49] build: run unit tests locally --- Cargo.toml | 49 +++++++++++++++++++++++++++++++++------------ Readme.md | 7 +++++++ build.rs | 12 ++++++++--- src/applogic/tlc.rs | 1 + src/test.rs | 10 +++++++++ 5 files changed, 63 insertions(+), 16 deletions(-) create mode 100644 src/test.rs diff --git a/Cargo.toml b/Cargo.toml index 233f358..5968d98 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,13 +3,36 @@ edition = "2024" name = "esp32-c_its-companion" rust-version = "1.88" version = "0.1.0" +default-run = "esp32-c_its-companion" [[bin]] name = "esp32-c_its-companion" path = "./src/main.rs" +[[bin]] +name = "test" +path = "./src/test.rs" +required-features = ["std"] + [features] -default = ["spat"] +default = ["esp", "spat"] + +# build for ESP (needed to build test for `host-tuple`) +esp = [ + "dep:esp-hal", + "dep:esp-rtos", + "dep:esp-bootloader-esp-idf", + "dep:embassy-executor", + "dep:embassy-time", + "dep:embedded-io", + "dep:embedded-io-async", + "dep:esp-alloc", + "dep:esp-backtrace", + "dep:esp-println", + "dep:esp-radio", + "dep:embassy_gps", +] +std = ["c-its-parser/std"] # echo CAM data on serial cam = ["c-its-parser/cam"] @@ -20,9 +43,9 @@ spat = ["c-its-parser/spatem", "c-its-parser/mapem"] [dependencies] -esp-hal = { version = "~1.1.0", features = ["esp32c5", "log-04", "unstable"] } +esp-hal = { version = "~1.1.0", optional = true, features = ["esp32c5", "log-04", "unstable"] } -esp-rtos = { version = "0.3.0", features = [ +esp-rtos = { version = "0.3.0", optional = true, features = [ "embassy", "esp-alloc", "esp-radio", @@ -30,22 +53,22 @@ esp-rtos = { version = "0.3.0", features = [ "log-04", ] } -esp-bootloader-esp-idf = { version = "0.5.0", features = ["esp32c5", "log-04"] } +esp-bootloader-esp-idf = { version = "0.5.0", optional = true, features = ["esp32c5", "log-04"] } log = "0.4.27" critical-section = "1.2.0" -embassy-executor = { version = "0.10.0", features = ["log"] } -embassy-time = { version = "0.5.0", features = ["log"] } -embedded-io = "0.7.1" -embedded-io-async = "0.7.0" -esp-alloc = "0.10.0" -esp-backtrace = { version = "0.19.0", features = [ +embassy-executor = { version = "0.10.0", optional = true, features = ["log"] } +embassy-time = { version = "0.5.0", optional = true, features = ["log"] } +embedded-io = { version = "0.7.1", optional = true } +embedded-io-async = { version = "0.7.0", optional = true } +esp-alloc = { version = "0.10.0", optional = true } +esp-backtrace = { version = "0.19.0", optional = true, features = [ "esp32c5", "panic-handler", "println", ] } -esp-println = { version = "0.17.0", features = ["esp32c5", "log-04"] } -esp-radio = { version = "0.18.0", features = [ +esp-println = { version = "0.17.0", optional = true, features = ["esp32c5", "log-04"] } +esp-radio = { version = "0.18.0", optional = true, features = [ "esp-alloc", "esp32c5", "log-04", @@ -54,7 +77,7 @@ esp-radio = { version = "0.18.0", features = [ "sniffer", ] } -embassy_gps = { version = "0.1.0", default-features = false, features = ["esp", "log-04"], git = "https://github.com/jbeyerstedt/embassy_gps.git", branch = "feat/rework-gpsfix" } +embassy_gps = { version = "0.1.0", optional = true, default-features = false, features = ["esp", "log-04"], git = "https://github.com/jbeyerstedt/embassy_gps.git", branch = "feat/rework-gpsfix" } c-its-parser = { version = "2.2.2", default-features = false, features = [ "time", "geo", diff --git a/Readme.md b/Readme.md index 02d912f..c2d3983 100644 --- a/Readme.md +++ b/Readme.md @@ -29,3 +29,10 @@ Connect your ESP32-C5 and run: ``` cargo run --release ``` + +### Run Unit Tests on Host +The unit tests in this project are meant to be run on the host: + +``` +cargo test --target host-tuple --bin test --no-default-features -F std,spat +``` diff --git a/build.rs b/build.rs index 9272c07..7ad15d3 100644 --- a/build.rs +++ b/build.rs @@ -1,7 +1,13 @@ +use std::env; + fn main() { - linker_be_nice(); - // make sure linkall.x is the last linker script (otherwise might cause problems with flip-link) - println!("cargo:rustc-link-arg=-Tlinkall.x"); + let target_arch = env::var("CARGO_CFG_TARGET_ARCH").unwrap(); + + if target_arch == "riscv32" { + linker_be_nice(); + // make sure linkall.x is the last linker script (otherwise might cause problems with flip-link) + println!("cargo:rustc-link-arg=-Tlinkall.x"); + } } fn linker_be_nice() { diff --git a/src/applogic/tlc.rs b/src/applogic/tlc.rs index ed03922..4b631c9 100644 --- a/src/applogic/tlc.rs +++ b/src/applogic/tlc.rs @@ -3,6 +3,7 @@ use c_its_parser::standards::mapem_2_2_1::mapem_pdu_descriptions; use c_its_parser::standards::spatem_2_2_1::spatem_pdu_descriptions; use chrono::Datelike; +#[cfg(target_arch = "riscv32")] use esp_println::println; pub fn handle_mapem(etsi: &mapem_pdu_descriptions::MAPEM) { diff --git a/src/test.rs b/src/test.rs new file mode 100644 index 0000000..0fb9fac --- /dev/null +++ b/src/test.rs @@ -0,0 +1,10 @@ +#![cfg(not(target_arch = "riscv32"))] + +extern crate alloc; + +mod applogic; +mod cache; + +fn main() { + // dummy only +} From 7463adb038cc2ce13142a03622c1efe18b484581 Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Sat, 30 May 2026 23:34:06 +0200 Subject: [PATCH 05/49] Evaluate remaining red/green time for upcoming intersection --- Cargo.lock | 123 ++++- Cargo.toml | 8 + Readme.md | 36 ++ src/applogic/mod.rs | 543 +++++++++++++++++++++ src/applogic/tlc.rs | 126 +++-- src/applogic/v2x/map.rs | 1019 +++++++++++++++++++++++++++++++++++++++ src/applogic/v2x/mod.rs | 4 + src/cache.rs | 221 +++++++++ src/geo_alg.rs | 446 +++++++++++++++++ src/main.rs | 78 +-- src/test.rs | 13 + src/testdata.rs | 457 ++++++++++++++++++ 12 files changed, 2975 insertions(+), 99 deletions(-) create mode 100644 src/applogic/v2x/map.rs create mode 100644 src/applogic/v2x/mod.rs create mode 100644 src/cache.rs create mode 100644 src/geo_alg.rs create mode 100644 src/testdata.rs diff --git a/Cargo.lock b/Cargo.lock index f183605..23c5a3a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,56 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c583acf993cf4245c4acb0a2cc2ab1f9cc097de73411bb6d3647ff6af2b1013d" +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -29,6 +79,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "assert_float_eq" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd9777fd8234cccd34019d9e26d70c8247f9baf24c8fdc0b56c548cf943af2ed" + [[package]] name = "autocfg" version = "1.5.0" @@ -164,6 +220,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "const-default" version = "1.0.0" @@ -633,6 +695,29 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "env_filter" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1011,6 +1096,7 @@ dependencies = [ name = "esp32-c_its-companion" version = "0.1.0" dependencies = [ + "assert_float_eq", "c-its-parser", "chrono", "critical-section", @@ -1020,6 +1106,7 @@ dependencies = [ "embassy_gps", "embedded-io 0.7.1", "embedded-io-async 0.7.0", + "env_logger", "esp-alloc", "esp-backtrace", "esp-bootloader-esp-idf", @@ -1027,7 +1114,9 @@ dependencies = [ "esp-println", "esp-radio", "esp-rtos", + "geo-types", "log", + "num-traits", ] [[package]] @@ -1333,6 +1422,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.13.0" @@ -1594,6 +1689,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "paste" version = "1.0.15" @@ -1757,6 +1858,18 @@ dependencies = [ "uuid", ] +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + [[package]] name = "regex-automata" version = "0.4.14" @@ -1770,9 +1883,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "riscv" @@ -2202,6 +2315,12 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.23.1" diff --git a/Cargo.toml b/Cargo.toml index 5968d98..0c91516 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,8 @@ cam = ["c-its-parser/cam"] denm = ["c-its-parser/denm"] # evaluate SPAT information spat = ["c-its-parser/spatem", "c-its-parser/mapem"] +# enable some debug printing (without enabling debug logging of the ESP RTOS) +spat_debug = [] [dependencies] @@ -86,6 +88,12 @@ c-its-parser = { version = "2.2.2", default-features = false, features = [ ] } chrono = { version = "0.4.44", default-features = false, features = ["alloc"] } crossbeam-queue = { version = "0.3.12", default-features = false, features = ["alloc"] } +geo-types = { version = "0.7.19", default-features = false } +num-traits = { version = "0.2.19", default-features = false, features = ["libm"] } + +[dev-dependencies] +assert_float_eq = "1.2.0" +env_logger = "0.11.10" # For fine tuning these settings, please refer to https://doc.rust-lang.org/cargo/reference/profiles.html diff --git a/Readme.md b/Readme.md index c2d3983..2393479 100644 --- a/Readme.md +++ b/Readme.md @@ -2,6 +2,29 @@ Displays different C-ITS receive-only use cases for cheap. +## Hardware Requirements +The code is currently tailored to: +- Seeed XIAO ESP32-C5 (8MB PSRAM) +- Seeed XIAO L67K (connected on GPIO12 (RX), GPIO11 (TX), GPIO1 (wakeup) and GPIO25 (reset)) +TODO: only enable in `spat` feature? +- TODO: display (when using `spat` feature) + +## Features + +### SPAT +Takes the current position and heading from the GNSS receiver and displays the signal phases of the upcoming intersection. +The upcoming intersection is selected when the own position is "inside" some ingress approach of the intersection. +The display will show the current phase and remaining time for all signal groups of the bike-able lanes in the approach. + +### DENM +TODO: Not implemented yet! + +Shall "publish" the most relevant information of a DENMs via BLE + +### CAM +Just prints a line for each received CAM. + + ## Usage ### Toolchain Installation @@ -24,10 +47,23 @@ rustup component add --toolchain nightly rustfmt ``` ### Build and Flash +In default configuration only signal phases (SPAT) data is displayed for the upcoming intersection. +Additional features are available though feature flags: + +- `esp`: Run on ESP (required as feature flag to enable unit tests on host) +- `std`: Run on system with `std` library +- `spat`: Enable signal phase (and intersection map) data +- `denm`: Enable DENMs +- `cam`: Enable CAMs + Connect your ESP32-C5 and run: ``` +# default configuration cargo run --release + +# for e.g. also enable DENM feature +cargo run --release -F denm ``` ### Run Unit Tests on Host diff --git a/src/applogic/mod.rs b/src/applogic/mod.rs index 1e61aa8..194ab6c 100644 --- a/src/applogic/mod.rs +++ b/src/applogic/mod.rs @@ -1,8 +1,551 @@ //! Application Logic +#[cfg(feature = "spat")] +use alloc::rc::Rc; +#[cfg(feature = "spat")] +use alloc::vec::Vec; + +#[cfg(all(target_arch = "riscv32", feature = "spat"))] +use esp_println::println; +#[cfg(feature = "spat")] +use log::{debug, error}; +use log::{info, warn}; + +#[cfg(feature = "spat")] +use crate::geo_alg; + +pub mod v2x; + #[cfg(feature = "cam")] pub mod cam; #[cfg(feature = "denm")] pub mod denm; #[cfg(feature = "spat")] pub mod tlc; + +#[cfg(feature = "spat")] +const PRUNE_INTERVAL: chrono::Duration = chrono::Duration::seconds(5); + +#[cfg(feature = "spat")] +const GLOSA_SEARCH_DIST_M: f32 = 400.; +#[cfg(feature = "spat")] +const GLOSA_SEARCH_ANGLE_PARAMS: [(f32, f32); 2] = [(400., 45.), (50., 180.)]; // far and near (distance, angle) tuple +#[cfg(feature = "spat")] +const GLOSA_LANE_HEADING_DIFF_DEG: f32 = 35.; +#[cfg(feature = "spat")] +const GLOSA_AT_LANE_OFFSET_M: f64 = 10.; +#[cfg(feature = "spat")] +const GLOSA_LANE_LEFT_OFFSET_M: f64 = 20.; +#[cfg(feature = "spat")] +const GLOSA_STOP_LINE_PASSED_M: f64 = 10.; + +pub struct State { + initialized: bool, + time: chrono::NaiveDateTime, + + position: geo_types::Point, + heading_deg: Option, + speed_mps: Option, + + #[cfg(feature = "spat")] + last_prune: chrono::NaiveDateTime, + #[cfg(feature = "spat")] + tlc_cache: tlc::Data, + + // GLOSA + #[cfg(feature = "spat")] + #[allow(clippy::struct_field_names)] + glosa_state: GlosaState, +} + +#[cfg(feature = "spat")] +#[derive(Debug, Default)] +enum GlosaState { + #[default] + Idle, + Locked(TlData), +} + +/// Where we are and pre-processed information about the current intersection +#[cfg(feature = "spat")] +#[derive(Debug)] +struct TlData { + intersection_id: u16, // mostly for debug printing + intersection: Rc>, + + lane_id: u8, + approach_id: Option, + + signal_groups: Vec, +} + +impl State { + pub fn new() -> Self { + Self { + initialized: false, + time: chrono::NaiveDateTime::default(), + position: geo_types::Point::default(), + heading_deg: None, + speed_mps: None, + #[cfg(feature = "spat")] + last_prune: chrono::NaiveDateTime::default(), + #[cfg(feature = "spat")] + tlc_cache: tlc::Data::new(chrono::Duration::seconds(20)), + #[cfg(feature = "spat")] + glosa_state: GlosaState::default(), + } + } + + #[cfg(feature = "esp")] + pub fn update_with_gpsfix(&mut self, fix: &embassy_gps::types::GpsFix) { + self.position = geo_types::Point::new(fix.longitude_deg, fix.latitude_deg); + + if let Some(heading) = fix.true_course_deg { + self.heading_deg = Some(heading); + } else { + warn!("State: Course missing from GpsFix"); + } + + if let Some(speed) = fix.speed_over_ground { + self.speed_mps = Some(speed); + } else { + warn!("State: Speed missing from GpsFix"); + } + + if let Some(time) = fix.get_timestamp() { + self.time = time; + + if !self.initialized { + info!("GNSS fix: {}", self.print_fix()); + } + self.initialized = true; + } + } + + pub fn print_fix(&self) -> alloc::string::String { + alloc::format!( + "{:?}, {:.4} N, {:.4} E, {:.0}°, {:.2} m/s", + self.time, + self.position.y(), + self.position.x(), + self.heading_deg.unwrap_or_default(), + self.speed_mps.unwrap_or_default(), + ) + } + + pub fn prune(&mut self) { + #[cfg(feature = "spat")] + if self.last_prune + PRUNE_INTERVAL < self.time { + self.tlc_cache.prune(self.time); + + info!("Known int.: {:?}", self.tlc_cache.known_intersections()); + + self.last_prune = self.time; + } + } + + #[cfg(feature = "spat")] + pub fn handle_mapem(&mut self, etsi: &tlc::MAPEM) { + self.tlc_cache.handle_mapem(self.time, etsi); + } + + #[cfg(feature = "spat")] + pub fn handle_spatem(&mut self, etsi: &tlc::SPATEM) { + // only update "our" intersection + if let GlosaState::Locked(data) = &self.glosa_state + && etsi + .spat + .intersections + .0 + .iter() + .find(|i| i.id.id.0 == data.intersection_id) + .is_some() + { + self.tlc_cache.handle_spatem(self.time, etsi); + } + } + + #[cfg(feature = "spat")] + pub fn run_glosa(&mut self) { + let Some(own_heading) = self.heading_deg else { + return; + }; + + let state_update = match &self.glosa_state { + // search for upcoming intersection + GlosaState::Idle => { + debug!( + "GLOSA: Searching with {} known intersections", + self.tlc_cache.known_intersections().len() + ); + + if let Some(search_result) = + Self::find_intersection(&self.position, own_heading, &self.tlc_cache) + { + info!( + "GLOSA: Approach has signal groups: {:?}", + search_result.signal_groups + ); + + Some(GlosaState::Locked(search_result)) + } else { + None + } + } + GlosaState::Locked(info) => { + debug!( + "GLOSA: Re-evaluating intersection {:4} (at lane {:3})", + info.intersection_id, info.lane_id + ); + + // determine if intersection is left + let intersection = info.intersection.borrow(); + if Self::is_intersection_left( + &intersection, + info.lane_id, + info.approach_id, + &self.position, + ) { + info!("GLOSA: Intersection was left -> back to idle"); + Some(GlosaState::Idle) + } else { + // get signal groups which are in our approach + let mut approach_sig_grps = intersection + .signal_groups() + .iter() + .filter(|i| { + info.signal_groups + .iter() + .find(|sg_id| **sg_id == i.id()) + .is_some() + }) + .collect::>(); + + // sort signal groups by maneuver + approach_sig_grps.sort_by_key(|v| v.maneuvers()); + + // display signal phases + for sig in approach_sig_grps { + if let Some(phase) = sig.phase_to_str() + && let Some(manv) = sig.maneuvers() + { + use core::ops::Sub; + + let min_end = sig.min_end_time(); + let dur = min_end.sub(self.time.and_utc()).as_seconds_f32(); + println!( + "GLOSA: {manv} {phase} for {dur:.1} s (until {})", + sig.min_end_time() + ); + } + } + + None // keep state + } + } + }; + + // can't update the state from within the match statement, so do it here + if let Some(new) = state_update { + self.glosa_state = new; + } + } + + #[cfg(feature = "spat")] + #[allow(clippy::too_many_lines)] + fn find_intersection( + own_pos: &geo_types::Point, + own_heading: f32, + tlc_cache: &tlc::Data, + ) -> Option { + // filter known intersections by distance (and bearing) + let upcoming_intersections = tlc_cache.intersections_matching(|i| { + let int_pos = i.center(); + + #[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)] + let distance = geo_alg::haversine_dist(own_pos, &int_pos) as f32; + let bearing = geo_alg::haversine_bearing(own_pos, &int_pos); + let bearing_diff = geo_alg::deg_180(own_heading - bearing); + + distance < GLOSA_SEARCH_DIST_M && bearing_diff.abs() < Self::get_search_angle(distance) + }); + + info!( + "GLOSA: upcoming int. count: {}", + upcoming_intersections.len() + ); + + // determine relevant approach + for int in upcoming_intersections { + let intersection = int.borrow(); + let lanes = intersection.ingress_lanes(); + + let own_pos_cart = geo_alg::lonlat_to_dcart(&intersection.ref_point(), own_pos); + + // find lanes with matching heading + let relevant_lanes = lanes + .iter() + .filter(|i| { + let lane_heading = i.get_heading(); + let heading_diff = geo_alg::deg_180(own_heading - lane_heading); + + debug!( + " - Lane {:4}/{:03} has heading {lane_heading:4.0} -> diff {heading_diff:4.0}°", + intersection.id(), + i.id() + ); + + heading_diff.abs() < GLOSA_LANE_HEADING_DIFF_DEG + }) + .collect::>(); + + info!( + "GLOSA: Int {:4} has {} relevant lanes", + intersection.id(), + relevant_lanes.len() + ); + for lane in &relevant_lanes { + info!( + " - lane {:03} from appr. {:?}, usages {:?}", + lane.id(), + lane.approach_id(), + lane.usages() + ); + } + + // determine in which lane/ approach we are + let current_lane = relevant_lanes.iter().find(|lane| { + #[cfg(feature = "spat_debug")] + info!("GLOSA: Searching for current lane"); + match geo_alg::LinePosition::for_ingress_lane(own_pos_cart, lane.nodes()) { + Ok(pos) => { + #[cfg(feature = "spat_debug")] + info!( + " - Lane {:4}/{:03} {:?} with {:.0} m offset", + intersection.id(), + lane.id(), + pos.semantic, + pos.perpendicular_dist, + ); + + if pos.perpendicular_dist < GLOSA_AT_LANE_OFFSET_M + && pos.semantic == geo_alg::LinePositionType::InBetween + { + info!( + "GLOSA: At lane {:4}/{:03} -> locking intersection", + intersection.id(), + lane.id() + ); + + true + } else { + false + } + } + Err(err) => { + warn!("Lane {:04}: Failed to determine position: {err}", lane.id()); + false + } + } + }); + + if let Some(lane) = current_lane { + // we found some lane which we are "inside" -> lane search can be aborted here + // but we should extract some information which is needed for future processing first + + // collect all signal groups from the current approach + let mut signal_groups = if let Some(own_approach) = lane.approach_id() { + let approach_lanes = relevant_lanes.iter().filter(|lane| { + if let Some(approach) = lane.approach_id() { + approach == own_approach + } else { + false + } + }); + + approach_lanes + .flat_map(|lane| { + lane.connections() + .iter() + .filter_map(v2x::map::Connection::signal_group) + }) + .collect::>() + } else { + error!( + "GLOSA: Lane has no approach ID, can't determine all possibly relevant signal groups" + ); + // just fall back to only the current lane's signal groups + lane.connections() + .iter() + .filter_map(v2x::map::Connection::signal_group) + .collect::>() + }; + + signal_groups.sort_unstable(); + signal_groups.dedup(); + + return Some(TlData { + intersection_id: intersection.id(), + intersection: int.clone(), + lane_id: lane.id(), + approach_id: lane.approach_id(), + signal_groups, + }); + } + } + + None + } + + #[cfg(feature = "spat")] + fn get_search_angle(distance: f32) -> f32 { + let (dist_near, angle_near) = GLOSA_SEARCH_ANGLE_PARAMS[0]; + let (dist_far, angle_far) = GLOSA_SEARCH_ANGLE_PARAMS[1]; + + if distance > dist_far { + angle_far + } else if distance < dist_near { + angle_near + } else { + // interpolate angle from near to far + let diff_dist = dist_far - dist_near; + let diff_angle = angle_far - angle_near; + + angle_near + (diff_angle / diff_dist) * (distance - dist_near) + } + } + + /// Determines if intersection was left + /// + /// TODO: return if left via stop line or otherwise + #[cfg(feature = "spat")] + fn is_intersection_left( + intersection: &v2x::map::Intersection, + lane_id: u8, + approach_id: Option, + own_pos: &geo_types::Point, + ) -> bool { + // get all lanes from the current approach + let lanes = if let Some(approach) = approach_id { + intersection.approach_lanes(approach) + } else if let Some(lane) = intersection.lane(lane_id) { + alloc::vec![lane] + } else { + alloc::vec![] + }; + + // find minimal distance to any lane where we are "inside" + let own_pos_cart = geo_alg::lonlat_to_dcart(&intersection.ref_point(), own_pos); + let min_perp_dist = lanes.iter().fold(f64::MAX, |min_dist, i| { + match geo_alg::LinePosition::for_ingress_lane(own_pos_cart, i.nodes()) { + Ok(pos) => { + // check if we left the lane via the stop line, but only count stop line as passed after X meters + if pos.semantic == geo_alg::LinePositionType::InBetween + || (pos.semantic == geo_alg::LinePositionType::BeforeStart + && pos.parallel_dist.abs() < GLOSA_STOP_LINE_PASSED_M) + { + // still at the lane -> update min_dist + pos.perpendicular_dist + } else { + // not at the lane -> take old min_dist + min_dist + } + } + Err(err) => { + warn!("Lane {:04}: Failed to determine position: {err}", i.id()); + min_dist + } + } + }); + + min_perp_dist > GLOSA_LANE_LEFT_OFFSET_M + } + + // -- getters -- + + #[allow(unused)] + pub fn initialized(&self) -> bool { + self.initialized + } + + #[allow(unused)] + pub fn time(&self) -> chrono::prelude::NaiveDateTime { + self.time + } +} + +#[cfg(test)] +mod tests { + + use super::*; + use crate::init_test_env_logger; + use crate::testdata::{MAPEM_HH_560, MAPEM_HH_1328}; + + #[cfg(feature = "spat")] + #[test] + fn glosa() { + init_test_env_logger(); + + // prime TLC cache + let mut state = State::new(); + let time_now = chrono::NaiveDateTime::default(); + + let etsi = if let c_its_parser::ItsMessage::Mapem { + geonetworking: _, + transport: _, + etsi, + } = + c_its_parser::de::decode(MAPEM_HH_1328, c_its_parser::Headers::None).unwrap() + { + etsi + } else { + panic!("Unexpected C-ITS message") + }; + state.tlc_cache.handle_mapem(time_now, &etsi); + + let etsi = if let c_its_parser::ItsMessage::Mapem { + geonetworking: _, + transport: _, + etsi, + } = + c_its_parser::de::decode(MAPEM_HH_560, c_its_parser::Headers::None).unwrap() + { + etsi + } else { + panic!("Unexpected C-ITS message") + }; + state.tlc_cache.handle_mapem(time_now, &etsi); + + // test GLOSA + let own_pos_infront = geo_types::Point::new(9.9752864, 53.5564028); + let own_pos_inside = geo_types::Point::new(9.9760764, 53.5563621); + let own_pos_passed = geo_types::Point::new(9.977217, 53.5560602); + let own_heading = 90.; + + state.heading_deg = Some(own_heading); + + state.position = own_pos_infront; + state.run_glosa(); + assert!(matches!(state.glosa_state, GlosaState::Idle)); + + state.position = own_pos_inside; + state.run_glosa(); + assert!(matches!(state.glosa_state, GlosaState::Locked(_))); + if let GlosaState::Locked(info) = &state.glosa_state { + assert_eq!(1328, info.intersection_id); + assert!(info.lane_id == 1 || info.lane_id == 2); + } + + state.position = own_pos_inside; + state.run_glosa(); + assert!(matches!(state.glosa_state, GlosaState::Locked(_))); + if let GlosaState::Locked(info) = &state.glosa_state { + assert_eq!(1328, info.intersection_id); + assert!(info.lane_id == 1 || info.lane_id == 2); + } + + state.position = own_pos_passed; + state.run_glosa(); + assert!(matches!(state.glosa_state, GlosaState::Idle)); + } +} diff --git a/src/applogic/tlc.rs b/src/applogic/tlc.rs index 4b631c9..96cbd8a 100644 --- a/src/applogic/tlc.rs +++ b/src/applogic/tlc.rs @@ -1,52 +1,108 @@ -//! C-ITS Traffic Light Data (MAPEM/ SPATEM) Evaluation +//! C-ITS Traffic Light Data (MAPEM/ SPATEM) Evaluation/ Cache -use c_its_parser::standards::mapem_2_2_1::mapem_pdu_descriptions; -use c_its_parser::standards::spatem_2_2_1::spatem_pdu_descriptions; +use alloc::rc::Rc; +use alloc::vec::Vec; + +pub use c_its_parser::standards::mapem_2_2_1::mapem_pdu_descriptions::MAPEM; +pub use c_its_parser::standards::spatem_2_2_1::spatem_pdu_descriptions::SPATEM; use chrono::Datelike; -#[cfg(target_arch = "riscv32")] +#[cfg(all(target_arch = "riscv32", feature = "spat_debug"))] use esp_println::println; -pub fn handle_mapem(etsi: &mapem_pdu_descriptions::MAPEM) { - // TODO: proof of concept only -- replace with actual evaluation - if let Some(map) = &etsi.map.intersections { - for int in &map.0 { - println!("got MAPEM for intersection {}", int.id.id.0); - } - } +use super::v2x::map; +use crate::cache::Cache; + +pub struct Data { + intersections: Cache, } -pub fn handle_spatem(etsi: &spatem_pdu_descriptions::SPATEM, now: &chrono::NaiveDateTime) { - // TODO: proof of concept only -- replace with actual evaluation - for int in &etsi.spat.intersections.0 { - println!("got SPATEM for intersection {}", int.id.id.0); +impl Data { + pub fn new(cache_lifetime: chrono::Duration) -> Self { + Self { + intersections: Cache::::new(cache_lifetime), + } + } - if let (Some(moy), Some(second)) = (&int.moy, &int.time_stamp) { - let year = now.year(); + pub fn known_intersections(&self) -> Vec { + self.intersections.keys() + } - let ref_time = c_its_parser::time_utils::time_from_moy_and_dsecond(moy, second, year); - println!("- ref time: {ref_time:?}"); + pub fn intersections_matching

( + &self, + predicate: P, + ) -> Vec>> + where + P: Fn(&map::Intersection) -> bool, + { + self.intersections.get_filtered(predicate) + } - for state in &int.states.0 { - let sig_grp = state.signal_group.0; + pub fn handle_mapem(&mut self, time_now: chrono::NaiveDateTime, etsi: &MAPEM) { + let layer_id = etsi.map.layer_id.as_ref().map(|v| v.0); + if let Some(map) = &etsi.map.intersections { + for geom in &map.0 { + let int_id = geom.id.id.0; - if let Some(event) = state.state_time_speed.0.first() { - if let Some(timing) = &event.timing { - let min_end_time = timing.min_end_time.to_datetime_from_moy(moy, year); + if self.intersections.contains(&int_id) { + // update with new map layers - println!( - "- sig grp. {sig_grp:03}: until {min_end_time:?} at {:?}", - event.event_state - ); - } else { - println!( - "- sig grp. {sig_grp:03}: {:?} until unknown", - event.event_state - ); - } + // we can ignore the result, since we're sure the item exists + let _ = self.intersections.update(time_now, int_id, |i| { + if !i.is_complete() { + i.add_layer(layer_id, geom); + + #[cfg(feature = "spat_debug")] + if i.is_complete() { + Self::pretty_print_intersection(&i); + } + } + }); } else { - println!("- sig grp. {sig_grp:03}: no events"); + // create new intersection + let intersection = map::Intersection::new(layer_id, geom); + + #[cfg(feature = "spat_debug")] + if intersection.is_complete() { + Self::pretty_print_intersection(&intersection); + } + + // we can ignore the result, since we're sure the item does not exist + let _ = self.intersections.insert(time_now, intersection); } } } } + + #[cfg(feature = "spat_debug")] + fn pretty_print_intersection(intersection: &map::Intersection) { + println!("TLC: {intersection}"); + println!("TLC: Signal Groups"); + for grp in intersection.signal_groups() { + println!(" {grp}"); + } + } + + pub fn prune(&mut self, time_now: chrono::NaiveDateTime) { + self.intersections.prune(time_now); + } + + pub fn handle_spatem(&mut self, time_now: chrono::NaiveDateTime, etsi: &SPATEM) { + for int in &etsi.spat.intersections.0 { + let int_id = int.id.id.0; + + if self.intersections.contains(&int_id) { + // update with SPAT data + + // we can ignore the result, since we're sure the item exists + let _ = self.intersections.update(time_now, int_id, |i| { + if i.is_complete() { + // add SPAT information to intersection + i.add_spat(int, time_now.year()); + } + }); + } else { + // drop message since we don't have (full) MAP yet + } + } + } } diff --git a/src/applogic/v2x/map.rs b/src/applogic/v2x/map.rs new file mode 100644 index 0000000..b49e818 --- /dev/null +++ b/src/applogic/v2x/map.rs @@ -0,0 +1,1019 @@ +//! Types for V2X data handling + +use alloc::string::{String, ToString}; +use alloc::vec::Vec; +use alloc::{format, vec}; +use core::ops::Div; + +use c_its_parser::standards::dsrc_2_2_1::etsi_its_dsrc; +use log::warn; + +use crate::cache::Cachable; +use crate::geo_alg; + +#[derive(Debug, Clone)] +#[allow(unused)] +pub struct Intersection { + id: u16, + /// reference point as lon/lat + ref_point: geo_types::Point, + /// guessed center point as lon/lat + center: geo_types::Point, + lanes: Vec, + signal_groups: Vec, + + // internal state + num_layers: u8, + layers_present: Vec, + is_complete: bool, + last_spat_millis: u16, +} + +impl Cachable for Intersection { + fn key(&self) -> u16 { + self.id + } +} + +impl core::fmt::Display for Intersection { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + let lanes = self.lanes.iter().fold(String::new(), |mut acc, i| { + use core::fmt::Write; + let _ = writeln!(acc, " {i}"); + acc + }); + + write!( + f, + "Intersection {} {{ ref: {:?}, \n{lanes}}}", + self.id, self.ref_point + ) + } +} + +#[allow(unused)] +impl Intersection { + pub fn new(layer_id: Option, value: &etsi_its_dsrc::IntersectionGeometry) -> Self { + let id = value.id.id.0; + let ref_point = value.ref_point.clone().into(); + let lanes = value + .lane_set + .0 + .iter() + .filter_map(|v| match Lane::new(v, &ref_point) { + Ok(lane) => Some(lane), + Err(err) => { + warn!("{err}"); + None + } + }) + .collect::>(); + + let (num_layers, layers_present, is_complete) = if let Some(layer_id) = layer_id { + let (num_layers, layer_num) = Self::layer_num_and_count(layer_id); + let complete = num_layers == 1; // just to be save from stupid input data + + (num_layers, alloc::vec![layer_num], complete) + } else { + (1, alloc::vec![1], true) + }; + + let signal_groups = if is_complete { + Self::collect_signal_groups(&lanes) + } else { + Vec::default() + }; + + let center = if is_complete { + Self::calculate_center(&ref_point, &lanes) + } else { + geo_types::Point::default() + }; + + Self { + id, + ref_point, + center, + lanes, + signal_groups, + num_layers, + layers_present, + is_complete, + last_spat_millis: 0, + } + } + + pub fn add_layer(&mut self, layer_id: Option, value: &etsi_its_dsrc::IntersectionGeometry) { + if self.is_complete { + return; + } + let Some(layer_id) = layer_id else { + return; + }; + let (_, layer_num) = Self::layer_num_and_count(layer_id); + + // determine if we already have this layer + if self.layers_present.contains(&layer_num) { + return; + } + + // add lanes + for lane in &value.lane_set.0 { + let lane_id = lane.lane_id.0; + + if self.lanes.iter().find(|i| i.id == lane_id).is_none() { + match Lane::new(lane, &self.ref_point) { + Ok(lane) => self.lanes.push(lane), + Err(err) => { + warn!("{err}"); + } + } + } + } + + self.layers_present.push(layer_num); + + // we can just count the present layers since we made sure to not add already known ones beforehand + if self.layers_present.len() == self.num_layers as usize { + self.signal_groups = Self::collect_signal_groups(&self.lanes); + self.center = Self::calculate_center(&self.ref_point, &self.lanes); + self.is_complete = true; + } + } + + pub fn add_spat(&mut self, value: &etsi_its_dsrc::IntersectionState, year: i32) { + if let Some(millis) = &value.time_stamp { + if self.last_spat_millis == millis.0 { + // skip this SPATEM since we already processed one with the same timestamp + return; + } + self.last_spat_millis = millis.0; + } + + if let Some(moy) = &value.moy { + for state in &value.states.0 { + let sig_grp = state.signal_group.0; + + if let Some(event) = state.state_time_speed.0.first() + && let Some(timing) = &event.timing + { + // get write access to SignalGroup item + if let Some(idx) = self.signal_groups.iter().position(|i| i.id == sig_grp) + && let Some(sig_grp) = self.signal_groups.get_mut(idx) + { + sig_grp.phase = Some(event.event_state); + sig_grp.min_end_time = timing.min_end_time.to_datetime_from_moy(moy, year); + } + } else { + // warn!("SPAT: SG {sig_grp:03} has no timing in first event"); + } + } + } + } + + fn collect_signal_groups(lanes: &[Lane]) -> Vec { + let mut connections = lanes + .iter() + .flat_map(|i| i.connections.clone()) + .filter(|i| i.signal_group.is_some()) + .collect::>(); + + connections.sort_by_key(|i| i.signal_group.unwrap_or_default()); + + let chunks = connections.chunk_by(|a, b| a.signal_group == b.signal_group); + chunks + .map(|chunk| { + let (sig_grp_id, maneuvers, mut target_lanes) = chunk.iter().fold( + (None, Maneuvers::default(), vec![]), + |(sig_grp_id, maneuvers, mut lanes), i| { + let new_maneuvers = i + .maneuvers + .map(|v| core::ops::Add::add(maneuvers, v)) + .unwrap_or_default(); + lanes.push(i.target_lane_id); + + (i.signal_group, new_maneuvers, lanes) + }, + ); + + // unwrap is fine since we removed all connections without signal group beforehand + let id = sig_grp_id.unwrap(); + let maneuvers = if maneuvers.is_emtpy() { + None + } else { + Some(maneuvers) + }; + target_lanes.sort_unstable(); + // TODO: dedup as well!? + + SignalGroup { + id, + maneuvers, + target_lanes, + phase: None, + min_end_time: chrono::DateTime::default(), + } + }) + .collect() + } + + fn calculate_center(ref_pos: &geo_types::Point, lanes: &[Lane]) -> geo_types::Point { + let (sum, count) = lanes.iter().fold( + (geo_types::Point::default(), 0), + |(center_sum, count), lane| { + // only use vehicle lanes + if lane.usages.contains(&UsageType::Vehicle) + && let Some(stop_line) = lane.get_stop_line() + { + (center_sum + stop_line.into(), count + 1) + } else { + (center_sum, count) + } + }, + ); + + // convert from X/Y to geo coordinate + let center_xy = sum.div(f64::from(count)); + + geo_alg::dcart_to_lonlat(ref_pos, ¢er_xy) + } + + fn layer_num_and_count(layer_id: u8) -> (u8, u8) { + let layer_num = layer_id % 10; + let num_layers = (layer_id - layer_num) / 10; + + (num_layers, layer_num) + } + + pub fn is_complete(&self) -> bool { + self.is_complete + } + + pub fn id(&self) -> u16 { + self.id + } + pub fn ref_point(&self) -> geo_types::Point { + self.ref_point + } + pub fn center(&self) -> geo_types::Point { + self.center + } + pub fn lanes(&self) -> &[Lane] { + &self.lanes + } + pub fn signal_groups(&self) -> &[SignalGroup] { + &self.signal_groups + } + + pub fn lane(&self, lane_id: u8) -> Option<&Lane> { + self.lanes.iter().find(|i| i.id == lane_id) + } + + pub fn ingress_lanes(&self) -> Vec<&Lane> { + self.lanes.iter().filter(|i| i.is_ingress).collect() + } + + pub fn bikeable_ingress_lanes(&self) -> Vec<&Lane> { + self.lanes + .iter() + .filter(|i| i.is_ingress && i.usages.contains(&UsageType::Bike)) + .collect() + } + + pub fn approach_lanes(&self, approach_id: u8) -> Vec<&Lane> { + self.lanes + .iter() + .filter(|i| i.approach_id.is_some_and(|i| i == approach_id)) + .collect() + } +} + +#[derive(Debug, Clone)] +#[allow(unused)] +pub struct Lane { + id: u8, + approach_id: Option, + /// lane nodes as local X/Y coordinates, X to the North, Y to the East + nodes: geo_types::LineString, + is_ingress: bool, + is_egress: bool, + usages: Vec, + connections: Vec, + maneuvers: Vec, +} + +impl core::fmt::Display for Lane { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + let type_str = match (self.is_ingress, self.is_egress) { + (true, true) => "IE", + (true, false) => "I", + (false, true) => "E", + (false, false) => "", + }; + let appr_str = if let Some(appr) = self.approach_id { + format!("{appr}") + } else { + String::new() + }; + let conns_str: String = self.connections.iter().fold(String::new(), |mut acc, i| { + use core::fmt::Write; + let _ = write!(acc, "{i}"); + acc + }); + + write!( + f, + "Lane {} ({type_str}-{appr_str}, {:?}) -> [{conns_str}]", + self.id, self.usages + ) + } +} + +#[derive(Debug, Copy, Clone)] +#[allow(unused)] +pub struct Connection { + id: Option, + target_lane_id: u8, + maneuvers: Option, + signal_group: Option, +} + +impl core::fmt::Display for Connection { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + let id = self.id.unwrap_or_default(); + let sig_str = match self.signal_group { + Some(sig) => format!("sig. {sig}"), + None => String::new(), + }; + + write!(f, "{{ conn. {id}: to {}, {sig_str} }}", self.target_lane_id) + } +} + +#[allow(unused)] +impl Connection { + pub fn id(&self) -> Option { + self.id + } + pub fn maneuvers(&self) -> Option { + self.maneuvers + } + pub fn signal_group(&self) -> Option { + self.signal_group + } +} + +impl From<&etsi_its_dsrc::Connection> for Connection { + fn from(value: &etsi_its_dsrc::Connection) -> Self { + let id = value.connection_id.as_ref().map(|v| v.0); + let target_lane_id = value.connecting_lane.lane.0; + let maneuvers = value + .connecting_lane + .maneuver + .as_ref() + .map(Maneuvers::from_dsrc); + let signal_group = value.signal_group.as_ref().map(|v| v.0); + + Self { + id, + target_lane_id, + maneuvers, + signal_group, + } + } +} + +/// Different view on a lane connection +/// +/// Technically this data is redundant with the `Connection` data of a `Lane`, +/// but it makes processing SPAT information easier, if signal group maneuvers can be accessed directly and not my collecting them from all lane connections. +#[derive(Debug, Clone)] +#[allow(unused)] +pub struct SignalGroup { + id: u8, + maneuvers: Option, + target_lanes: Vec, // mainly for debugging + + // SPAT data + /// current signal phase + phase: Option, + min_end_time: chrono::DateTime, + // max_end_time: Option>, + // likely_time: Option>, +} + +impl core::fmt::Display for SignalGroup { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + let manv_str = self.maneuvers.map(|v| format!(" {v}")).unwrap_or_default(); + let phase_str = self.phase_to_str().unwrap_or_default(); + write!( + f, + "SG {:2} {{{manv_str} to {:?} {phase_str}}}", + self.id, self.target_lanes + ) + } +} + +#[allow(unused)] +impl SignalGroup { + pub fn id(&self) -> u8 { + self.id + } + pub fn maneuvers(&self) -> Option { + self.maneuvers + } + pub fn phase(&self) -> Option { + self.phase + } + pub fn min_end_time(&self) -> chrono::prelude::DateTime { + self.min_end_time + } + + pub fn phase_to_str(&self) -> Option { + self.phase.map(|v| { + match v { + etsi_its_dsrc::MovementPhaseState::unavailable => "n.a.", + etsi_its_dsrc::MovementPhaseState::dark => "dark", + etsi_its_dsrc::MovementPhaseState::stop_Then_Proceed => "stop", + etsi_its_dsrc::MovementPhaseState::stop_And_Remain => "RED", + etsi_its_dsrc::MovementPhaseState::pre_Movement => "PRE", + etsi_its_dsrc::MovementPhaseState::permissive_Movement_Allowed + | etsi_its_dsrc::MovementPhaseState::protected_Movement_Allowed => "GRN", + etsi_its_dsrc::MovementPhaseState::permissive_clearance + | etsi_its_dsrc::MovementPhaseState::protected_clearance => "YEL", + + etsi_its_dsrc::MovementPhaseState::caution_Conflicting_Traffic => "blnk", + } + .to_string() + }) + } +} + +#[derive(Debug, Clone)] +#[allow(unused)] +pub struct SignalPhase { + state: etsi_its_dsrc::SignalStatus, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +#[allow(unused)] +pub enum UsageType { + /// vehicle lanes that are not limited to Bus or Taxi use, nor restricted from public use + Vehicle, + /// vehicle lanes that are restricted to bus use or vehicle lanes shared with bus vehicle traffic + Bus, + /// tracked-vehicle lanes or vehicle lanes shared with tracked vehicle traffic + Tram, + /// bike-lanes or vehicle lanes shared with cyclist vehicle traffic + Bike, + /// crosswalks or sidewalks + Pedestrian, +} + +#[derive(Debug, Default, Copy, Clone, PartialEq, Eq)] +pub struct Maneuvers { + left_allowed: bool, + staight_allowed: bool, + right_allowed: bool, +} + +impl core::fmt::Display for Maneuvers { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!( + f, + "Maneuvers {{ {}{}{} }}", + if self.left_allowed { "l" } else { "_" }, + if self.staight_allowed { "s" } else { "_" }, + if self.right_allowed { "r" } else { "_" }, + ) + } +} + +impl PartialOrd for Maneuvers { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} +impl Ord for Maneuvers { + fn cmp(&self, other: &Self) -> core::cmp::Ordering { + if self.eq(other) { + core::cmp::Ordering::Equal + } else { + #[allow(clippy::unnested_or_patterns)] + match (self.left_allowed, self.staight_allowed, self.right_allowed) { + // left (or none) < everything + (true, false, false) | (false, false, false) => core::cmp::Ordering::Less, + // left and straight > (just left) + (true, true, false) => { + if other.eq(&Maneuvers::new(true, false, false)) { + core::cmp::Ordering::Greater + } else { + core::cmp::Ordering::Less + } + } + // omni > (anything without right) + (true, true, true) | (true, false, true) => { + if other.right_allowed { + core::cmp::Ordering::Less + } else { + core::cmp::Ordering::Greater + } + } + // (only straight) > omni + (false, true, false) => { + if other.eq(&Maneuvers::new(true, true, true)) { + core::cmp::Ordering::Greater + } else { + core::cmp::Ordering::Less + } + } + // (straight and right) < right + (false, true, true) => { + if other.eq(&Maneuvers::new(false, false, true)) { + core::cmp::Ordering::Less + } else { + core::cmp::Ordering::Greater + } + } + // right > alles + (false, false, true) => core::cmp::Ordering::Greater, + } + } + } +} + +impl core::ops::Add for Maneuvers { + type Output = Maneuvers; + + fn add(self, rhs: Self) -> Self::Output { + Self::Output { + staight_allowed: self.staight_allowed || rhs.staight_allowed, + left_allowed: self.left_allowed || rhs.left_allowed, + right_allowed: self.right_allowed || rhs.right_allowed, + } + } +} + +#[allow(unused)] +impl Maneuvers { + pub fn new(left_allowed: bool, staight_allowed: bool, right_allowed: bool) -> Self { + Self { + left_allowed, + staight_allowed, + right_allowed, + } + } + + pub fn from_dsrc(value: &etsi_its_dsrc::AllowedManeuvers) -> Self { + let left_allowed = + value.get_maneuver_left_allowed() || value.get_maneuver_left_turn_on_red_allowed(); + let right_allowed = + value.get_maneuver_right_allowed() || value.get_maneuver_right_turn_on_red_allowed(); + + Self { + left_allowed, + staight_allowed: value.get_maneuver_straight_allowed(), + right_allowed, + } + } + + pub fn is_emtpy(self) -> bool { + !self.left_allowed && !self.staight_allowed && !self.right_allowed + } +} + +#[allow(unused)] +impl Lane { + /// Creates a `Lane` from C-ITS data + pub fn new( + lane: &etsi_its_dsrc::GenericLane, + ref_pos: &geo_types::Point, + ) -> Result { + let etsi_its_dsrc::NodeListXY::nodes(node_set_xy) = &lane.node_list else { + return Err(format!( + "Lane::new(): ComputedLane not supported (used in lane {})", + lane.lane_id.0 + )); + }; + + let nodes_enu = Self::node_set_to_line_string_xy(node_set_xy, *ref_pos); + + // convert to NED instead of ENU + let nodes = nodes_enu + .points() + .map(|p| geo_types::Point::new(p.y(), p.x())) + .collect(); + + let usages = UsageType::get_usages(&lane.lane_attributes)?; + let connections = lane + .connects_to + .as_ref() + .map(|v| v.0.iter().map(core::convert::Into::into).collect()) + .unwrap_or_default(); + + let maneuvers = alloc::vec![]; + + let is_ingress = lane.lane_attributes.directional_use.get_ingress_path(); + let is_egress = lane.lane_attributes.directional_use.get_egress_path(); + + Ok(Self { + id: lane.lane_id.0, + approach_id: lane.ingress_approach.as_ref().map(|v| v.0), + nodes, + usages, + connections, + maneuvers, + is_ingress, + is_egress, + }) + } + + fn node_set_to_line_string_xy( + data: &etsi_its_dsrc::NodeSetXY, + ref_pos: geo_types::Point, + ) -> geo_types::LineString { + let mut prev_delta = geo_types::Point::default(); + let mut prev_ref_pos = ref_pos; + data.0 + .iter() + .map(|pt| { + let node = match &pt.delta { + etsi_its_dsrc::NodeOffsetPointXY::node_LatLon(node_llm_d64b) => { + let result = pt.delta.to_ddist(&prev_ref_pos); + + // reference position needs to be the last `node_LatLon` + prev_ref_pos = geo_types::Point::new( + node_llm_d64b.lon.as_deg(), + node_llm_d64b.lat.as_deg(), + ); + result + } + _ => prev_delta + pt.delta.to_ddist(&ref_pos), + }; + prev_delta = node; + + node + }) + .collect() + } + + pub fn id(&self) -> u8 { + self.id + } + pub fn approach_id(&self) -> Option { + self.approach_id + } + pub fn is_ingress(&self) -> bool { + self.is_ingress + } + pub fn nodes(&self) -> &geo_types::LineString { + &self.nodes + } + pub fn usages(&self) -> &[UsageType] { + &self.usages + } + pub fn connections(&self) -> &[Connection] { + &self.connections + } + pub fn maneuvers(&self) -> &[Maneuvers] { + &self.maneuvers + } + + pub fn get_stop_line(&self) -> Option { + self.nodes.0.first().copied() + } + + /// Determines average heading + pub fn get_heading(&self) -> f32 { + // unwrap is fine since a lane always contains at least 2 nodes + let stop_line = self.nodes.points().next().unwrap(); + // unwrap is fine since a lane always contains at least 2 nodes + let second_point = self.nodes.points().next_back().unwrap(); + + #[allow(clippy::cast_possible_truncation)] + let average_heading = geo_alg::cartesian_bearing(second_point, stop_line) as f32; + + average_heading + } + + /// Determines heading at stop line and average + pub fn get_headings(&self) -> (f32, f32) { + // unwrap is fine since a lane always contains at least 2 nodes + let stop_line = self.nodes.points().next().unwrap(); + // unwrap is fine since a lane always contains at least 2 nodes + let second_point = self.nodes.points().next().unwrap(); + // unwrap is fine since a lane always contains at least 2 nodes + let entry_point = self.nodes.points().next_back().unwrap(); + + #[allow(clippy::cast_possible_truncation)] + let stop_line_heading = geo_alg::cartesian_bearing(second_point, stop_line) as f32; + #[allow(clippy::cast_possible_truncation)] + let average_heading = geo_alg::cartesian_bearing(entry_point, stop_line) as f32; + + (stop_line_heading, average_heading) + } +} + +#[allow(dead_code)] +impl UsageType { + /// Determine possible usages of the lane + /// + /// Lanes may be used my multiple classes of traffic at once, so this needs to + /// return a vector of [`UsageType`]. + /// + /// # Errors + /// Returns display string of the [`LaneAttributes`], if no suitable lane class was found + pub fn get_usages(lane_attrs: &etsi_its_dsrc::LaneAttributes) -> Result, String> { + let mut result = alloc::vec![]; + if Self::is_vehicle_useable(lane_attrs) { + result.push(Self::Vehicle); + } + if Self::is_bus_useable(lane_attrs) { + result.push(Self::Bus); + } + if Self::is_tram_useable(lane_attrs) { + result.push(Self::Tram); + } + if Self::is_bike_useable(lane_attrs) { + result.push(Self::Bike); + } + if Self::is_pedestrian_useable(lane_attrs) { + result.push(Self::Pedestrian); + } + + if result.is_empty() { + Err(format!("Unsupported LaneAttributes: {lane_attrs}")) + } else { + Ok(result) + } + } + + /// See [`UsageType::Vehicle`] + fn is_vehicle_useable(lane_attrs: &etsi_its_dsrc::LaneAttributes) -> bool { + match &lane_attrs.lane_type { + etsi_its_dsrc::LaneTypeAttributes::vehicle(attrs) => { + !attrs.get_restricted_to_bus_use() + && !attrs.get_restricted_to_taxi_use() + && !attrs.get_restricted_from_public_use() + } + _ => false, + } + } + + /// See [`UsageType::Bus`] + fn is_bus_useable(lane_attrs: &etsi_its_dsrc::LaneAttributes) -> bool { + match &lane_attrs.lane_type { + etsi_its_dsrc::LaneTypeAttributes::vehicle(attrs) => { + attrs.get_restricted_to_bus_use() | lane_attrs.shared_with.get_bus_vehicle_traffic() + } + _ => false, + } + } + + /// See [`UsageType::Tram`] + fn is_tram_useable(lane_attrs: &etsi_its_dsrc::LaneAttributes) -> bool { + match &lane_attrs.lane_type { + etsi_its_dsrc::LaneTypeAttributes::trackedVehicle(_) => true, + etsi_its_dsrc::LaneTypeAttributes::vehicle(_) => { + lane_attrs.shared_with.get_tracked_vehicle_traffic() + } + _ => false, + } + } + + /// See [`UsageType::Bike`] + fn is_bike_useable(lane_attrs: &etsi_its_dsrc::LaneAttributes) -> bool { + match &lane_attrs.lane_type { + etsi_its_dsrc::LaneTypeAttributes::bikeLane(_) => true, + // etsi_its_dsrc::LaneTypeAttributes::sidewalk(attrs) => { + // attrs.get_bicycle_use_allowed() + // } + etsi_its_dsrc::LaneTypeAttributes::vehicle(_) => { + lane_attrs.shared_with.get_cyclist_vehicle_traffic() + } + _ => false, + } + } + + /// See [`UsageType::Pedestrian`] + fn is_pedestrian_useable(lane_attrs: &etsi_its_dsrc::LaneAttributes) -> bool { + match &lane_attrs.lane_type { + etsi_its_dsrc::LaneTypeAttributes::crosswalk(_) + | etsi_its_dsrc::LaneTypeAttributes::sidewalk(_) => true, + // etsi_its_dsrc::LaneTypeAttributes::vehicle(_) => { + // lane_attrs.shared_with.get_pedestrian_traffic() + // || lane_attrs.shared_with.get_pedestrians_traffic() + // } + _ => false, + } + } +} + +#[cfg(test)] +mod tests { + use c_its_parser::standards::dsrc_2_2_1::etsi_its_dsrc::IntersectionGeometry; + use c_its_parser::standards::mapem_2_2_1::mapem_pdu_descriptions; + + use super::*; + use crate::init_test_env_logger; + use crate::testdata::{ + MAPEM_HH_560, + MAPEM_HH_1328, + MAPEM_HH_2349_L31, + MAPEM_HH_2349_L32, + MAPEM_HH_2349_L33, + }; + + #[test] + fn map_simple() { + let etsi = decode_mapem(MAPEM_HH_1328); + + let layer_id = etsi.map.layer_id.map(|v| v.0); + if let Some(map) = &etsi.map.intersections { + for geom in &map.0 { + let intersection = Intersection::new(layer_id, geom); + + assert!(intersection.is_complete()); + assert_ne!(geo_types::Point::default(), intersection.center()); + + // validate that center position is reasonable + let ref_pos_dist = + geo_alg::haversine_dist(&intersection.center, &intersection.ref_point); + println!( + "Map ref {:?}, center {ref_pos_dist:.1} m away", + intersection.ref_point + ); + assert!(ref_pos_dist < 15.); // for MAP 1328 the reference point is ~12 meters from the intersection center + } + } else { + panic!("MAPEM is missing intersection data") + } + + // forge a layered mapem + let layer_id = Some(11); + if let Some(map) = &etsi.map.intersections { + for geom in &map.0 { + let intersection = Intersection::new(layer_id, geom); + + assert!(intersection.is_complete()); + assert_ne!(geo_types::Point::default(), intersection.center()); + } + } else { + panic!("MAPEM is missing intersection data") + } + + let layer_id = Some(22); + if let Some(map) = &etsi.map.intersections { + for geom in &map.0 { + let intersection = Intersection::new(layer_id, geom); + + assert!(!intersection.is_complete()); + assert_eq!(geo_types::Point::default(), intersection.center()); + } + } else { + panic!("MAPEM is missing intersection data") + } + } + + #[test] + fn map_layered() { + let etsi_layer31 = decode_mapem(MAPEM_HH_2349_L31); + let etsi_layer32 = decode_mapem(MAPEM_HH_2349_L32); + let etsi_layer33 = decode_mapem(MAPEM_HH_2349_L33); + + // add some layer + let (layer_id, geom) = extract_geom(&etsi_layer32); + let mut intersection = Intersection::new(layer_id, geom); + assert!(!intersection.is_complete()); + assert_eq!(geo_types::Point::default(), intersection.center()); + + // add a second layer + { + let num_lanes_before = intersection.lanes().len(); + + let (layer_id, geom) = extract_geom(&etsi_layer33); + intersection.add_layer(layer_id, geom); + assert!(!intersection.is_complete()); + assert_eq!(geo_types::Point::default(), intersection.center()); + + let num_lanes_after = intersection.lanes().len(); + assert!(num_lanes_before <= num_lanes_after); + } + + // add first layer again + { + let num_lanes_before = intersection.lanes().len(); + + let (layer_id, geom) = extract_geom(&etsi_layer32); + intersection.add_layer(layer_id, geom); + assert!(!intersection.is_complete()); + assert_eq!(geo_types::Point::default(), intersection.center()); + + let num_lanes_after = intersection.lanes().len(); + assert!(num_lanes_before == num_lanes_after); + } + + // add final layer + { + let num_lanes_before = intersection.lanes().len(); + + let (layer_id, geom) = extract_geom(&etsi_layer31); + intersection.add_layer(layer_id, geom); + assert!(intersection.is_complete()); + assert_ne!(geo_types::Point::default(), intersection.center()); + + let num_lanes_after = intersection.lanes().len(); + assert!(num_lanes_before <= num_lanes_after); + } + } + + #[test] + fn map_signal_groups() { + init_test_env_logger(); + + let etsi = decode_mapem(MAPEM_HH_1328); + let layer_id = etsi.map.layer_id.map(|v| v.0); + if let Some(map) = &etsi.map.intersections { + for geom in &map.0 { + let intersection = Intersection::new(layer_id, geom); + + assert!(intersection.is_complete()); + assert_eq!(2, intersection.signal_groups.len()); + } + } else { + panic!("MAPEM is missing intersection data") + } + + let etsi = decode_mapem(MAPEM_HH_560); + let layer_id = etsi.map.layer_id.map(|v| v.0); + if let Some(map) = &etsi.map.intersections { + for geom in &map.0 { + let intersection = Intersection::new(layer_id, geom); + + assert!(intersection.is_complete()); + assert_eq!(10, intersection.signal_groups.len()); + } + } else { + panic!("MAPEM is missing intersection data") + } + } + + fn decode_mapem(uper: &[u8]) -> Box { + if let c_its_parser::ItsMessage::Mapem { + geonetworking: _, + transport: _, + etsi, + } = c_its_parser::de::decode(uper, c_its_parser::Headers::None).unwrap() + { + etsi + } else { + panic!("Unexpected C-ITS message") + } + } + + fn extract_geom( + etsi: &Box, + ) -> (Option, &IntersectionGeometry) { + let layer_id = etsi.map.layer_id.as_ref().map(|v| v.0); + if let Some(map) = &etsi.map.intersections { + // just assume we only have one intersection per mapem + (layer_id, map.0.first().unwrap()) + } else { + panic!("MAPEM is missing intersection data") + } + } + + #[test] + fn maneuver_order() { + let left = Maneuvers::new(true, false, false); + let straight_and_left = Maneuvers::new(true, true, false); + let straight = Maneuvers::new(false, true, false); + let omni = Maneuvers::new(true, true, true); + let straight_and_right = Maneuvers::new(false, true, true); + let right = Maneuvers::new(false, false, true); + + let maneuvers = vec![ + right, + omni, + straight, + left, + straight_and_left, + straight_and_right, + ]; + + let mut sorted = maneuvers.clone(); + sorted.sort(); + + println!("Sorted maneuvers:"); + for manv in &sorted { + println!("- {manv}"); + } + + assert_eq!(left, sorted[0]); + assert_eq!(straight_and_left, sorted[1]); + assert_eq!(omni, sorted[2]); + assert_eq!(straight, sorted[3]); + assert_eq!(straight_and_right, sorted[4]); + assert_eq!(right, sorted[5]); + } +} diff --git a/src/applogic/v2x/mod.rs b/src/applogic/v2x/mod.rs new file mode 100644 index 0000000..8f027ce --- /dev/null +++ b/src/applogic/v2x/mod.rs @@ -0,0 +1,4 @@ +//! Types for V2X data handling + +#[cfg(feature = "spat")] +pub mod map; diff --git a/src/cache.rs b/src/cache.rs new file mode 100644 index 0000000..7cdb138 --- /dev/null +++ b/src/cache.rs @@ -0,0 +1,221 @@ +//! Data Cache +//! +//! Store items for some time + +use alloc::rc::Rc; +use alloc::vec::Vec; +use core::ops::Sub; + +pub trait Cachable +where + K: core::cmp::PartialEq, +{ + fn key(&self) -> K; +} + +struct CacheItem +where + T: Cachable, + K: core::cmp::PartialEq, +{ + timestamp: chrono::NaiveDateTime, + data: Rc>, + phantom: core::marker::PhantomData, // rust wants this +} + +impl core::fmt::Display for CacheItem +where + T: Cachable, + K: core::cmp::PartialEq + core::fmt::Display, +{ + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!( + f, + "CacheItem({}, {:?})", + self.data.borrow().key(), + self.timestamp + ) + } +} + +impl CacheItem +where + T: Cachable, + K: core::cmp::PartialEq, +{ + fn new(time_now: chrono::NaiveDateTime, data: T) -> Self { + Self { + timestamp: time_now, + data: Rc::new(core::cell::RefCell::new(data)), + phantom: core::marker::PhantomData, + } + } +} + +#[derive(Debug, Clone)] +pub enum CacheError +where + K: core::fmt::Display, +{ + KeyNotFound(K), + InsertKeyAlreadyPresent(K), +} + +impl core::fmt::Display for CacheError +where + K: core::fmt::Display, +{ + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + CacheError::KeyNotFound(key) => write!(f, "Key '{key}' doesn't exist in cache"), + CacheError::InsertKeyAlreadyPresent(key) => { + write!(f, "Insert failed b/c key '{key}' already exists in cache") + } + } + } +} + +/// Item cache +/// +/// Stores items for a certain time. +/// Items need to implement `Cachable` so that an itentifier (key) can be retreived from them. +pub struct Cache +where + T: Cachable, + K: core::cmp::PartialEq, +{ + lifetime: chrono::Duration, + items: Vec>, +} + +impl core::fmt::Display for Cache +where + T: Cachable, + K: core::cmp::PartialEq + core::fmt::Display, +{ + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "Cache({}, {} items)", self.lifetime, self.items.len()) + } +} + +#[allow(unused)] +impl Cache +where + T: Cachable + Clone, + K: core::cmp::PartialEq + core::fmt::Display, +{ + /// Creates a new cache with a certain item lifetime + pub fn new(lifetime: chrono::Duration) -> Self { + Self { + lifetime, + items: alloc::vec![], + } + } + + /// Determines if a key is in the cache + pub fn contains(&mut self, key: &K) -> bool { + self.items + .iter() + .find(|i| i.data.borrow().key() == *key) + .is_some() + } + + /// Retrieves all known keys + pub fn keys(&self) -> Vec { + self.items.iter().map(|i| i.data.borrow().key()).collect() + } + + /// Adds a new item to the cache + /// + /// Call [`Self::update`] to update the contents of the cached item + /// or just to bump the timestamp. + /// + /// # Errors + /// Fails if the specified key already exist + pub fn insert( + &mut self, + time_now: chrono::NaiveDateTime, + data: T, + ) -> Result<(), CacheError> { + let key = data.key(); + if self.contains(&key) { + Err(CacheError::InsertKeyAlreadyPresent(key)) + } else { + self.items.push(CacheItem::new(time_now, data)); + Ok(()) + } + } + + /// Retrieves an item from the cache + /// + /// Returns `None` if no item exists for this key + pub fn get(&mut self, key: &K) -> Option>> { + self.items.iter().find_map(|i| { + if i.data.borrow().key() == *key { + Some(i.data.clone()) + } else { + None + } + }) + } + + /// Retrieves items from the cache which match the predicate + pub fn get_filtered

(&self, predicate: P) -> Vec>> + where + P: Fn(&T) -> bool, + { + self.items + .iter() + .filter_map(|i| { + let data = i.data.borrow(); + if predicate(&data) { + Some(i.data.clone()) + } else { + None + } + }) + .collect() + } + + /// Updates an item in the cache using the provided function + /// + /// This can also be used to just bump the timestamp of the cached item by providing an empty closure. + /// + /// # Errors + /// Fails if the specified key does not exist in the cache + pub fn update( + &mut self, + time_now: chrono::NaiveDateTime, + key: K, + func: F, + ) -> Result<(), CacheError> + where + F: Fn(&mut T), + K: core::fmt::Display, + { + // find item + let Some(pos) = self.items.iter().position(|i| i.data.borrow().key() == key) else { + return Err(CacheError::KeyNotFound(key)); + }; + + // unwrap is fine since we fetched the position before + let item = self.items.get_mut(pos).unwrap(); + + let mut data = item.data.borrow_mut(); + func(&mut data); + + item.timestamp = time_now; + + Ok(()) + } + + /// Cleans items which timed out from the cache + /// + /// Call this regularly to prune old items. + pub fn prune(&mut self, time_now: chrono::NaiveDateTime) { + self.items.retain(|i| { + let age = time_now.sub(i.timestamp); + age < self.lifetime + }); + } +} diff --git a/src/geo_alg.rs b/src/geo_alg.rs new file mode 100644 index 0000000..12efd8f --- /dev/null +++ b/src/geo_alg.rs @@ -0,0 +1,446 @@ +//! Geo Algorithms +//! +//! Since the `geo` crate is not `no_std`, we need to implement the most important algorithms on our own + +use alloc::string::{String, ToString}; +use core::ops::{Mul, Sub}; + +use log::warn; +#[cfg(not(feature = "std"))] +use num_traits::float::Float; + +const EARTH_RADIUS: f64 = 6_371_008.8; // mean radius of GRS80 ellipsoid +const EARTH_CIRCUMFERENCE: f64 = 40_030_228.884; // calculated from `EARTH_RADIUS * 2 * PI` + +/// Haversine distance between two geo coordinates (in meters) +#[allow(unused)] +pub fn haversine_dist(a: &geo_types::Point, b: &geo_types::Point) -> f64 { + let theta1 = a.y().to_radians(); + let theta2 = b.y().to_radians(); + let delta_theta = (b.y() - a.y()).to_radians(); + let delta_lambda = (b.x() - a.x()).to_radians(); + let a = (delta_theta / 2.).sin().powi(2) + + theta1.cos() * theta2.cos() * (delta_lambda / 2.).sin().powi(2); + let c = 2. * a.sqrt().asin(); + EARTH_RADIUS * c +} + +/// Haversine bearing from point `a` to point `b` +#[allow(unused)] +pub fn haversine_bearing(a: &geo_types::Point, b: &geo_types::Point) -> f32 { + let (lng_a, lat_a) = (a.x().to_radians(), a.y().to_radians()); + let (lng_b, lat_b) = (b.x().to_radians(), b.y().to_radians()); + let delta_lng = lng_b - lng_a; + + #[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)] + let s = (lat_b.cos() * delta_lng.sin()) as f32; + #[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)] + let c = (lat_a.cos() * lat_b.sin() - lat_a.sin() * lat_b.cos() * delta_lng.cos()) as f32; + + let degrees = f32::atan2(s, c).to_degrees(); + (degrees + 360.0) % 360.0 +} + +/// Cartesian distance between to X/Y coordinates (in unit of the coordinates) +#[allow(unused)] +pub fn cartesian_dist(a: geo_types::Point, b: geo_types::Point) -> f64 { + let vector = b.sub(a); + cartesian_norm(vector) +} + +/// Cartesian bearing from X/Y point `a` to X/Y point `b` +#[allow(unused)] +pub fn cartesian_bearing(a: geo_types::Point, b: geo_types::Point) -> f64 { + let vector = b.sub(a); + cartesian_theta(vector) +} + +/// Cartesian vector norm (length) +#[allow(unused)] +pub fn cartesian_norm(vec: geo_types::Point) -> f64 { + f64::sqrt(vec.x() * vec.x() + vec.y() * vec.y()) +} + +/// Cartesian vector direction (polar coordinate angle) +#[allow(unused)] +pub fn cartesian_theta(vec: geo_types::Point) -> f64 { + let theta_rad = f64::atan2(vec.y(), vec.x()); + theta_rad * 180. / core::f64::consts::PI +} + +/// Normalizes an angle (in degrees) to (-180, 180] interval +#[allow(unused)] +pub fn deg_180(angle_deg: f32) -> f32 { + let full_circle_deg = 360.0; + + let angle = angle_deg % full_circle_deg; + if angle > full_circle_deg / 2. { + return angle - full_circle_deg; + } + if angle <= -full_circle_deg / 2. { + return angle + full_circle_deg; + } + angle +} + +/// Normalizes an angle (in degrees) to [0, 360) interval +#[allow(unused)] +pub fn deg_360(angle_deg: f32) -> f32 { + let full_circle_deg = 360.0; + + let angle = angle_deg % full_circle_deg; + if (angle < 0.) { + return angle + full_circle_deg; + } + angle +} + +/// Converts a geo-position (lon/lat) to cartesian coordinates relative to a reference positions +/// +/// Both `ref_pos` and `position` are expected to be lon/lat in degrees. +/// The output will be in a local X/Y coordinate system with X pointing north and Y pointing east. +#[allow(unused)] +pub fn lonlat_to_dcart( + ref_pos: &geo_types::Point, + position: &geo_types::Point, +) -> geo_types::Point { + let pos_diff = position.sub(*ref_pos); + let dlat_deg = pos_diff.y(); + let dlon_deg = pos_diff.x(); + + // latitude (north/south) degree per meter is independent from longitude + let dx = dlat_deg / 360. * EARTH_CIRCUMFERENCE; + + // longitude (east/west) degree per meter has a different value depending on the latitude + #[allow(clippy::cast_possible_truncation)] + let ref_lat_rad = ref_pos.to_radians().y(); + let dy = dlon_deg / 360. * (EARTH_CIRCUMFERENCE * ref_lat_rad.cos()); + + geo_types::Point::new(dx, dy) +} + +/// Converts a X/Y coordinate (NED relative to a reference position) to a geo-position +/// +/// The `ref_pos` is expected to be lon/lat in degrees and the output will be the same. +/// The `position` is expected to be in a local X/Y coordinate system with X pointing north and Y pointing east. +#[allow(unused)] +pub fn dcart_to_lonlat( + ref_pos: &geo_types::Point, + position: &geo_types::Point, +) -> geo_types::Point { + // note that we're using NED while c-its-parser uses ITS-G5 style ENU X/Y coordinates + // so we need to swap X and Y coordinates + + #[allow(clippy::cast_possible_truncation)] + let dx = position.y() as f32; + #[allow(clippy::cast_possible_truncation)] + let dy = position.x() as f32; + + c_its_parser::geo_utils::point_from_dxy(dx, dy, ref_pos) +} + +/// Describes how a position is related to a line segment +#[derive(Debug, Default)] +#[allow(unused)] +pub struct LinePosition { + /// Own position relative to the line segment + pub semantic: LinePositionType, + /// Distance to center line (always positive) + pub parallel_dist: f64, + /// Distance to first node (negative if `BeforeStart`) + pub perpendicular_dist: f64, +} + +#[derive(Debug, Default, PartialEq, Eq, PartialOrd, Ord)] +#[allow(unused)] +pub enum LinePositionType { + #[default] + Undefined, + BeforeStart, + InBetween, + AfterEnd, +} + +#[allow(unused)] +impl LinePosition { + pub fn new(parallel_dist: f64, perpendicular_dist: f64, after_end: bool) -> Self { + let semantic = if after_end { + LinePositionType::AfterEnd + } else if parallel_dist < 0. { + LinePositionType::BeforeStart + } else { + LinePositionType::InBetween + }; + Self { + semantic, + parallel_dist, + perpendicular_dist, + } + } + + /// Determines `point` position relative to a line segment (in X/Y coordinates) + /// + /// The `ls` shall consist of 2 points! + /// + /// # Algorithm + /// The line segment can be converted to a vector which describes an infinite line. + /// So we can calculate the (perpendicular) projection of the point on that line: + /// ```text + /// [Case A] (point) | [Case B] (point) + /// | | | + /// | | | + /// (node1)---(node2)-----(q) | (node1)------(q)------(node2) + /// ``` + /// + /// This will already give us the perpendicular and parallel distance. + /// Since we came from a finite line segment and want to know if the point is bejond the segment, + /// we also need to compare the parallel distance with the length of the vector. + /// So both case are the same mathematically. + /// + /// # Errors + /// Fails if points are the same + pub fn for_ls(point: geo_types::Point, line: &geo_types::Line) -> Result { + let node1 = line.start_point(); + let node2 = line.end_point(); + + let line_vec = node2.sub(node1); + let line_vec_length = cartesian_norm(line_vec); + + // input validation + if line_vec_length < f64::EPSILON { + return Err("Points are the same".to_string()); + } + + // calculate projection of `p` onto the line + // ```math + // # n needs to be a unit vector + // n = (node2 - node1) / |node2 - node1| + // # vector from node1 to the point + // p = point - node1 + // # projection is the unit-vector multipled by the dot-product of p and n + // q = (p {dot} n) * n + // # perpendicular length is calculated from the vector q->p + // QP = q - p + // ``` + let p = point.sub(node1); + let n = line_vec.mul(1. / line_vec_length); + let q = n.mul(p.dot(n)); + let qp_length = cartesian_norm(q.sub(p)); + + // calculate parallel distance of `q` to `node1`. + // This can be achieved by the dot-product since `n` is a unit vector and + // the dot-product of two paralle vectors is the same as the multiplication of the vector norms. + let q_dist = n.dot(q); + + // generate output + let after_end = q_dist > line_vec_length; + let line_pos = Self::new(q_dist, qp_length, after_end); + + Ok(line_pos) + } + + /// Determines `point` position relative to nodes of an ingress `Lane` + /// + /// Keep in mind, that the lane's node list starts at the stop line and is extending away from the intersection. + /// But we are driving the other way around! + /// + /// # Errors + /// Returns a human-readable string if position can't be determined + pub fn for_ingress_lane( + point: geo_types::Point, + nodes: &geo_types::LineString, + ) -> Result { + let mut result = Self::default(); + + let mut lines = nodes.lines(); + + // check, that the stop line is not behind us + let line = lines + .next() + .ok_or("Lane has less than two nodes".to_string())?; + + let pos = Self::for_ls(point, &line)?; + if pos.semantic == LinePositionType::BeforeStart { + // we have crossed the stop line -> abort search for position inside the lane + return Ok(pos); + } + if pos.semantic == LinePositionType::InBetween { + // we're at this segment -> search finished + return Ok(pos); + } + // otherwise: continue with next segment (and store this segment's length) + let mut lane_length = cartesian_norm(line.delta().into()); + let mut last_pos = LinePosition::default(); + + // continue with remaining lines + for line in lines { + let line_len = cartesian_norm(line.delta().into()); + + // ignored line, if points are too close <10cm + if line_len < 0.1 { + warn!("Lane nodes are just {line_len:.1} m apart -> ignoring"); + continue; + } + + let pos = Self::for_ls(point, &line)?; + match pos.semantic { + LinePositionType::BeforeStart => { + // we're at the outer arc of the lane right at the border between line segments + // -> set parallel distance to zero and finish search + return Ok(LinePosition::new( + lane_length, + pos.perpendicular_dist, + false, + )); + } + LinePositionType::InBetween => { + // we're at this segment + // -> add segment's distance to accumulated lane length and finish search + return Ok(LinePosition::new( + lane_length + pos.parallel_dist, + pos.perpendicular_dist, + false, + )); + } + _ => { + // continue searching, add segment's distance to lane length accumulator + lane_length += line_len; + last_pos = pos; + } + } + } + + // if we came this far, we're after the lane's end (actually the start in driving direction) + // -> add parallel distance to accumulated lane length + Ok(LinePosition::new( + lane_length + last_pos.parallel_dist, + last_pos.perpendicular_dist, + true, + )) + } +} + +#[cfg(test)] +mod tests { + use crate::geo_alg::{LinePosition, cartesian_bearing}; + + #[test] + fn test_cart_bearing() { + let start = geo_types::Point::new(0., 0.); + + assert_float_eq::assert_float_absolute_eq!( + 0., + cartesian_bearing(start, geo_types::Point::new(10., 0.)) + ); + + assert_float_eq::assert_float_absolute_eq!( + 90., + cartesian_bearing(start, geo_types::Point::new(0., 10.)) + ); + + assert_float_eq::assert_float_absolute_eq!( + 45., + cartesian_bearing(start, geo_types::Point::new(10., 10.)) + ); + + assert_float_eq::assert_float_absolute_eq!( + 135., + cartesian_bearing(start, geo_types::Point::new(-10., 10.)) + ); + + assert_float_eq::assert_float_absolute_eq!( + -135., + cartesian_bearing(start, geo_types::Point::new(-10., -10.)) + ); + } + + #[test] + fn linepos_for_ls_validation() { + // identical nodes shall result in an error + + let point = geo_types::Point::new(0., 0.); + let node1 = geo_types::Point::new(0., 0.); + let node2 = geo_types::Point::new(10., -5.); + + let ls = geo_types::Line::new(node1, node1); + assert!(LinePosition::for_ls(point, &ls).is_err()); + + let ls = geo_types::Line::new(node2, node2); + assert!(LinePosition::for_ls(point, &ls).is_err()); + } + + #[test] + fn linepos_for_ls_test() { + use crate::geo_alg::LinePositionType; + + // test on X axis + { + let node1 = geo_types::Point::new(0., 0.); + let node2 = geo_types::Point::new(5., 0.); + + let ls = geo_types::Line::new(node1, node2); + + // test some positions + let test_tuples = vec![ + (node1.x() - 1000., LinePositionType::BeforeStart), + (node1.x() - 1., LinePositionType::BeforeStart), + (node1.x(), LinePositionType::InBetween), + (node1.x() + 1., LinePositionType::InBetween), + (node2.x() - 1., LinePositionType::InBetween), + (node2.x(), LinePositionType::InBetween), + (node2.x() + 1., LinePositionType::AfterEnd), + (node2.x() + 1000., LinePositionType::AfterEnd), + ]; + let y_values = vec![(node1.y() - 1.), (node1.y()), (node1.y() + 1.)]; + + // y position should only affect the perpendicular distance + for y in &y_values { + // x position affects the position type and parallel distance + for (x, semantic) in &test_tuples { + let point = geo_types::Point::new(*x, *y); + + let result = LinePosition::for_ls(point, &ls).unwrap(); + + assert_eq!(semantic, &result.semantic); + assert_eq!((y - node1.y()).abs(), result.perpendicular_dist); + assert_eq!(x - node1.x(), result.parallel_dist); + } + } + } + + // test on Y axis + { + let node1 = geo_types::Point::new(3., 5.); + let node2 = geo_types::Point::new(3., 100.); + + let ls = geo_types::Line::new(node1, node2); + + // test some positions + let test_tuples = vec![ + (node1.y() - 1000., LinePositionType::BeforeStart), + (node1.y() - 1., LinePositionType::BeforeStart), + (node1.y(), LinePositionType::InBetween), + (node1.y() + 1., LinePositionType::InBetween), + (node2.y() - 1., LinePositionType::InBetween), + (node2.y(), LinePositionType::InBetween), + (node2.y() + 1., LinePositionType::AfterEnd), + (node2.y() + 1000., LinePositionType::AfterEnd), + ]; + let x_values = vec![(node1.x() - 1.), (node1.x()), (node1.x() + 1.)]; + + // x position should only affect the perpendicular distance + for x in &x_values { + // y position affects the position type and parallel distance + for (y, semantic) in &test_tuples { + let point = geo_types::Point::new(*x, *y); + + let result = LinePosition::for_ls(point, &ls).unwrap(); + + assert_eq!(semantic, &result.semantic); + assert_eq!((x - node1.x()).abs(), result.perpendicular_dist); + assert_eq!(y - node1.y(), result.parallel_dist); + } + } + } + } +} diff --git a/src/main.rs b/src/main.rs index 807561e..79a3024 100644 --- a/src/main.rs +++ b/src/main.rs @@ -23,65 +23,13 @@ use log::{debug, error, info, warn}; extern crate alloc; mod applogic; +#[cfg(feature = "spat")] +mod cache; +mod geo_alg; mod radio; const WIFI_CHANNEL: radio::Channel = 180; -#[derive(Debug, Default)] -struct State { - initialized: bool, - time: chrono::NaiveDateTime, - - lat_deg: f32, - lon_deg: f32, - heading_deg: Option, - speed_mps: Option, -} - -impl State { - fn update_with_gpsfix(&mut self, fix: &embassy_gps::types::GpsFix) { - #[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)] - let lat_deg = fix.latitude_deg as f32; - #[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)] - let lon_deg = fix.longitude_deg as f32; - - self.lat_deg = lat_deg; - self.lon_deg = lon_deg; - - if let Some(heading) = fix.true_course_deg { - self.heading_deg = Some(heading); - } else { - warn!("State: Course missing from GpsFix"); - } - - if let Some(speed) = fix.speed_over_ground { - self.speed_mps = Some(speed); - } else { - warn!("State: Speed missing from GpsFix"); - } - - if let Some(time) = fix.get_timestamp() { - self.time = time; - - if !self.initialized { - info!("GNSS fix: {}", self.print_fix()); - } - self.initialized = true; - } - } - - fn print_fix(&self) -> alloc::string::String { - alloc::format!( - "{:?}, {:.4} N, {:.4} E, {:.0}°, {:.2} m/s", - self.time, - self.lat_deg, - self.lon_deg, - self.heading_deg.unwrap_or_default(), - self.speed_mps.unwrap_or_default(), - ) - } -} - // This creates a default app-descriptor required by the esp-idf bootloader. // For more information see: esp_bootloader_esp_idf::esp_app_desc!(); @@ -157,7 +105,7 @@ async fn main(spawner: Spawner) -> ! { ); info!("GNSS task started, waiting for GNSS fix..."); - let mut state = State::default(); + let mut state = applogic::State::new(); loop { embassy_time::Timer::after(embassy_time::Duration::from_millis(5)).await; @@ -167,9 +115,12 @@ async fn main(spawner: Spawner) -> ! { if let Some(fix) = gnss_update_ref.replace(None) { state.update_with_gpsfix(&fix); - if let Some(time) = fix.get_timestamp() { - state.time = time; - info!("{}", state.print_fix()); // TODO: degrate to debug later? + if fix.get_timestamp().is_some() { + info!("{}", state.print_fix()); + + // run GLOSA algorithm + #[cfg(feature = "spat")] + state.run_glosa(); } } }); @@ -193,15 +144,15 @@ async fn main(spawner: Spawner) -> ! { geonetworking: _, transport: _, etsi, - } => applogic::tlc::handle_mapem(&etsi), + } => state.handle_mapem(&etsi), #[cfg(feature = "spat")] ItsMessage::Spatem { geonetworking: _, transport: _, etsi, } => { - if state.initialized { - applogic::tlc::handle_spatem(&etsi, &state.time); + if state.initialized() { + state.handle_spatem(&etsi); } } @@ -235,6 +186,9 @@ async fn main(spawner: Spawner) -> ! { } } }); + + // maintenance + state.prune(); } } diff --git a/src/test.rs b/src/test.rs index 0fb9fac..3eb42dc 100644 --- a/src/test.rs +++ b/src/test.rs @@ -1,10 +1,23 @@ #![cfg(not(target_arch = "riscv32"))] +#![allow(unused)] extern crate alloc; mod applogic; mod cache; +mod geo_alg; +mod testdata; fn main() { + // env_logger::init(); // dummy only } + +/// Initializes the env-logger to be used inside tests +fn init_test_env_logger() { + let _ = env_logger::builder() + .format_timestamp(None) + .filter_level(log::LevelFilter::Debug) + .is_test(true) + .try_init(); +} diff --git a/src/testdata.rs b/src/testdata.rs new file mode 100644 index 0000000..567410d --- /dev/null +++ b/src/testdata.rs @@ -0,0 +1,457 @@ +//! Central provider for V2X test messages +//! +//! All messages are without any headers (so they start with the ItsPduHeader) + +#![cfg(all(not(target_arch = "riscv32"), any(feature = "spat", feature = "cam")))] +#![allow(unused)] + +/// Intersection "Sievekingplatz-Südfahrbahn/Ziviljustizgebäude" +/// +/// Pedestrian crossing over a one-way street (going from west to east) +/// +/// Possible start point (heading 90° to 135°): +/// - in front: `geo_types::Point::new(9.9752864, 53.5564028)` +/// - at lane: `geo_types::Point::new(9.9760764, 53.5563621)` +/// - passed: `geo_types::Point::new(9.977217, 53.5560602)` +#[cfg(feature = "spat")] +pub const MAPEM_HH_1328: &[u8] = &[ + 0x2, 0x5, 0x0, 0x12, 0x19, 0x8c, 0x8, 0x0, 0x3, 0x9, 0xcd, 0x83, 0x42, 0xfc, 0x9a, 0x94, 0xef, + 0xb1, 0x67, 0x71, 0x8b, 0x36, 0x4e, 0x2e, 0x31, 0x60, 0xb9, 0xa4, 0x0, 0xc, 0x14, 0xc0, 0x20, + 0xab, 0x21, 0xda, 0x5c, 0xe2, 0x78, 0x72, 0x74, 0x5, 0x14, 0x18, 0x90, 0x4, 0x50, 0x80, 0x0, + 0x0, 0x85, 0xc, 0x5c, 0xa8, 0xa0, 0x0, 0x80, 0x23, 0xc1, 0x42, 0x7e, 0xa8, 0x58, 0x46, 0x1d, + 0xa0, 0x11, 0xef, 0x4d, 0xf0, 0x45, 0xa5, 0x56, 0x8, 0x2d, 0xa6, 0x2, 0x30, 0xc9, 0x26, 0x11, + 0xb7, 0xc5, 0x90, 0x84, 0xca, 0x16, 0xc1, 0x60, 0x70, 0x0, 0x2, 0x2, 0x20, 0x6, 0x44, 0x40, + 0x0, 0x0, 0x0, 0x8f, 0x5, 0xf8, 0x83, 0xed, 0x8c, 0x19, 0x20, 0x10, 0xa1, 0x0, 0x0, 0x1, 0xa, + 0x23, 0x2a, 0x63, 0x40, 0x1, 0x9, 0xb9, 0xa7, 0x40, 0x25, 0xec, 0x21, 0x17, 0xad, 0x3c, 0x8, + 0xef, 0x66, 0xfc, 0x47, 0xa1, 0x2e, 0x82, 0x39, 0x99, 0x2b, 0x11, 0xd2, 0xc7, 0x8, 0x8f, 0x32, + 0x18, 0x4, 0x70, 0x50, 0x8c, 0x2b, 0x5, 0x80, 0x0, 0x10, 0x25, 0x82, 0x40, 0x0, 0x8, 0x18, + 0x80, 0x29, 0x11, 0x0, 0x0, 0x0, 0x1, 0x17, 0x14, 0x80, 0x3f, 0x70, 0xf8, 0x88, 0x2, 0x11, + 0x10, 0x0, 0x0, 0x0, 0x11, 0x23, 0x38, 0x83, 0xe5, 0x8c, 0x90, 0x20, 0x33, 0x2, 0x4, 0x0, 0x0, + 0x1, 0x43, 0xd6, 0xe2, 0x8, 0x0, 0x20, 0x72, 0x5a, 0xd0, 0x50, 0x38, 0x10, 0x20, 0x20, 0x3b, + 0x2, 0x4, 0x0, 0x0, 0x1, 0x20, 0x23, 0x20, 0xa0, 0x0, 0x82, 0x37, 0x94, 0xc1, 0x40, 0xc0, 0x40, + 0xa0, +]; + +/// Intersection "Johannisbollwerk/Ditmar-Koel-Straße" +/// +/// Has a very complex geometry with multiple stop lines after each other. +/// Main traffic direction is east-west, has separate bike lanes. +/// +/// Possible start point (heading 90°): TODO +#[cfg(feature = "spat")] +pub const MAPEM_HH_2053: &[u8] = &[ + 0x2, 0x5, 0x0, 0x12, 0x43, 0x84, 0x8, 0x0, 0x3, 0x9, 0xcd, 0x83, 0x42, 0xfc, 0x9a, 0x94, 0xef, + 0xb2, 0x61, 0x71, 0x93, 0x6, 0xac, 0xee, 0x31, 0x66, 0xb9, 0x94, 0x0, 0xc, 0x20, 0x14, 0x10, + 0xab, 0x1e, 0x9c, 0x96, 0xe2, 0x77, 0x51, 0x1e, 0x5, 0x14, 0xc8, 0x90, 0x1c, 0xd0, 0x88, 0x0, + 0x0, 0x15, 0x8f, 0x5d, 0xb6, 0x88, 0x0, 0x20, 0xc, 0x8e, 0xb0, 0x1a, 0x4a, 0xb8, 0x2c, 0xa, + 0x0, 0x0, 0x40, 0x44, 0x0, 0x84, 0x88, 0x80, 0x0, 0x1, 0x9, 0xdd, 0xc0, 0x41, 0xdd, 0x54, 0xb0, + 0x59, 0xe9, 0x39, 0x4, 0x80, 0x64, 0x84, 0x0, 0x0, 0x0, 0xaa, 0x39, 0xc9, 0x5d, 0x0, 0x4, 0x5c, + 0x8c, 0x91, 0x84, 0xd8, 0x75, 0x3d, 0x21, 0x58, 0x34, 0x0, 0x3, 0x1, 0x2c, 0x8, 0x80, 0x0, + 0x81, 0x4, 0x1, 0x90, 0x88, 0x0, 0x0, 0x1, 0x18, 0xe6, 0x61, 0xa8, 0x13, 0xaf, 0x1d, 0xc0, + 0x98, 0xc4, 0x44, 0x80, 0x84, 0x84, 0x0, 0x0, 0x0, 0xaa, 0x11, 0x44, 0xe9, 0x0, 0x4, 0x5c, + 0x8c, 0x91, 0x84, 0xd8, 0xdd, 0x3c, 0x50, 0x58, 0x2c, 0x0, 0x3, 0x1, 0x88, 0x2, 0xa1, 0x10, + 0x0, 0x0, 0x2, 0x31, 0xa3, 0x3e, 0x34, 0x27, 0x7a, 0x38, 0x81, 0x38, 0x8a, 0xc9, 0x0, 0x45, + 0x8, 0x0, 0x0, 0x1, 0x48, 0x1a, 0xd6, 0x68, 0x0, 0x22, 0xcf, 0xe9, 0xc7, 0x1e, 0x92, 0xe7, + 0x46, 0x2b, 0x6, 0x80, 0x0, 0x30, 0x55, 0x82, 0xc0, 0x0, 0x18, 0x31, 0x21, 0x34, 0x21, 0x0, + 0x0, 0x0, 0x2b, 0x4c, 0x89, 0xc, 0x90, 0x0, 0x44, 0x3d, 0xb5, 0x5a, 0x12, 0x39, 0x48, 0xc1, + 0x65, 0x10, 0x0, 0x26, 0xe, 0x20, 0x50, 0xe4, 0x40, 0x0, 0x0, 0x8, 0xda, 0x2, 0x25, 0x30, 0xe3, + 0x85, 0x9c, 0x1e, 0x1e, 0x92, 0x89, 0x9, 0x61, 0x8, 0x0, 0x0, 0x1, 0x5a, 0x3e, 0x86, 0x18, + 0x80, 0x2, 0x21, 0xcb, 0xaa, 0x80, 0x94, 0x8a, 0x1c, 0xb, 0x29, 0x80, 0x1, 0x30, 0x81, 0x2, + 0x97, 0x22, 0x0, 0x0, 0x0, 0x46, 0xc5, 0xb0, 0x9a, 0x7, 0xc, 0x2d, 0xe0, 0xf3, 0x54, 0x88, + 0x48, 0x4f, 0x8, 0x4, 0x20, 0x0, 0x0, 0x15, 0xa2, 0x28, 0x44, 0x88, 0x80, 0x2b, 0x50, 0x87, + 0x5e, 0xa8, 0xc2, 0x1f, 0xaa, 0x0, 0x2c, 0xaa, 0x0, 0x4, 0xc2, 0x44, 0xa, 0x9c, 0x80, 0x84, + 0x0, 0x0, 0x2, 0xb5, 0xec, 0x1, 0x40, 0x11, 0x6a, 0xe, 0x75, 0x55, 0xc1, 0xdb, 0x69, 0xc4, + 0x90, 0x7d, 0x90, 0x8, 0x40, 0x0, 0x0, 0x6d, 0x5e, 0x40, 0x79, 0xb, 0x44, 0x1, 0x54, 0x46, + 0x7e, 0x8, 0x37, 0x88, 0x6f, 0x23, 0xd0, 0x44, 0x91, 0x34, 0x45, 0x5f, 0x6f, 0x8e, 0x65, 0x5, + 0x90, 0x40, 0x0, 0x48, 0x50, 0x81, 0x2, 0x90, 0x10, 0x80, 0x0, 0x0, 0x58, 0x59, 0x12, 0xe1, + 0x80, 0x8a, 0x88, 0x37, 0x26, 0x78, 0x1c, 0xf3, 0x20, 0x48, 0x2c, 0xc8, 0x40, 0x0, 0x0, 0x1b, + 0x57, 0x80, 0x1e, 0x86, 0x10, 0x0, 0x46, 0x70, 0x58, 0x42, 0x88, 0xa4, 0x23, 0xcc, 0x44, 0x4f, + 0x38, 0x44, 0x0, 0xae, 0xe2, 0x21, 0x58, 0xa4, 0x0, 0x4, 0x85, 0xac, 0x90, 0x80, 0x4, 0x83, + 0x84, 0x5, 0x14, 0x88, 0x0, 0x0, 0x1, 0x21, 0x96, 0x6b, 0xd8, 0x3, 0x6f, 0x67, 0xc1, 0xaa, + 0x35, 0x24, 0x82, 0xec, 0x84, 0x0, 0x0, 0x1, 0xb5, 0x77, 0xb1, 0xed, 0xd9, 0x0, 0x4, 0x67, + 0x6c, 0x83, 0xd0, 0x8b, 0x2a, 0x32, 0x44, 0x1c, 0x54, 0x6a, 0x40, 0x5c, 0xad, 0xb3, 0x15, 0x8a, + 0xc0, 0x0, 0x48, 0x62, 0xcb, 0xd0, 0x0, 0x44, 0x34, 0x40, 0x55, 0x48, 0x80, 0x0, 0x0, 0x12, + 0x1b, 0xf0, 0xc6, 0x68, 0x2d, 0xd7, 0x8, 0x19, 0xe3, 0x4c, 0x20, 0x5f, 0x44, 0x44, 0x0, 0x0, + 0x9, 0xf, 0x23, 0x6c, 0x98, 0x15, 0xac, 0xb6, 0xa, 0x62, 0x52, 0x10, 0x24, 0x92, 0x20, 0x0, + 0x0, 0x4, 0x84, 0x6b, 0x2a, 0xd6, 0x1c, 0x8a, 0xcf, 0x3, 0x90, 0xdc, 0xc9, 0x21, 0x1c, 0xa0, + 0x10, 0x80, 0x0, 0x0, 0x58, 0x4a, 0x82, 0xe0, 0xc8, 0x80, 0x2b, 0x50, 0x19, 0x7, 0x6c, 0x8, + 0x4, 0x82, 0xb, 0x2f, 0x20, 0x1, 0x60, 0xf2, 0x41, 0x25, 0x42, 0x0, 0x0, 0x1, 0x98, 0x22, 0xb3, + 0x96, 0xe8, 0x0, 0x20, 0x80, 0xac, 0x80, 0x73, 0x37, 0x90, 0x75, 0x54, 0xbe, 0xf, 0xdd, 0x81, + 0x60, 0xb2, 0xe0, 0x20, 0x6c, 0x59, 0x42, 0x2c, 0xb7, 0xd5, 0x0, 0x10, 0x96, 0x28, 0x20, 0x1, + 0x42, 0xb, 0x2f, 0x20, 0x0, 0xb1, 0x15, 0x8a, 0x88, 0x0, 0x50, 0xa9, 0x20, 0x9a, 0xa0, 0x10, + 0x80, 0x0, 0x0, 0xd8, 0x5, 0xcc, 0x2e, 0xe8, 0x80, 0x2a, 0x88, 0x75, 0xc4, 0x84, 0xf, 0x81, + 0x92, 0xc1, 0xf5, 0x11, 0x1c, 0x59, 0x84, 0xf8, 0x40, 0xb1, 0xa8, 0x0, 0xc, 0x12, 0x10, 0x1a, + 0x62, 0x2, 0x10, 0x0, 0x0, 0xb, 0x57, 0x61, 0xa0, 0x42, 0x1, 0x15, 0x11, 0x1e, 0x53, 0xdb, + 0x88, 0xc7, 0x21, 0xa0, 0x90, 0x41, 0x50, 0x80, 0x0, 0x0, 0x36, 0x1, 0xc7, 0x3, 0xea, 0x0, 0x8, + 0x75, 0x7c, 0x79, 0xf, 0xfb, 0x95, 0xe1, 0xef, 0x70, 0xd0, 0x59, 0xd6, 0xf5, 0xe0, 0xb1, 0x98, + 0x0, 0xc, 0x13, 0x10, 0x19, 0x62, 0x20, 0x0, 0x0, 0x4, 0xab, 0xb4, 0x4f, 0xff, 0x82, 0x3b, + 0x97, 0xb6, 0x11, 0x77, 0xc1, 0x99, 0x20, 0x8a, 0xa1, 0x0, 0x0, 0x0, 0x8c, 0x4, 0x11, 0xf4, + 0xc4, 0x0, 0x10, 0xec, 0x88, 0xfe, 0x2c, 0x2c, 0x89, 0xe0, 0xf0, 0x38, 0x5e, 0xac, 0x74, 0x7c, + 0x24, 0x0, 0x40, 0x4f, 0x6f, 0xc8, 0x2c, 0x62, 0x0, 0x3, 0x5, 0x4, 0x6, 0x18, 0x88, 0x0, 0x0, + 0x1, 0x2a, 0xed, 0xc3, 0xf5, 0xc0, 0x8e, 0xa1, 0xed, 0x84, 0x5b, 0xd0, 0x66, 0x8, 0x10, 0xc0, + 0x81, 0x0, 0x0, 0x0, 0x53, 0x6a, 0x8a, 0x3a, 0x0, 0x8, 0x1d, 0x86, 0x94, 0x14, 0x12, 0xe, 0x2c, + 0x8, 0x12, 0xc0, 0x81, 0x0, 0x0, 0x0, 0x53, 0xeb, 0x9c, 0x8a, 0x0, 0x8, 0x22, 0x89, 0x6c, 0x14, + 0x10, 0xe, 0x2e, 0x8, 0x14, 0x80, 0x42, 0x0, 0x0, 0x0, 0x52, 0xdd, 0x8e, 0xf2, 0x0, 0x8, 0x20, + 0xd6, 0x74, 0x14, 0x16, 0x10, 0x30, 0x0, 0x16, 0x40, 0x42, 0x0, 0x0, 0x0, 0x49, 0x4e, 0x21, + 0x48, 0x0, 0x20, 0x7c, 0xe6, 0x30, 0x40, 0xc6, 0x4, 0x8, 0x0, 0x0, 0x2, 0x82, 0xce, 0x9a, 0x10, + 0x0, 0x40, 0xd4, 0x45, 0xe0, 0xa0, 0xd0, 0x41, 0x90, 0x40, 0xd6, 0x4, 0x8, 0x0, 0x0, 0x2, 0x46, + 0x74, 0x3c, 0x40, 0x1, 0x4, 0xb0, 0xe8, 0x82, 0x83, 0x1, 0x6, 0x81, 0x3, 0x90, 0x8, 0x40, 0x0, + 0x0, 0x9, 0xc, 0xcb, 0xe9, 0x0, 0x4, 0x12, 0xd3, 0xaa, 0xa, 0xf, 0x5, 0x1b, 0x0, 0xf, 0x20, + 0x21, 0x0, 0x0, 0x0, 0x2c, 0x7b, 0xf1, 0xcf, 0x40, 0x1, 0x3, 0x3b, 0x7, 0x2, 0xd, 0xa0, 0x20, + 0x40, 0x0, 0x0, 0x1a, 0xbd, 0xaf, 0x2, 0xfa, 0x80, 0x2, 0x7, 0xca, 0x63, 0x5, 0xe, 0x6, 0x8e, + 0x0, 0xe, 0x10, 0x20, 0x40, 0x0, 0x0, 0x1a, 0xbe, 0x96, 0xfc, 0xb4, 0x80, 0x2, 0x8, 0x39, 0x9d, + 0x4, 0x1d, 0x40, 0x40, 0x80, 0x0, 0x0, 0x35, 0x7e, 0x35, 0xf0, 0x9d, 0x0, 0x4, 0xf, 0x8c, 0xc6, + 0xa, 0x1e, 0xe, 0x1d, 0x0, 0x1e, 0x20, 0x40, 0x80, 0x0, 0x0, 0x30, 0x1, 0x86, 0x20, 0x50, 0x0, + 0x41, 0x7, 0xb3, 0xa0, 0x84, 0x2c, 0x8, 0x10, 0x0, 0x0, 0x6, 0x9, 0xca, 0xb9, 0xea, 0x0, 0x8, + 0x19, 0xd8, 0x30, 0x14, 0x44, 0x2e, 0x3c, 0x8, 0x44, 0xc0, 0x81, 0x0, 0x0, 0x0, 0x60, 0xfe, + 0xcb, 0x6e, 0xa0, 0x0, 0x82, 0x63, 0x7d, 0x1, 0x44, 0x22, 0xe3, 0xe0, 0x85, 0x6c, 0x8, 0x10, + 0x0, 0x0, 0x5, 0xaf, 0x58, 0x78, 0xc8, 0x0, 0x20, 0x8b, 0x65, 0x90, 0x51, 0x60, 0xa1, 0x0, + 0x21, 0x63, 0x2, 0x4, 0x0, 0x0, 0x1, 0x6a, 0x5a, 0x6, 0xe2, 0x0, 0x8, 0x1d, 0x36, 0x9c, 0x14, + 0x56, 0x28, 0x42, 0x8, 0x5a, 0xc0, 0x42, 0x0, 0x0, 0x0, 0x5a, 0x4c, 0xc2, 0xc6, 0x80, 0x2, 0x7, + 0x45, 0xa8, 0x5, 0x17, 0xa, 0x91, 0x2, 0x17, 0x30, 0x10, 0x80, 0x0, 0x0, 0x16, 0xac, 0x12, + 0x2b, 0x20, 0x0, 0x82, 0x2f, 0x96, 0x1, 0x45, 0xa2, 0xa4, 0x60, 0x86, 0x8, 0x8, 0x10, 0x0, 0x0, + 0x6, 0x0, 0x3c, 0xc4, 0x5a, 0x0, 0x8, 0x20, 0xe6, 0x74, 0x14, 0x62, 0x1e, 0x48, 0x0, 0x62, + 0x40, 0x81, 0x0, 0x0, 0x0, 0x6a, 0xfc, 0xd3, 0xe1, 0x5a, 0x0, 0x8, 0x1f, 0x29, 0x8c, 0x10, + 0xc9, 0x1, 0x2, 0x0, 0x0, 0x0, 0xd5, 0xf4, 0xa7, 0xe6, 0xa4, 0x0, 0x10, 0x41, 0xac, 0xe8, 0x28, + 0xcc, 0x40, 0x94, 0x0, 0xcc, 0x81, 0x2, 0x0, 0x0, 0x0, 0xd5, 0xed, 0xe8, 0x18, 0xd4, 0x0, 0x10, + 0x3e, 0x73, 0x18, +]; + +/// Intersection "Feldstraße/Glacischaussee" +/// +/// Has a very complex geometry with multiple stop lines after each other. +/// Main traffic direction is east-west, has separate bike lanes. +/// +/// Possible start point (heading 90°): TODO +#[cfg(feature = "spat")] +pub const MAPEM_HH_560: &[u8] = &[ + 0x2, 0x5, 0x0, 0x1, 0x61, 0x2b, 0x8, 0x0, 0x3, 0x9, 0x4d, 0x83, 0x42, 0xfc, 0x9a, 0x94, 0xef, + 0xb0, 0x6b, 0x7d, 0xab, 0x66, 0x17, 0xd8, 0xb3, 0x5c, 0xda, 0x0, 0x6, 0x4, 0x60, 0x18, 0x55, + 0x91, 0x1, 0x73, 0x71, 0x3b, 0xe2, 0xcb, 0x2, 0x58, 0x56, 0x48, 0x1c, 0x28, 0x44, 0x0, 0x0, + 0x32, 0xbb, 0xbd, 0x9a, 0x91, 0x0, 0x61, 0x90, 0x6e, 0x15, 0x40, 0x3c, 0x5c, 0xac, 0x39, 0x63, + 0x28, 0x8e, 0xcc, 0x98, 0x51, 0xe, 0x55, 0xe1, 0x2, 0xd0, 0x3d, 0xd2, 0xe, 0x4, 0xe8, 0x22, + 0xb0, 0xa2, 0x0, 0x1, 0x7, 0x58, 0x49, 0x0, 0x0, 0x84, 0x8, 0x5, 0x21, 0x11, 0x0, 0x0, 0x2, + 0x21, 0x1f, 0x51, 0xf0, 0x9e, 0xd9, 0x70, 0x12, 0xab, 0x42, 0xc4, 0x2, 0x50, 0x8c, 0x80, 0x0, + 0x1, 0x10, 0x5e, 0xa0, 0x20, 0x4f, 0x4c, 0xba, 0x9, 0x52, 0xa2, 0xe4, 0x81, 0x8, 0x84, 0x40, + 0x0, 0x2, 0xad, 0x36, 0xa3, 0x6c, 0x40, 0x1, 0x2, 0x93, 0x4b, 0x4, 0x38, 0xd7, 0x80, 0x2, 0x16, + 0x50, 0x1, 0xff, 0x2c, 0x0, 0x2f, 0xd6, 0x13, 0xb, 0x6f, 0x1, 0x60, 0x84, 0x0, 0x82, 0x2, 0x20, + 0x8, 0x64, 0x44, 0x0, 0x0, 0x9, 0x1b, 0x9, 0x2c, 0xd0, 0x4, 0xf0, 0x82, 0x25, 0x1c, 0x3f, 0xd2, + 0x40, 0xd1, 0x42, 0x20, 0x0, 0x1, 0xd7, 0xa0, 0xe6, 0xac, 0xa2, 0x0, 0xc3, 0x21, 0xc1, 0xfa, + 0xd8, 0x58, 0xf5, 0xb9, 0x2, 0xc1, 0x7d, 0x4e, 0xe, 0x68, 0xf1, 0x3, 0xa9, 0x26, 0x13, 0x81, + 0x8c, 0xef, 0x60, 0xdb, 0x75, 0xa0, 0xea, 0xdd, 0xa4, 0xb, 0x4, 0x80, 0x0, 0x20, 0x22, 0x40, + 0xb1, 0x42, 0x20, 0x0, 0x1, 0x97, 0xce, 0x86, 0x7c, 0x22, 0x0, 0xc3, 0x20, 0xcd, 0x78, 0x81, + 0x63, 0xde, 0xe5, 0xb, 0x8, 0xf5, 0x74, 0x39, 0xef, 0xd1, 0x8b, 0x2f, 0x7e, 0x90, 0x55, 0x57, + 0x82, 0x41, 0xca, 0x7d, 0xdc, 0x16, 0x22, 0x80, 0x0, 0x61, 0xc2, 0x2, 0x24, 0x44, 0x40, 0x0, + 0x0, 0xb3, 0x4b, 0x65, 0x8e, 0x81, 0x21, 0x90, 0xe0, 0x57, 0xc6, 0xb, 0xe9, 0xef, 0x24, 0xc, + 0x14, 0x22, 0x0, 0x0, 0x19, 0x7b, 0x78, 0x69, 0x4a, 0x20, 0xc, 0x32, 0x1b, 0x4b, 0x87, 0x85, + 0x8f, 0x7b, 0x92, 0x2c, 0x1d, 0xd5, 0x50, 0xe7, 0x2f, 0x2a, 0x2c, 0xbb, 0xfa, 0x11, 0x64, 0x6f, + 0x83, 0x2, 0x81, 0xb1, 0xc1, 0x62, 0x48, 0x0, 0x6, 0x1e, 0x20, 0x24, 0x44, 0x44, 0x0, 0x0, 0xb, + 0x34, 0xa5, 0x54, 0x3c, 0x12, 0x19, 0x7, 0xee, 0xf2, 0x2, 0x99, 0x7b, 0xc9, 0x1, 0x51, 0x8, + 0x80, 0x0, 0x7, 0x51, 0x5d, 0x86, 0x5a, 0x20, 0xc, 0x32, 0x0, 0x62, 0x8c, 0x11, 0xe2, 0x4c, + 0xd0, 0x42, 0x65, 0xb3, 0x9, 0x3a, 0xaa, 0xc1, 0xa, 0xb8, 0x18, 0x26, 0x2, 0xb0, 0x88, 0xc6, + 0x67, 0xb8, 0x23, 0x96, 0x95, 0x82, 0xc3, 0xd0, 0x0, 0x10, 0x24, 0x40, 0x3c, 0x48, 0x88, 0x0, + 0x0, 0x11, 0xfc, 0x8d, 0xab, 0xa0, 0xcd, 0x78, 0x81, 0x5f, 0x56, 0x9b, 0x12, 0x3, 0x22, 0x11, + 0x0, 0x0, 0x14, 0xb7, 0x45, 0x19, 0x21, 0x0, 0x4, 0x44, 0x19, 0x3b, 0xa2, 0x3b, 0x49, 0xa0, + 0x8, 0x40, 0xb7, 0x1, 0x26, 0x15, 0x6c, 0x47, 0x6f, 0x49, 0x42, 0x3d, 0xba, 0x89, 0x11, 0x52, + 0x56, 0x78, 0xce, 0xf9, 0x3c, 0xc1, 0xb, 0x1d, 0x3a, 0x88, 0x77, 0xa8, 0x3c, 0x44, 0xc5, 0x39, + 0x80, 0xb1, 0x18, 0x0, 0x5, 0xa, 0x24, 0x7, 0x44, 0x22, 0x0, 0x0, 0x29, 0x6d, 0xa7, 0x28, 0xda, + 0x0, 0x8, 0x89, 0xf6, 0x6a, 0x44, 0x71, 0x53, 0x50, 0x10, 0xc3, 0x6d, 0x82, 0x46, 0x2c, 0xa8, + 0x8e, 0xa2, 0x94, 0x84, 0x7a, 0x94, 0xca, 0x22, 0xbf, 0xae, 0x81, 0x9e, 0x3e, 0x78, 0x82, 0x17, + 0x2a, 0x6f, 0x10, 0xf2, 0xd0, 0x60, 0x89, 0x96, 0x72, 0xc1, 0x62, 0x50, 0x0, 0xa, 0x16, 0x48, + 0x6, 0x68, 0x44, 0x0, 0x0, 0x33, 0x24, 0x13, 0x3d, 0xf1, 0x10, 0x6, 0x19, 0x1, 0x56, 0x4a, 0x4, + 0x84, 0xc, 0x80, 0x99, 0x12, 0x30, 0x5, 0x81, 0x2c, 0x9, 0x6, 0x1, 0xc2, 0x15, 0xd0, 0xa5, 0x9, + 0x18, 0x0, 0x22, 0xb1, 0x22, 0x0, 0x6, 0x3, 0x58, 0x89, 0x0, 0x3, 0x2, 0x12, 0x0, 0x9a, 0x11, + 0x0, 0x0, 0xe, 0xc8, 0x3b, 0x8f, 0xac, 0x44, 0x1, 0x86, 0x42, 0x88, 0xf, 0xb0, 0x51, 0xa1, + 0xff, 0x9, 0x20, 0xc, 0x41, 0x26, 0x60, 0xd8, 0x28, 0x4c, 0x97, 0x5, 0x39, 0x1b, 0x91, 0x2d, + 0xc1, 0x75, 0x5, 0x30, 0x9c, 0x50, 0x58, 0x4a, 0x0, 0x3, 0x82, 0x92, 0x1, 0x1a, 0x11, 0x0, 0x0, + 0xc, 0xc8, 0x9b, 0x4f, 0x8f, 0x44, 0x1, 0x86, 0x42, 0x5f, 0x8a, 0x48, 0x3, 0xb0, 0x1c, 0x24, + 0xa8, 0x37, 0x88, 0xd9, 0xc5, 0xe4, 0x69, 0x7b, 0x38, 0x49, 0xf, 0x9b, 0x5, 0xdc, 0xac, 0xcd, + 0x2a, 0x16, 0x20, 0xb0, 0xf8, 0x0, 0x8, 0x6, 0x24, 0x10, 0x24, 0x12, 0x0, 0x0, 0x15, 0x9a, + 0x65, 0xaf, 0x56, 0x88, 0x3, 0xc, 0x83, 0xf2, 0x7b, 0x5, 0xb9, 0xe, 0xc6, 0x2e, 0x1, 0x6b, + 0xe1, 0x6c, 0xf3, 0x28, 0xb, 0x63, 0x56, 0xec, 0x5c, 0x8a, 0x98, 0x20, 0xb0, 0x98, 0x0, 0x9, + 0xc, 0x24, 0x2d, 0x44, 0x2, 0x10, 0x0, 0x0, 0x3a, 0xd9, 0xca, 0x42, 0xc4, 0x40, 0x16, 0x20, + 0x0, 0x34, 0xec, 0x3, 0xe, 0x65, 0x1, 0x15, 0x3e, 0x84, 0x3a, 0xd6, 0xe1, 0x3, 0x15, 0xb9, + 0x8c, 0xa6, 0x16, 0x3d, 0x0, 0xab, 0x7d, 0x80, 0x1, 0xc3, 0x81, 0x65, 0xd0, 0x0, 0xa, 0x1a, + 0x20, 0x5c, 0x44, 0x4, 0x20, 0x0, 0x0, 0x11, 0xe4, 0x58, 0x4a, 0x20, 0xfc, 0x9e, 0xc0, 0x66, + 0xaf, 0xa0, 0x20, 0x9b, 0x2, 0x4, 0x0, 0x0, 0x1, 0x7e, 0x98, 0x22, 0x8a, 0x0, 0x8, 0x1d, 0x6, + 0xa0, 0x4, 0x28, 0x2, 0x8, 0x28, 0xc0, 0x81, 0x0, 0x0, 0x0, 0x66, 0x20, 0x8c, 0x7b, 0x20, 0x0, + 0x82, 0x26, 0x97, 0x40, 0x42, 0x60, 0x40, 0x82, 0xac, 0x8, 0x10, 0x0, 0x0, 0x5, 0xb9, 0x28, + 0x6f, 0xe8, 0x0, 0x20, 0x71, 0x1b, 0x0, 0x10, 0xb0, 0x18, 0x20, 0xb3, 0x2, 0x4, 0x0, 0x0, 0x1, + 0x56, 0xc2, 0x4b, 0x68, 0x0, 0x20, 0x8f, 0x25, 0x0, 0x10, 0xa8, 0x20, 0x20, 0xbb, 0x2, 0x4, + 0x0, 0x0, 0x1, 0x7e, 0x63, 0x57, 0xea, 0x0, 0x8, 0x26, 0x37, 0xcc, 0x4, 0x30, 0xa, 0x8, 0x30, + 0xc0, 0x81, 0x0, 0x0, 0x0, 0x5e, 0x5, 0x57, 0xac, 0x80, 0x2, 0x6, 0x76, 0xd, 0x1, 0xb, 0x83, + 0x2, 0xc, 0xb0, 0x20, 0x40, 0x0, 0x0, 0x13, 0xeb, 0xc2, 0x9a, 0x0, 0x8, 0x22, 0x56, 0x8c, 0x4, + 0x34, 0xe, 0x8, 0x34, 0xc0, 0x81, 0x0, 0x0, 0x0, 0x4e, 0xf3, 0x45, 0x28, 0x0, 0x20, 0x76, 0xe5, + 0xd0, 0x10, 0xc8, 0x40, 0x20, 0xdb, 0x2, 0x4, 0x0, 0x0, 0x1, 0x6f, 0xd8, 0xd, 0x32, 0x0, 0x8, + 0x19, 0xd8, 0x34, 0x4, 0x38, 0x12, 0x8, 0x38, 0xc0, 0x81, 0x0, 0x0, 0x0, 0x5d, 0x90, 0xc1, + 0x90, 0x80, 0x2, 0x9, 0x8d, 0xf3, 0x1, 0xd, 0x85, 0x2, 0xf, 0xa0, 0x10, 0x80, 0x0, 0x0, 0x15, + 0xa2, 0x24, 0x2e, 0x80, 0x2, 0x8, 0xee, 0x50, 0x1, 0x10, 0x5, 0x80, 0x10, 0x10, 0x10, 0x80, + 0x0, 0x0, 0x17, 0x0, 0x81, 0x9d, 0xa0, 0x0, 0x81, 0xc8, 0x6b, 0x41, 0x8, 0x50, 0x8, 0x40, 0x0, + 0x0, 0xb, 0x8a, 0xd8, 0xd8, 0x50, 0x0, 0x40, 0xcf, 0x42, 0x0, 0x22, 0x20, 0xc0, 0x2, 0x22, 0x2, + 0x10, 0x0, 0x0, 0x2, 0xef, 0x9c, 0x24, 0xe4, 0x0, 0x10, 0x4c, 0x4f, 0x80, 0x21, 0x1a, 0x1, 0x8, + 0x0, 0x0, 0x1, 0x5a, 0xd2, 0xde, 0x48, 0x0, 0x20, 0x80, 0x26, 0x40, 0x11, 0x20, 0x68, 0x1, + 0x21, 0x1, 0x8, 0x0, 0x0, 0x1, 0x5a, 0xce, 0x9a, 0x68, 0x0, 0x20, 0x80, 0x19, 0xc0, 0x42, 0x54, + 0x2, 0x10, 0x0, 0x0, 0x2, 0xa4, 0x6c, 0xcc, 0x10, 0x0, 0x41, 0x2a, 0x39, 0x40, 0x22, 0x60, + 0xe0, 0x2, 0x62, 0x2, 0x10, 0x0, 0x0, 0x2, 0x47, 0x69, 0x47, 0x40, 0x1, 0x3, 0x49, 0x13, 0x2, + 0x13, 0xa0, 0x10, 0x80, 0x0, 0x0, 0x17, 0xe0, 0x4, 0x82, 0x20, 0x0, 0x82, 0x63, 0x7d, 0x0, + 0x45, 0x1, 0xe0, 0x5, 0x4, 0x4, 0x20, 0x0, 0x0, 0x5, 0x6c, 0xd8, 0xff, 0xa0, 0x0, 0x81, 0x9d, + 0x83, 0x1, 0xa, 0x58, 0x10, 0x20, 0x0, 0x0, 0xb, 0x60, 0x30, 0x70, 0x10, 0x0, 0x41, 0x31, 0x42, + 0x0, 0x22, 0xa1, 0x0, 0x42, 0xa6, 0x4, 0x8, 0x0, 0x0, 0x2, 0xd3, 0xa2, 0x16, 0x24, 0x0, 0x10, + 0x33, 0xcf, 0x80, 0x8, 0xa4, 0x44, 0x10, 0xad, 0x0, 0x84, 0x0, 0x0, 0x0, 0xbd, 0xe7, 0x8f, + 0xdd, 0x0, 0x4, 0xe, 0x8b, 0x50, 0x2, 0x2c, 0x12, 0x0, 0x2c, 0x20, 0x21, 0x0, 0x0, 0x0, 0x2f, + 0xe0, 0x89, 0xbb, 0x40, 0x1, 0x4, 0x71, 0x29, 0x80, +]; + +/// Intersection "TODO" +/// +/// 3 layers +#[cfg(feature = "spat")] +pub const MAPEM_HH_2349_L31: &[u8] = &[ + 0x2, 0x5, 0x0, 0x12, 0x25, 0xf4, 0x18, 0x0, 0x3e, 0x6, 0xd, 0x91, 0x21, 0x93, 0x3b, 0x8c, 0x99, + 0xb4, 0x73, 0x71, 0x8b, 0x6, 0xe, 0x60, 0x0, 0x61, 0x25, 0xa1, 0x5, 0x58, 0xef, 0x69, 0x97, + 0x13, 0xed, 0x54, 0x20, 0x28, 0xa3, 0x68, 0x4, 0x49, 0xc6, 0xeb, 0xcb, 0xa2, 0x4, 0xf1, 0xba, + 0xf2, 0xe8, 0x9d, 0x6, 0x9d, 0xa8, 0x20, 0xf2, 0xda, 0x81, 0x99, 0x2, 0x4, 0x8, 0x0, 0x0, 0xab, + 0x72, 0xf, 0xe5, 0xc0, 0x7d, 0xa6, 0x34, 0x41, 0xf4, 0xe3, 0x75, 0xe5, 0xd1, 0x2, 0x78, 0xdd, + 0x79, 0x74, 0x4e, 0x83, 0x4e, 0xd4, 0x10, 0x79, 0x6d, 0x40, 0xcd, 0x1, 0x2, 0x4, 0x0, 0x0, + 0x55, 0xb8, 0x57, 0xf1, 0x50, 0x3e, 0x73, 0x18, 0x28, 0x80, 0x3c, 0x51, 0x10, 0x6d, 0x38, 0xdd, + 0x79, 0x74, 0x40, 0x9e, 0x37, 0x5e, 0x5d, 0x13, 0xa0, 0xd3, 0xb5, 0x4, 0x1e, 0x5b, 0x50, 0x32, + 0x40, 0x40, 0x81, 0x0, 0x0, 0x10, 0x8c, 0x36, 0xc6, 0x1, 0x31, 0xc1, 0xc0, 0xa1, 0xc0, 0x91, + 0x24, 0x1, 0xe4, 0xe3, 0x75, 0xe5, 0xd1, 0x2, 0x78, 0xdd, 0x79, 0x74, 0x4e, 0x83, 0x4e, 0xd4, + 0x10, 0x79, 0x6d, 0x40, 0xcc, 0x81, 0x2, 0x4, 0x0, 0x0, 0x55, 0xaf, 0x28, 0x32, 0xd0, 0x3e, + 0x73, 0x1a, 0x0, 0xd2, 0x71, 0xba, 0xf2, 0xe8, 0x81, 0x3c, 0x6e, 0xbc, 0xba, 0x27, 0x41, 0xa7, + 0x6a, 0x8, 0x3c, 0xb6, 0xa0, 0x64, 0x40, 0x81, 0x2, 0x0, 0x0, 0x20, 0x21, 0xcd, 0x23, 0x81, + 0x9c, 0x7e, 0x51, 0x7, 0x53, 0x8d, 0xd7, 0x97, 0x44, 0x9, 0xe3, 0x75, 0xe5, 0xd1, 0x3a, 0xd, + 0x3b, 0x50, 0x41, 0xe5, 0xb5, 0x3, 0x34, 0x4, 0x8, 0x10, 0x0, 0x1, 0x56, 0xd5, 0x20, 0xe, 0xc1, + 0x6, 0xb3, 0xa0, 0xa1, 0xe0, 0xe1, 0x34, 0x41, 0x94, 0xe3, 0x75, 0xe5, 0xd1, 0x2, 0x78, 0xdd, + 0x79, 0x74, 0x4e, 0x83, 0x4e, 0xd4, 0x10, 0x79, 0x6d, 0x40, 0xc9, 0x1, 0x2, 0x4, 0x0, 0x0, + 0x40, 0xe9, 0x9a, 0x78, 0x4, 0xc9, 0x3, 0x82, 0x86, 0x83, 0x4, 0x50, 0x6, 0x13, 0x8d, 0xd7, + 0x97, 0x44, 0x9, 0xe3, 0x75, 0xe5, 0xd1, 0x3a, 0xd, 0x3b, 0x50, 0x41, 0xe5, 0xb5, 0x3, 0x22, + 0x2, 0x8, 0x10, 0x0, 0x1, 0x8, 0xf2, 0x70, 0xe4, 0x13, 0x1c, 0x22, 0x88, 0x2e, 0x9c, 0x6e, + 0xbc, 0xba, 0x20, 0x4f, 0x1b, 0xaf, 0x2e, 0x89, 0xd0, 0x69, 0xda, 0x82, 0xf, 0x2d, 0xa8, 0x19, + 0x20, 0x10, 0x40, 0x80, 0x0, 0x8, 0x2f, 0xb3, 0x77, 0x0, 0x67, 0x5e, 0xf0, 0x50, 0xc0, 0x58, + 0x82, 0x0, 0xb2, 0x71, 0xba, 0xf2, 0xe8, 0x81, 0x3c, 0x6e, 0xbc, 0xba, 0x27, 0x41, 0xa7, 0x6a, + 0x8, 0x3c, 0xb6, 0xa0, 0x64, 0x40, 0x41, 0x2, 0x0, 0x0, 0x20, 0x73, 0x2d, 0xbe, 0x82, 0x64, + 0x81, 0x51, 0x5, 0x53, 0x8d, 0xd7, 0x97, 0x44, 0x9, 0xe3, 0x75, 0xe5, 0xd1, 0x3a, 0xd, 0x3b, + 0x50, 0x41, 0xe5, 0xb5, 0x3, 0x24, 0x2, 0x8, 0x10, 0x0, 0x1, 0x0, 0x69, 0x6d, 0x60, 0xc, 0xe3, + 0xf6, 0xa, 0x16, 0xa, 0xf, 0x40, 0x14, 0x4e, 0x37, 0x5e, 0x5d, 0x10, 0x27, 0x8d, 0xd7, 0x97, + 0x44, 0xe8, 0x34, 0xed, 0x41, 0x7, 0x96, 0xd4, 0xc, 0x88, 0x10, 0x20, 0x40, 0x0, 0x4, 0xe, + 0xb5, 0xa7, 0x50, 0x4c, 0x90, 0x2b, 0x20, 0xa, 0xd1, 0xb0, 0xe8, 0xe5, 0xcf, 0xa7, 0x2c, 0xba, + 0x73, 0x65, 0xdc, 0x81, 0x8a, 0xd, 0x3b, 0x50, 0x41, 0xe5, 0xb5, 0x3, 0x11, 0x40, 0x20, 0x0, + 0x2, 0x88, 0x69, 0x7c, 0x9a, 0x43, 0xec, 0xa4, 0x90, 0x1, 0x0, 0xc0, 0x0, 0xd1, 0xe3, 0x90, + 0x68, 0xf2, 0x0, 0x34, 0x59, 0x18, 0x1a, 0x1c, 0x98, 0x8, 0x2, 0x0, 0x16, 0x86, 0xc9, 0xe0, + 0x31, 0x99, 0x20, 0x18, 0xbc, 0x9e, 0x9c, 0x5b, 0x29, 0x10, 0x1, 0x41, 0x61, 0x90, 0x0, 0x2, + 0x2, 0x80, 0x20, 0x9c, 0x6e, 0xbc, 0xba, 0x20, 0x4f, 0x1b, 0xaf, 0x2e, 0x89, 0xd0, 0x69, 0xda, + 0x82, 0xf, 0x2d, 0xa8, 0x19, 0x90, 0x20, 0x40, 0x80, 0x0, 0xa, 0xb6, 0xbd, 0x0, 0x6c, 0x8, + 0x35, 0x9d, 0x50, 0xc, 0x5a, 0x36, 0x1d, 0x1c, 0xb9, 0xf4, 0xe5, 0x97, 0x4e, 0x6c, 0xbb, 0x90, + 0x34, 0x41, 0xa7, 0x6a, 0x8, 0x3c, 0xb6, 0xa0, 0x66, 0x64, 0x4, 0x0, 0x0, 0x9, 0x55, 0x73, + 0x60, 0x6b, 0xc2, 0x13, 0x5c, 0x90, 0x86, 0xc9, 0xce, 0x59, 0x0, 0x96, 0x8d, 0x87, 0x47, 0x2e, + 0x7d, 0x39, 0x65, 0xd3, 0x9b, 0x2e, 0xe4, 0xc, 0x90, 0x69, 0xda, 0x82, 0xf, 0x2d, 0xa8, 0x18, + 0x8a, 0x10, 0x0, 0x0, 0xe, 0x43, 0x56, 0xe3, 0x87, 0x1f, 0xbb, 0xe5, 0x46, 0x1c, 0x3, 0x18, + 0x41, 0xff, 0x4d, 0xe0, 0x80, 0x20, 0x0, 0xff, 0xa9, 0xd8, 0xd, 0x6e, 0x53, 0x6, 0xb3, 0x2d, + 0xa7, 0x58, 0xcb, 0xa4, 0x0, 0x50, 0x58, 0x5c, 0x0, 0x0, 0x81, 0x20, 0x9, 0x27, 0x1b, 0xaf, + 0x2e, 0x88, 0x13, 0xc6, 0xeb, 0xcb, 0xa2, 0x74, 0x1a, 0x76, 0xa0, 0x83, 0xcb, 0x6a, 0x6, 0x44, + 0x8, 0x10, 0x20, 0x0, 0x2, 0x11, 0x7e, 0xd9, 0x90, 0x26, 0x38, 0x31, 0x0, 0x81, 0x38, 0xdd, + 0x79, 0x74, 0x40, 0x9e, 0x37, 0x5e, 0x5d, 0x13, 0xa0, 0xd3, 0xb5, 0x4, 0x1e, 0x5b, 0x50, 0x33, + 0x20, 0x40, 0x81, 0x0, 0x0, 0x15, 0x6f, 0x79, 0xf1, 0x88, 0x10, 0x6b, 0x3a, 0x80, 0x38, 0x9c, + 0x6e, 0xbc, 0xba, 0x20, 0x4f, 0x1b, 0xaf, 0x2e, 0x89, 0xd0, 0x69, 0xda, 0x82, 0xf, 0x2d, 0xa8, + 0x19, 0x10, 0x20, 0x40, 0x80, 0x0, 0x8, 0x31, 0x1b, 0x57, 0x40, 0x67, 0x5f, 0x25, 0x0, 0xb5, + 0xa3, 0x61, 0xd1, 0xcb, 0x9f, 0x4e, 0x59, 0x74, 0xe6, 0xcb, 0xb9, 0x3, 0x34, 0x1a, 0x76, 0xa0, + 0x83, 0xcb, 0x6a, 0x6, 0x66, 0x44, 0x0, 0x0, 0x0, 0x95, 0x57, 0xe2, 0x0, 0xfc, 0x46, 0x24, + 0xec, 0x62, 0x2e, 0xc7, 0x57, 0x44, 0x21, 0x4e, 0x37, 0x5e, 0x5d, 0x10, 0x27, 0x8d, 0xd7, 0x97, + 0x44, 0xe8, 0x34, 0xed, 0x41, 0x7, 0x96, 0xd4, 0xc, 0xd0, 0x10, 0x20, 0x40, 0x0, 0x5, 0x5b, + 0xd9, 0x7c, 0x72, 0x4, 0x14, 0xce, 0x82, 0x88, 0x83, 0x45, 0x54, 0x2, 0x16, 0x8d, 0x87, 0x47, + 0x2e, 0x7d, 0x39, 0x65, 0xd3, 0x9b, 0x2e, 0xe4, 0xc, 0xd0, 0x69, 0xda, 0x82, 0xf, 0x2d, 0xa8, + 0x19, 0x11, 0x10, 0x0, 0x0, 0x2, 0x40, 0xab, 0x99, 0x11, 0xc, 0x3d, 0x3b, 0x22, 0x87, 0x13, + 0xc5, 0x44, 0xd, 0x4e, 0x37, 0x5e, 0x5d, 0x10, 0x27, 0x8d, 0xd7, 0x97, 0x44, 0xe8, 0x34, 0xed, + 0x41, 0x7, 0x96, 0xd4, 0xc, 0x58, 0x10, 0x20, 0x40, 0x0, 0x4, 0x2e, 0x8a, 0x53, 0x20, 0x3e, + 0xb3, 0x18, 0x28, 0x38, 0x14, 0x29, 0x10, 0x39, 0x38, 0xdd, 0x79, 0x74, 0x40, 0x9e, 0x37, 0x5e, + 0x5d, 0x13, 0xa0, 0xd3, 0xb5, 0x4, 0x1e, 0x5b, 0x50, 0x31, 0x60, 0x40, 0x81, 0x0, 0x0, 0x10, + 0xc6, 0x17, 0xad, 0x41, 0x5, 0xb3, 0xa0, 0xa0, 0xd0, 0x50, 0xb4, 0x40, 0xf4, 0xe3, 0x75, 0xe5, + 0xd1, 0x2, 0x78, 0xdd, 0x79, 0x74, 0x4e, 0x83, 0x4e, 0xd4, 0x10, 0x79, 0x6d, 0x40, 0xcd, 0x1, + 0x2, 0x4, 0x0, 0x0, 0x55, 0xaf, 0xc8, 0x33, 0x20, 0x3e, 0x73, 0x18, 0x28, 0x40, 0x34, 0x31, + 0x90, 0xd, 0x68, 0xd8, 0x74, 0x72, 0xe7, 0xd3, 0x96, 0x5d, 0x39, 0xb2, 0xee, 0x40, 0xcd, 0x6, + 0x9d, 0xa8, 0x20, 0xf2, 0xda, 0x81, 0x88, 0xa1, 0x0, 0x0, 0x2, 0x44, 0x36, 0x2e, 0x23, 0x61, + 0x79, 0xf4, 0x9a, 0x83, 0xcf, 0x94, 0x1, 0xe7, 0x4a, 0xc0, 0xf3, 0x65, 0xc0, 0x79, 0x73, 0x10, + 0xf5, 0xf5, 0x26, 0x64, 0x46, 0x30, 0x58, 0x23, 0x6, 0x9, 0x32, 0x18, 0x4, 0x8b, 0x13, 0x82, + 0x3b, 0x8d, 0x1, 0x18, 0x48, 0x0, 0x96, 0x69, 0x30, 0x40, 0x10, 0x0, 0x23, 0x9, 0x0, 0x11, + 0xdc, 0x6a, 0x9, 0x12, 0x2a, 0x4, 0x97, 0xe, 0x2, 0x0, 0x80, 0x51, 0x18, 0x41, 0xa8, 0x0, 0xa0, + 0xb0, 0x84, 0x0, 0x2, 0x3, 0x44, 0x11, 0x4e, 0x37, 0x5e, 0x5d, 0x10, 0x27, 0x8d, 0xd7, 0x97, + 0x44, 0xe8, 0x34, 0xed, 0x41, 0x7, 0x96, 0xd4, 0xc, 0x90, 0x10, 0x20, 0x40, 0x0, 0x4, 0x18, + 0x69, 0xad, 0xe0, 0x33, 0xaf, 0xa0, 0x28, 0x48, 0x20, 0x35, 0x10, 0x4d, 0x38, 0xdd, 0x79, 0x74, + 0x40, 0x9e, 0x37, 0x5e, 0x5d, 0x13, 0xa0, 0xd3, 0xb5, 0x4, 0x1e, 0x5b, 0x50, 0x32, 0x40, 0x40, + 0x81, 0x0, 0x0, 0x10, 0x10, 0xa6, 0x95, 0x80, 0xce, 0x3f, 0x60, 0xa1, 0x40, 0x90, 0xe0, +]; +#[cfg(feature = "spat")] +pub const MAPEM_HH_2349_L32: &[u8] = &[ + 0x2, 0x5, 0x0, 0x12, 0x25, 0xf4, 0x18, 0x0, 0x40, 0x6, 0xd, 0x91, 0x21, 0x93, 0x3b, 0x8c, 0x99, + 0xb4, 0x73, 0x71, 0x8b, 0x6, 0xe, 0x60, 0x0, 0x61, 0x25, 0xa1, 0x5, 0x58, 0xef, 0x69, 0x97, + 0x13, 0xed, 0x54, 0x20, 0x28, 0xa3, 0x48, 0x84, 0x29, 0xc6, 0xeb, 0xcb, 0xa2, 0x4, 0xf1, 0xba, + 0xf2, 0xe8, 0x9d, 0x6, 0x9d, 0xa8, 0x20, 0xf2, 0xda, 0x81, 0x9a, 0x2, 0x4, 0x8, 0x0, 0x0, 0xab, + 0x7b, 0x2f, 0x8e, 0x40, 0x82, 0x99, 0xd0, 0x51, 0x10, 0x68, 0xaa, 0x0, 0xf2, 0x71, 0xba, 0xf2, + 0xe8, 0x81, 0x3c, 0x6e, 0xbc, 0xba, 0x27, 0x41, 0xa7, 0x6a, 0x8, 0x3c, 0xb6, 0xa0, 0x66, 0x40, + 0x81, 0x2, 0x0, 0x0, 0x2a, 0xd7, 0x94, 0x19, 0x68, 0x1f, 0x39, 0x8d, 0x0, 0x71, 0x38, 0xdd, + 0x79, 0x74, 0x40, 0x9e, 0x37, 0x5e, 0x5d, 0x13, 0xa0, 0xd3, 0xb5, 0x4, 0x1e, 0x5b, 0x50, 0x32, + 0x20, 0x40, 0x81, 0x0, 0x0, 0x10, 0x62, 0x36, 0xae, 0x80, 0xce, 0xbe, 0x48, 0x3, 0x49, 0xc6, + 0xeb, 0xcb, 0xa2, 0x4, 0xf1, 0xba, 0xf2, 0xe8, 0x9d, 0x6, 0x9d, 0xa8, 0x20, 0xf2, 0xda, 0x81, + 0x91, 0x2, 0x4, 0x8, 0x0, 0x0, 0x80, 0x87, 0x34, 0x8e, 0x6, 0x71, 0xf9, 0x44, 0x19, 0x4e, 0x37, + 0x5e, 0x5d, 0x10, 0x27, 0x8d, 0xd7, 0x97, 0x44, 0xe8, 0x34, 0xed, 0x41, 0x7, 0x96, 0xd4, 0xc, + 0x90, 0x10, 0x20, 0x40, 0x0, 0x4, 0xe, 0x99, 0xa7, 0x80, 0x4c, 0x90, 0x38, 0x28, 0x68, 0x30, + 0x45, 0x0, 0x61, 0x38, 0xdd, 0x79, 0x74, 0x40, 0x9e, 0x37, 0x5e, 0x5d, 0x13, 0xa0, 0xd3, 0xb5, + 0x4, 0x1e, 0x5b, 0x50, 0x32, 0x20, 0x20, 0x81, 0x0, 0x0, 0x10, 0x8f, 0x27, 0xe, 0x41, 0x31, + 0xc2, 0x28, 0x82, 0xe9, 0xc6, 0xeb, 0xcb, 0xa2, 0x4, 0xf1, 0xba, 0xf2, 0xe8, 0x9d, 0x6, 0x9d, + 0xa8, 0x20, 0xf2, 0xda, 0x81, 0x92, 0x1, 0x4, 0x8, 0x0, 0x0, 0x82, 0xfb, 0x37, 0x70, 0x6, 0x75, + 0xef, 0x5, 0xc, 0x5, 0x88, 0x20, 0xb, 0x27, 0x1b, 0xaf, 0x2e, 0x88, 0x13, 0xc6, 0xeb, 0xcb, + 0xa2, 0x74, 0x1a, 0x76, 0xa0, 0x83, 0xcb, 0x6a, 0x6, 0x44, 0x4, 0x10, 0x20, 0x0, 0x2, 0x7, + 0x32, 0xdb, 0xe8, 0x26, 0x48, 0x15, 0x10, 0x55, 0x38, 0xdd, 0x79, 0x74, 0x40, 0x9e, 0x37, 0x5e, + 0x5d, 0x13, 0xa0, 0xd3, 0xb5, 0x4, 0x1e, 0x5b, 0x50, 0x32, 0x40, 0x20, 0x81, 0x0, 0x0, 0x10, + 0x6, 0x96, 0xd6, 0x0, 0xce, 0x3f, 0x60, 0xa1, 0x60, 0xa0, 0xf5, 0x0, 0xb5, 0xa3, 0x61, 0xd1, + 0xcb, 0x9f, 0x4e, 0x59, 0x74, 0xe6, 0xcb, 0xb9, 0x3, 0x34, 0x1a, 0x76, 0xa0, 0x83, 0xcb, 0x6a, + 0x6, 0x66, 0x44, 0x0, 0x0, 0x0, 0x95, 0x57, 0xe2, 0x0, 0xfc, 0x46, 0x24, 0xec, 0x62, 0x2e, + 0xc7, 0x57, 0x40, 0x12, 0x4e, 0x37, 0x5e, 0x5d, 0x10, 0x27, 0x8d, 0xd7, 0x97, 0x44, 0xe8, 0x34, + 0xed, 0x41, 0x7, 0x96, 0xd4, 0xc, 0x88, 0x10, 0x20, 0x40, 0x0, 0x4, 0x22, 0xfd, 0xb3, 0x20, + 0x4c, 0x70, 0x62, 0x20, 0xea, 0x71, 0xba, 0xf2, 0xe8, 0x81, 0x3c, 0x6e, 0xbc, 0xba, 0x27, 0x41, + 0xa7, 0x6a, 0x8, 0x3c, 0xb6, 0xa0, 0x66, 0x80, 0x81, 0x2, 0x0, 0x0, 0x2a, 0xda, 0xa4, 0x1, + 0xd8, 0x20, 0xd6, 0x74, 0x14, 0x3c, 0x1c, 0x26, 0xa0, 0xa, 0xb4, 0x6c, 0x3a, 0x39, 0x73, 0xe9, + 0xcb, 0x2e, 0x9c, 0xd9, 0x77, 0x20, 0x6a, 0x83, 0x4e, 0xd4, 0x10, 0x79, 0x6d, 0x40, 0xc4, 0x48, + 0x80, 0x0, 0x0, 0x11, 0x81, 0x6e, 0x2, 0x81, 0xcc, 0x71, 0x88, 0x5c, 0x25, 0x20, 0x4c, 0x80, + 0xeb, 0x46, 0xc3, 0xa3, 0x97, 0x3e, 0x9c, 0xb2, 0xe9, 0xcd, 0x97, 0x72, 0x6, 0x28, 0x34, 0xed, + 0x41, 0x7, 0x96, 0xd4, 0xc, 0x89, 0x8, 0x0, 0x0, 0xf, 0x21, 0x6, 0xc, 0xfb, 0xa, 0x3e, 0x44, + 0xf0, 0x10, 0xc, 0x0, 0x18, 0xb2, 0x5d, 0x83, 0x13, 0xcb, 0xa8, 0xa2, 0x58, 0xba, 0x11, 0x16, + 0x8e, 0xa0, 0x8c, 0x18, 0x20, 0x7, 0xfc, 0x10, 0x5a, 0x76, 0x7, 0xd1, 0x3b, 0xb, 0xa1, 0x1b, + 0x40, 0x80, 0x20, 0x0, 0x3f, 0xc, 0xe8, 0x1f, 0xe6, 0x78, 0x10, 0x23, 0x3a, 0x8, 0x25, 0x9e, + 0x44, 0x6a, 0x3b, 0x20, 0x2, 0x8a, 0xc1, 0x48, 0x0, 0x10, 0x1d, 0x60, 0x84, 0x0, 0x8, 0x10, + 0x80, 0x44, 0x9c, 0x6e, 0xbc, 0xba, 0x20, 0x4f, 0x1b, 0xaf, 0x2e, 0x89, 0xd0, 0x69, 0xda, 0x82, + 0xf, 0x2d, 0xa8, 0x19, 0x90, 0x20, 0x40, 0x80, 0x0, 0xa, 0xb7, 0x20, 0xfe, 0x5c, 0x7, 0xda, + 0x63, 0x44, 0x11, 0x4e, 0x37, 0x5e, 0x5d, 0x10, 0x27, 0x8d, 0xd7, 0x97, 0x44, 0xe8, 0x34, 0xed, + 0x41, 0x7, 0x96, 0xd4, 0xc, 0x90, 0x10, 0x20, 0x40, 0x0, 0x4, 0x18, 0x69, 0xad, 0xe0, 0x33, + 0xaf, 0xa0, 0x28, 0x48, 0x20, 0x35, 0x10, 0x6d, 0x38, 0xdd, 0x79, 0x74, 0x40, 0x9e, 0x37, 0x5e, + 0x5d, 0x13, 0xa0, 0xd3, 0xb5, 0x4, 0x1e, 0x5b, 0x50, 0x32, 0x40, 0x40, 0x81, 0x0, 0x0, 0x10, + 0x8c, 0x36, 0xc6, 0x1, 0x31, 0xc1, 0xc0, 0xa1, 0xc0, 0x91, 0x25, 0x0, 0x45, 0xa3, 0x61, 0xd1, + 0xcb, 0x9f, 0x4e, 0x59, 0x74, 0xe6, 0xcb, 0xb9, 0x3, 0x44, 0x1a, 0x76, 0xa0, 0x83, 0xcb, 0x6a, + 0x6, 0x22, 0x44, 0x0, 0x0, 0x0, 0x8c, 0x3, 0xf1, 0x64, 0xf, 0xed, 0x92, 0xe2, 0xc5, 0xf8, 0xba, + 0x64, 0x6, 0x5a, 0x36, 0x1d, 0x1c, 0xb9, 0xf4, 0xe5, 0x97, 0x4e, 0x6c, 0xbb, 0x90, 0x32, 0x41, + 0xa7, 0x6a, 0x8, 0x3c, 0xb6, 0xa0, 0x64, 0x48, 0x40, 0x0, 0x0, 0x29, 0x6, 0xee, 0x66, 0xf4, + 0x91, 0x7b, 0x93, 0xc8, 0x8, 0x6, 0x0, 0x4c, 0x7f, 0x2, 0x68, 0x0, 0xa1, 0x8e, 0x0, 0x44, 0x51, + 0xa4, 0x3, 0xe5, 0x88, 0x34, 0x38, 0x3, 0x5, 0x85, 0xa0, 0x0, 0x38, 0x4a, 0x20, 0x6a, 0x71, + 0xba, 0xf2, 0xe8, 0x81, 0x3c, 0x6e, 0xbc, 0xba, 0x27, 0x41, 0xa7, 0x6a, 0x8, 0x3c, 0xb6, 0xa0, + 0x62, 0xc0, 0x81, 0x2, 0x0, 0x0, 0x21, 0x74, 0x52, 0x99, 0x1, 0xf5, 0x98, 0xc1, 0x41, 0xc0, + 0xa1, 0x48, 0x2, 0x89, 0xc6, 0xeb, 0xcb, 0xa2, 0x4, 0xf1, 0xba, 0xf2, 0xe8, 0x9d, 0x6, 0x9d, + 0xa8, 0x20, 0xf2, 0xda, 0x81, 0x91, 0x2, 0x4, 0x8, 0x0, 0x0, 0x81, 0xd6, 0xb4, 0xea, 0x9, 0x92, + 0x5, 0x44, 0x1f, 0x4e, 0x37, 0x5e, 0x5d, 0x10, 0x27, 0x8d, 0xd7, 0x97, 0x44, 0xe8, 0x34, 0xed, + 0x41, 0x7, 0x96, 0xd4, 0xc, 0xd0, 0x10, 0x20, 0x40, 0x0, 0x5, 0x5b, 0x85, 0x7f, 0x15, 0x3, + 0xe7, 0x31, 0x82, 0x88, 0x3, 0xc5, 0x11, 0x3, 0x93, 0x8d, 0xd7, 0x97, 0x44, 0x9, 0xe3, 0x75, + 0xe5, 0xd1, 0x3a, 0xd, 0x3b, 0x50, 0x41, 0xe5, 0xb5, 0x3, 0x16, 0x4, 0x8, 0x10, 0x0, 0x1, 0xc, + 0x61, 0x7a, 0xd4, 0x10, 0x5b, 0x3a, 0xa, 0xd, 0x5, 0xb, 0x40, 0x20, 0x4e, 0x37, 0x5e, 0x5d, + 0x10, 0x27, 0x8d, 0xd7, 0x97, 0x44, 0xe8, 0x34, 0xed, 0x41, 0x7, 0x96, 0xd4, 0xc, 0xc8, 0x10, + 0x20, 0x40, 0x0, 0x5, 0x5b, 0xde, 0x7c, 0x62, 0x4, 0x1a, 0xce, 0xa2, 0x7, 0xa7, 0x1b, 0xaf, + 0x2e, 0x88, 0x13, 0xc6, 0xeb, 0xcb, 0xa2, 0x74, 0x1a, 0x76, 0xa0, 0x83, 0xcb, 0x6a, 0x6, 0x68, + 0x8, 0x10, 0x20, 0x0, 0x2, 0xad, 0x7e, 0x41, 0x99, 0x1, 0xf3, 0x98, 0xc1, 0x42, 0x1, 0xa1, + 0x88, 0x2, 0x9, 0xc6, 0xeb, 0xcb, 0xa2, 0x4, 0xf1, 0xba, 0xf2, 0xe8, 0x9d, 0x6, 0x9d, 0xa8, + 0x20, 0xf2, 0xda, 0x81, 0x99, 0x2, 0x4, 0x8, 0x0, 0x0, 0xab, 0x6b, 0xd0, 0x6, 0xc0, 0x83, 0x59, + 0xd4, 0x41, 0x34, 0xe3, 0x75, 0xe5, 0xd1, 0x2, 0x78, 0xdd, 0x79, 0x74, 0x4e, 0x83, 0x4e, 0xd4, + 0x10, 0x79, 0x6d, 0x40, 0xc9, 0x1, 0x2, 0x4, 0x0, 0x0, 0x40, 0x42, 0x9a, 0x56, 0x3, 0x38, 0xfd, + 0x82, 0x85, 0x2, 0x43, 0x80, +]; +#[cfg(feature = "spat")] +pub const MAPEM_HH_2349_L33: &[u8] = &[ + 0x2, 0x5, 0x0, 0x12, 0x25, 0xf4, 0x18, 0x0, 0x42, 0x6, 0xd, 0x91, 0x21, 0x93, 0x3b, 0x8c, 0x99, + 0xb4, 0x73, 0x71, 0x8b, 0x6, 0xe, 0x60, 0x0, 0x61, 0x25, 0xa1, 0x5, 0x58, 0xef, 0x69, 0x97, + 0x13, 0xed, 0x54, 0x20, 0x28, 0xa3, 0x48, 0x84, 0x29, 0xc6, 0xeb, 0xcb, 0xa2, 0x4, 0xf1, 0xba, + 0xf2, 0xe8, 0x9d, 0x6, 0x9d, 0xa8, 0x20, 0xf2, 0xda, 0x81, 0x9a, 0x2, 0x4, 0x8, 0x0, 0x0, 0xab, + 0x7b, 0x2f, 0x8e, 0x40, 0x82, 0x99, 0xd0, 0x51, 0x10, 0x68, 0xaa, 0x0, 0xf2, 0x71, 0xba, 0xf2, + 0xe8, 0x81, 0x3c, 0x6e, 0xbc, 0xba, 0x27, 0x41, 0xa7, 0x6a, 0x8, 0x3c, 0xb6, 0xa0, 0x66, 0x40, + 0x81, 0x2, 0x0, 0x0, 0x2a, 0xd7, 0x94, 0x19, 0x68, 0x1f, 0x39, 0x8d, 0x10, 0x75, 0x38, 0xdd, + 0x79, 0x74, 0x40, 0x9e, 0x37, 0x5e, 0x5d, 0x13, 0xa0, 0xd3, 0xb5, 0x4, 0x1e, 0x5b, 0x50, 0x33, + 0x40, 0x40, 0x81, 0x0, 0x0, 0x15, 0x6d, 0x52, 0x0, 0xec, 0x10, 0x6b, 0x3a, 0xa, 0x1e, 0xe, + 0x13, 0x44, 0x1b, 0x4e, 0x37, 0x5e, 0x5d, 0x10, 0x27, 0x8d, 0xd7, 0x97, 0x44, 0xe8, 0x34, 0xed, + 0x41, 0x7, 0x96, 0xd4, 0xc, 0x90, 0x10, 0x20, 0x40, 0x0, 0x4, 0x23, 0xd, 0xb1, 0x80, 0x4c, + 0x70, 0x70, 0x28, 0x70, 0x24, 0x49, 0x0, 0x69, 0x38, 0xdd, 0x79, 0x74, 0x40, 0x9e, 0x37, 0x5e, + 0x5d, 0x13, 0xa0, 0xd3, 0xb5, 0x4, 0x1e, 0x5b, 0x50, 0x32, 0x20, 0x40, 0x81, 0x0, 0x0, 0x10, + 0x10, 0xe6, 0x91, 0xc0, 0xce, 0x3f, 0x28, 0x83, 0x29, 0xc6, 0xeb, 0xcb, 0xa2, 0x4, 0xf1, 0xba, + 0xf2, 0xe8, 0x9d, 0x6, 0x9d, 0xa8, 0x20, 0xf2, 0xda, 0x81, 0x92, 0x2, 0x4, 0x8, 0x0, 0x0, 0x81, + 0xd3, 0x34, 0xf0, 0x9, 0x92, 0x7, 0x5, 0xd, 0x6, 0x8, 0xa2, 0xb, 0xa7, 0x1b, 0xaf, 0x2e, 0x88, + 0x13, 0xc6, 0xeb, 0xcb, 0xa2, 0x74, 0x1a, 0x76, 0xa0, 0x83, 0xcb, 0x6a, 0x6, 0x48, 0x4, 0x10, + 0x20, 0x0, 0x2, 0xb, 0xec, 0xdd, 0xc0, 0x19, 0xd7, 0xbc, 0x14, 0x30, 0x16, 0x20, 0x80, 0x2c, + 0x9c, 0x6e, 0xbc, 0xba, 0x20, 0x4f, 0x1b, 0xaf, 0x2e, 0x89, 0xd0, 0x69, 0xda, 0x82, 0xf, 0x2d, + 0xa8, 0x19, 0x10, 0x10, 0x40, 0x80, 0x0, 0x8, 0x1c, 0xcb, 0x6f, 0xa0, 0x99, 0x20, 0x54, 0x41, + 0x54, 0xe3, 0x75, 0xe5, 0xd1, 0x2, 0x78, 0xdd, 0x79, 0x74, 0x4e, 0x83, 0x4e, 0xd4, 0x10, 0x79, + 0x6d, 0x40, 0xc9, 0x0, 0x82, 0x4, 0x0, 0x0, 0x40, 0x1a, 0x5b, 0x58, 0x3, 0x38, 0xfd, 0x82, + 0x85, 0x82, 0x83, 0xd0, 0x7, 0x13, 0x8d, 0xd7, 0x97, 0x44, 0x9, 0xe3, 0x75, 0xe5, 0xd1, 0x3a, + 0xd, 0x3b, 0x50, 0x41, 0xe5, 0xb5, 0x3, 0x22, 0x4, 0x8, 0x10, 0x0, 0x1, 0x6, 0x23, 0x6a, 0xe8, + 0xc, 0xeb, 0xe4, 0xc8, 0x12, 0xb4, 0x6c, 0x3a, 0x39, 0x73, 0xe9, 0xcb, 0x2e, 0x9c, 0xd9, 0x77, + 0x20, 0x64, 0x83, 0x4e, 0xd4, 0x10, 0x79, 0x6d, 0x40, 0xcc, 0xd0, 0x80, 0x0, 0x0, 0x12, 0xad, + 0x1e, 0x3f, 0x1c, 0x4, 0xed, 0x3b, 0xc2, 0xa7, 0xc3, 0x3c, 0xe1, 0x82, 0xc1, 0x20, 0x0, 0xc, + 0x11, 0x40, 0x21, 0x68, 0xd8, 0x74, 0x72, 0xe7, 0xd3, 0x96, 0x5d, 0x39, 0xb2, 0xee, 0x40, 0xcd, + 0x6, 0x9d, 0xa8, 0x20, 0xf2, 0xda, 0x81, 0x91, 0x11, 0x0, 0x0, 0x0, 0x24, 0xa, 0xb9, 0x91, + 0x10, 0xc3, 0xd3, 0xb2, 0x28, 0x71, 0x3c, 0x54, 0x41, 0x34, 0xe3, 0x75, 0xe5, 0xd1, 0x2, 0x78, + 0xdd, 0x79, 0x74, 0x4e, 0x83, 0x4e, 0xd4, 0x10, 0x79, 0x6d, 0x40, 0xc9, 0x1, 0x2, 0x4, 0x0, + 0x0, 0x40, 0x42, 0x9a, 0x56, 0x3, 0x38, 0xfd, 0x82, 0x85, 0x2, 0x43, 0x90, 0x6, 0x13, 0x8d, + 0xd7, 0x97, 0x44, 0x9, 0xe3, 0x75, 0xe5, 0xd1, 0x3a, 0xd, 0x3b, 0x50, 0x41, 0xe5, 0xb5, 0x3, + 0x22, 0x2, 0x8, 0x10, 0x0, 0x1, 0x8, 0xf2, 0x70, 0xe4, 0x13, 0x1c, 0x22, 0xa0, 0x8, 0xb4, 0x6c, + 0x3a, 0x39, 0x73, 0xe9, 0xcb, 0x2e, 0x9c, 0xd9, 0x77, 0x20, 0x68, 0x83, 0x4e, 0xd4, 0x10, 0x79, + 0x6d, 0x40, 0xc4, 0x48, 0x80, 0x0, 0x0, 0x11, 0x80, 0x7e, 0x2c, 0x81, 0xfd, 0xb2, 0x5c, 0x58, + 0xbf, 0x17, 0x48, 0x2, 0x49, 0xc6, 0xeb, 0xcb, 0xa2, 0x4, 0xf1, 0xba, 0xf2, 0xe8, 0x9d, 0x6, + 0x9d, 0xa8, 0x20, 0xf2, 0xda, 0x81, 0x91, 0x2, 0x4, 0x8, 0x0, 0x0, 0x84, 0x5f, 0xb6, 0x64, 0x9, + 0x8e, 0xc, 0x44, 0xd, 0x4e, 0x37, 0x5e, 0x5d, 0x10, 0x27, 0x8d, 0xd7, 0x97, 0x44, 0xe8, 0x34, + 0xed, 0x41, 0x7, 0x96, 0xd4, 0xc, 0x58, 0x10, 0x20, 0x40, 0x0, 0x4, 0x2e, 0x8a, 0x53, 0x20, + 0x3e, 0xb3, 0x18, 0x28, 0x38, 0x14, 0x29, 0x0, 0x51, 0x38, 0xdd, 0x79, 0x74, 0x40, 0x9e, 0x37, + 0x5e, 0x5d, 0x13, 0xa0, 0xd3, 0xb5, 0x4, 0x1e, 0x5b, 0x50, 0x32, 0x20, 0x40, 0x81, 0x0, 0x0, + 0x10, 0x3a, 0xd6, 0x9d, 0x41, 0x32, 0x40, 0xa8, 0x83, 0xe9, 0xc6, 0xeb, 0xcb, 0xa2, 0x4, 0xf1, + 0xba, 0xf2, 0xe8, 0x9d, 0x6, 0x9d, 0xa8, 0x20, 0xf2, 0xda, 0x81, 0x9a, 0x2, 0x4, 0x8, 0x0, 0x0, + 0xab, 0x70, 0xaf, 0xe2, 0xa0, 0x7c, 0xe6, 0x30, 0x51, 0x0, 0x78, 0xa2, 0x20, 0x72, 0x71, 0xba, + 0xf2, 0xe8, 0x81, 0x3c, 0x6e, 0xbc, 0xba, 0x27, 0x41, 0xa7, 0x6a, 0x8, 0x3c, 0xb6, 0xa0, 0x62, + 0xc0, 0x81, 0x2, 0x0, 0x0, 0x21, 0x8c, 0x2f, 0x5a, 0x82, 0xb, 0x67, 0x41, 0x41, 0xa0, 0xa1, + 0x68, 0x4, 0x9, 0xc6, 0xeb, 0xcb, 0xa2, 0x4, 0xf1, 0xba, 0xf2, 0xe8, 0x9d, 0x6, 0x9d, 0xa8, + 0x20, 0xf2, 0xda, 0x81, 0x99, 0x2, 0x4, 0x8, 0x0, 0x0, 0xab, 0x7b, 0xcf, 0x8c, 0x40, 0x83, + 0x59, 0xd4, 0x40, 0xf4, 0xe3, 0x75, 0xe5, 0xd1, 0x2, 0x78, 0xdd, 0x79, 0x74, 0x4e, 0x83, 0x4e, + 0xd4, 0x10, 0x79, 0x6d, 0x40, 0xcd, 0x1, 0x2, 0x4, 0x0, 0x0, 0x55, 0xaf, 0xc8, 0x33, 0x20, + 0x3e, 0x73, 0x18, 0x28, 0x40, 0x34, 0x31, 0x90, 0x29, 0x68, 0xd8, 0x74, 0x72, 0xe7, 0xd3, 0x96, + 0x5d, 0x39, 0xb2, 0xee, 0x40, 0xc5, 0x6, 0x9d, 0xa8, 0x20, 0xf2, 0xda, 0x81, 0x99, 0xa1, 0x0, + 0x0, 0x0, 0x45, 0x5a, 0x6e, 0x7c, 0xf8, 0x9, 0xda, 0x77, 0x80, 0x80, 0x60, 0x2, 0xa7, 0xbf, + 0xbc, 0xf1, 0xa, 0xc1, 0x60, 0x0, 0xc, 0x15, 0x61, 0x4, 0x0, 0xc, 0xc, 0x80, 0x20, 0x9c, 0x6e, + 0xbc, 0xba, 0x20, 0x4f, 0x1b, 0xaf, 0x2e, 0x89, 0xd0, 0x69, 0xda, 0x82, 0xf, 0x2d, 0xa8, 0x19, + 0x90, 0x20, 0x40, 0x80, 0x0, 0xa, 0xb6, 0xbd, 0x0, 0x6c, 0x8, 0x35, 0x9d, 0x40, 0x22, 0x4e, + 0x37, 0x5e, 0x5d, 0x10, 0x27, 0x8d, 0xd7, 0x97, 0x44, 0xe8, 0x34, 0xed, 0x41, 0x7, 0x96, 0xd4, + 0xc, 0xc8, 0x10, 0x20, 0x40, 0x0, 0x5, 0x5b, 0x90, 0x7f, 0x2e, 0x3, 0xed, 0x31, 0xa8, 0x2, + 0xad, 0x1b, 0xe, 0x8e, 0x5c, 0xfa, 0x72, 0xcb, 0xa7, 0x36, 0x5d, 0xc8, 0x1a, 0xa0, 0xd3, 0xb5, + 0x4, 0x1e, 0x5b, 0x50, 0x31, 0x12, 0x20, 0x0, 0x0, 0x4, 0x60, 0x5b, 0x80, 0xa0, 0x73, 0x1c, + 0x62, 0x17, 0x9, 0x48, 0x12, 0x20, 0x8a, 0x71, 0xba, 0xf2, 0xe8, 0x81, 0x3c, 0x6e, 0xbc, 0xba, + 0x27, 0x41, 0xa7, 0x6a, 0x8, 0x3c, 0xb6, 0xa0, 0x64, 0x80, 0x81, 0x2, 0x0, 0x0, 0x20, 0xc3, + 0x4d, 0x6f, 0x1, 0x9d, 0x7d, 0x1, 0x42, 0x41, 0x1, 0xa0, +]; + +// More data: +// - 34 (Neuer Pferdemarkt/Neuer Kamp): 2 layers, quite complex bike crossings + +/// Just some random SPATEM +#[cfg(feature = "spat")] +pub const SPATEM: &[u8] = &[ + 0x02, 0x04, 0x00, 0x00, 0x30, 0x16, 0x00, 0x38, 0x4a, 0x6c, 0x1a, 0x17, 0xe4, 0xd4, 0xa7, 0x7d, + 0x93, 0x5b, 0x8c, 0x9a, 0xb0, 0x63, 0x71, 0x92, 0xe6, 0x50, 0x00, 0x30, 0x9c, 0x50, 0x40, 0x00, + 0x0e, 0xa7, 0x1d, 0x4f, 0xf0, 0x10, 0x00, 0x22, 0x8c, 0xe2, 0xf9, 0x44, 0xff, 0x63, 0x5b, 0x63, + 0x48, 0x71, 0xad, 0xb1, 0xae, 0xa1, 0xae, 0xa7, 0x80, 0x10, 0xa1, 0xb8, 0xbe, 0x29, 0x40, 0xa0, + 0xd6, 0xd8, 0xd1, 0x5c, 0x6b, 0x6c, 0x6c, 0x0c, 0x6c, 0x0d, 0xe0, 0x06, 0x28, 0x0e, 0x2d, 0xfa, + 0x68, 0xf6, 0x35, 0xb6, 0x34, 0x57, 0x1a, 0xdb, 0x1b, 0x03, 0x1b, 0x03, 0x78, 0x02, 0x0a, 0x1b, + 0x8d, 0x8b, 0x8d, 0xc0, 0x0d, 0xa7, 0x0d, 0x11, 0xc6, 0xd3, 0x86, 0xd4, 0xc6, 0xd4, 0xde, 0x00, + 0xa2, 0x8a, 0xe3, 0x5f, 0x23, 0x6c, 0x43, 0x66, 0x03, 0x43, 0x71, 0xb3, 0x01, 0xbb, 0x71, 0xba, + 0xd1, 0x80, 0x30, 0xa0, 0x38, 0xaf, 0x79, 0x86, 0x50, 0xd6, 0xd8, 0xd1, 0x5c, 0x6b, 0x6c, 0x6c, + 0x0c, 0x6c, 0x0c, 0x60, 0x0e, 0x28, 0xce, 0x35, 0xfc, 0x36, 0xce, 0x36, 0x6a, 0x34, 0x87, 0x1b, + 0x35, 0x1b, 0x44, 0x1b, 0x44, 0x78, 0x04, 0x0a, 0x1b, 0x8d, 0x8b, 0x8d, 0xc0, 0x0d, 0xa7, 0x0d, + 0x15, 0xc6, 0xd3, 0x86, 0xe3, 0xc6, 0xe1, 0x46, 0x01, 0x22, 0x80, 0xe2, 0xdf, 0xa5, 0xcd, 0x03, + 0x5b, 0x63, 0x45, 0x71, 0xad, 0xb1, 0xb1, 0xc1, 0xb0, 0xd1, 0x80, +]; + +/// Some random CAM +#[cfg(feature = "cam")] +pub const CAM: &[u8] = &[ + 0x02, 0x02, 0xde, 0x14, 0x0c, 0xe5, 0xc7, 0xc0, 0x40, 0x5a, 0xb2, 0x3d, 0x82, 0xce, 0x27, 0x81, + 0xe9, 0xa2, 0x78, 0x27, 0x4b, 0xc6, 0x33, 0xfa, 0x54, 0x58, 0x7c, 0xa0, 0xa2, 0x7e, 0x83, 0x02, + 0x96, 0x8a, 0x97, 0x33, 0xff, 0x82, 0x00, 0x1a, 0x10, 0x3f, 0xe0, 0x14, 0x39, 0x80, 0x10, 0x6e, + 0x00, 0x75, 0x80, 0x11, 0x58, 0xce, 0x00, 0x02, 0xf0, 0x3a, 0xdc, 0x08, 0xc4, 0xc8, 0x00, 0x01, + 0x57, 0x81, 0xd6, 0x20, 0x46, 0x96, 0x33, 0x80, 0x0a, 0xbc, 0x0e, 0xdb, 0x02, 0x39, 0x31, 0x9c, + 0x00, 0x55, 0xe0, 0x75, 0x08, 0x11, 0x85, 0x90, 0x00, 0x02, 0xaf, 0x03, 0xa0, 0xc0, 0x91, 0x2c, + 0x80, 0x00, 0x16, 0x78, 0x1c, 0x9e, 0x05, 0x65, 0x64, 0x00, 0x00, 0xc3, 0xc0, 0xe0, 0x90, 0x2d, + 0xbb, 0x19, 0xc0, 0x06, 0xde, 0x05, 0x8d, 0x81, 0x02, 0x18, 0xce, 0x00, 0x35, 0xf0, 0x15, 0x5c, + 0x00, 0x06, 0xc6, 0x70, 0x00, 0xdf, 0x80, 0x8d, 0x5f, 0xde, 0x66, 0x27, 0x00, 0x07, 0x3c, 0x04, + 0x76, 0xfd, 0x67, 0x31, 0x9c, 0x00, 0x58, 0x60, 0x41, 0x37, 0xd3, 0x15, 0x89, 0xc0, 0x06, 0xdc, +]; From 57a6b7983d7ae7c56f923cdf7cb16d522c1b69d3 Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Mon, 15 Jun 2026 21:36:34 +0200 Subject: [PATCH 06/49] main: Add feature flag for GNSS module --- Cargo.toml | 5 +++-- Readme.md | 5 ++++- src/applogic/mod.rs | 8 +++++++- src/main.rs | 5 +++++ 4 files changed, 19 insertions(+), 4 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 0c91516..aa57862 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,16 +30,17 @@ esp = [ "dep:esp-backtrace", "dep:esp-println", "dep:esp-radio", - "dep:embassy_gps", ] std = ["c-its-parser/std"] +# enable L67K GNSS module +gnss = ["dep:embassy_gps"] # echo CAM data on serial cam = ["c-its-parser/cam"] # echo DENM events on serial and BLE denm = ["c-its-parser/denm"] # evaluate SPAT information -spat = ["c-its-parser/spatem", "c-its-parser/mapem"] +spat = ["gnss", "c-its-parser/spatem", "c-its-parser/mapem"] # enable some debug printing (without enabling debug logging of the ESP RTOS) spat_debug = [] diff --git a/Readme.md b/Readme.md index 2393479..7b386cc 100644 --- a/Readme.md +++ b/Readme.md @@ -6,11 +6,14 @@ Displays different C-ITS receive-only use cases for cheap. The code is currently tailored to: - Seeed XIAO ESP32-C5 (8MB PSRAM) - Seeed XIAO L67K (connected on GPIO12 (RX), GPIO11 (TX), GPIO1 (wakeup) and GPIO25 (reset)) -TODO: only enable in `spat` feature? +only available with `gnss` feature (which is included in `spat` feature) - TODO: display (when using `spat` feature) ## Features +### GNSS +Enable L67K GNSS module and in main application logic/ state. + ### SPAT Takes the current position and heading from the GNSS receiver and displays the signal phases of the upcoming intersection. The upcoming intersection is selected when the own position is "inside" some ingress approach of the intersection. diff --git a/src/applogic/mod.rs b/src/applogic/mod.rs index 194ab6c..f691424 100644 --- a/src/applogic/mod.rs +++ b/src/applogic/mod.rs @@ -9,6 +9,7 @@ use alloc::vec::Vec; use esp_println::println; #[cfg(feature = "spat")] use log::{debug, error}; +#[cfg(feature = "gnss")] use log::{info, warn}; #[cfg(feature = "spat")] @@ -39,12 +40,16 @@ const GLOSA_LANE_LEFT_OFFSET_M: f64 = 20.; #[cfg(feature = "spat")] const GLOSA_STOP_LINE_PASSED_M: f64 = 10.; +#[allow(unused)] pub struct State { initialized: bool, time: chrono::NaiveDateTime, + #[allow(unused)] position: geo_types::Point, + #[allow(unused)] heading_deg: Option, + #[allow(unused)] speed_mps: Option, #[cfg(feature = "spat")] @@ -96,7 +101,7 @@ impl State { } } - #[cfg(feature = "esp")] + #[cfg(all(feature = "esp", feature = "gnss"))] pub fn update_with_gpsfix(&mut self, fix: &embassy_gps::types::GpsFix) { self.position = geo_types::Point::new(fix.longitude_deg, fix.latitude_deg); @@ -122,6 +127,7 @@ impl State { } } + #[cfg(feature = "gnss")] pub fn print_fix(&self) -> alloc::string::String { alloc::format!( "{:?}, {:.4} N, {:.4} E, {:.0}°, {:.2} m/s", diff --git a/src/main.rs b/src/main.rs index 79a3024..60dd720 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,6 +13,7 @@ use core::cell::RefCell; use critical_section::Mutex; use crossbeam_queue::ArrayQueue; use embassy_executor::Spawner; +#[cfg(feature = "gnss")] use embassy_gps::gps::l76k; use esp_backtrace as _; use esp_hal::clock::CpuClock; @@ -34,6 +35,7 @@ const WIFI_CHANNEL: radio::Channel = 180; // For more information see: esp_bootloader_esp_idf::esp_app_desc!(); +#[cfg(feature = "gnss")] static GNSS_UPDATE: Mutex>> = Mutex::new(RefCell::new(None)); static WIFI_RX_QUEUE: Mutex>>>> = Mutex::new(RefCell::new(None)); @@ -93,6 +95,7 @@ async fn main(spawner: Spawner) -> ! { // TX: D6/ GPIO11 // WAKEUP: D0/ GPIO1 -> HIGH for active, LOW for Sleep // RESET: D2/ GPIO25 -> HIGH for normal, LOW for Reset + #[cfg(feature = "gnss")] spawner.spawn( gnss_task( peripherals.GPIO1, @@ -109,6 +112,7 @@ async fn main(spawner: Spawner) -> ! { loop { embassy_time::Timer::after(embassy_time::Duration::from_millis(5)).await; + #[cfg(feature = "gnss")] critical_section::with(|cs| { let gnss_update_ref = GNSS_UPDATE.borrow(cs); @@ -236,6 +240,7 @@ fn handle_frame(frame: wifi::sniffer::PromiscuousPkt<'_>) { }); } +#[cfg(feature = "gnss")] #[allow(clippy::large_stack_frames, reason = "GNSS driver needs some space")] #[embassy_executor::task] async fn gnss_task( From 002f0bb08bc122a6c9838db7d97d931cae00b40e Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Wed, 17 Jun 2026 23:07:29 +0200 Subject: [PATCH 07/49] gnss: Only process every second fix event We always get two "fix" events per second right after each other. So let's drop one of them to not run GLOSA twice for each position update. --- src/main.rs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/main.rs b/src/main.rs index 60dd720..f00817f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -267,13 +267,18 @@ async fn gnss_task( ) .await; + let mut fix_drop_message = false; loop { if let Ok(Some(embassy_gps::types::GpsEvent::Fix(fix))) = gps.step().await { - // send to main thread - critical_section::with(|cs| { - let gnss_update_ref = GNSS_UPDATE.borrow(cs); - gnss_update_ref.replace(Some(fix)); - }); + // drop every second message as we always get each fix twice + fix_drop_message = !fix_drop_message; + if fix_drop_message { + // send to main thread + critical_section::with(|cs| { + let gnss_update_ref = GNSS_UPDATE.borrow(cs); + gnss_update_ref.replace(Some(fix)); + }); + } } else { // FSM will recover automatically from errors and ignore other event types } From a7f6440e7559a6f615c193cbf1af17ba480411e1 Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Wed, 17 Jun 2026 23:09:24 +0200 Subject: [PATCH 08/49] spat: decrease "stop line passed" distance --- src/applogic/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/applogic/mod.rs b/src/applogic/mod.rs index f691424..158812c 100644 --- a/src/applogic/mod.rs +++ b/src/applogic/mod.rs @@ -38,7 +38,7 @@ const GLOSA_AT_LANE_OFFSET_M: f64 = 10.; #[cfg(feature = "spat")] const GLOSA_LANE_LEFT_OFFSET_M: f64 = 20.; #[cfg(feature = "spat")] -const GLOSA_STOP_LINE_PASSED_M: f64 = 10.; +const GLOSA_STOP_LINE_PASSED_M: f64 = 5.; #[allow(unused)] pub struct State { From b2ec98da2e9fb3588879454381b4e06900be3d67 Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Mon, 15 Jun 2026 23:08:53 +0200 Subject: [PATCH 09/49] spat: Add screen --- Cargo.lock | 78 +++++++++++++++++ Cargo.toml | 8 +- Readme.md | 3 +- src/applogic/mod.rs | 108 ++++++++++++++++++----- src/applogic/v2x/map.rs | 6 +- src/main.rs | 102 +++++++++++++++++++++- src/screen.rs | 186 ++++++++++++++++++++++++++++++++++++++++ 7 files changed, 462 insertions(+), 29 deletions(-) create mode 100644 src/screen.rs diff --git a/Cargo.lock b/Cargo.lock index 23c5a3a..82c05fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -91,6 +91,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "az" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b7e4c2464d97fe331d41de9d5db0def0a96f4d823b8b32a2efd503578988973" + [[package]] name = "base64" version = "0.13.1" @@ -604,6 +610,29 @@ dependencies = [ "nb 1.1.0", ] +[[package]] +name = "embedded-graphics" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e8da660bb0c829b34a56a965490597f82a55e767b91f9543be80ce8ccb416fe" +dependencies = [ + "az", + "byteorder", + "embedded-graphics-core", + "float-cmp", + "micromath", +] + +[[package]] +name = "embedded-graphics-core" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95743bef3ff70fcba3930246c4e6872882bbea0dcc6da2ca860112e0cd4bd09f" +dependencies = [ + "az", + "byteorder", +] + [[package]] name = "embedded-hal" version = "0.2.7" @@ -629,6 +658,16 @@ dependencies = [ "embedded-hal 1.0.0", ] +[[package]] +name = "embedded-hal-bus" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513e0b3a8fb7d3013a8ae17a834283f170deaf7d0eeab0a7c1a36ad4dd356d22" +dependencies = [ + "critical-section", + "embedded-hal 1.0.0", +] + [[package]] name = "embedded-io" version = "0.6.1" @@ -1104,6 +1143,8 @@ dependencies = [ "embassy-executor", "embassy-time", "embassy_gps", + "embedded-graphics", + "embedded-hal-bus", "embedded-io 0.7.1", "embedded-io-async 0.7.0", "env_logger", @@ -1116,7 +1157,9 @@ dependencies = [ "esp-rtos", "geo-types", "log", + "mipidsi", "num-traits", + "profont", ] [[package]] @@ -1197,6 +1240,15 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" +dependencies = [ + "num-traits", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1537,12 +1589,29 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "micromath" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3c8dda44ff03a2f238717214da50f65d5a53b45cd213a7370424ffdb6fae815" + [[package]] name = "minimal-lexical" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "mipidsi" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790ebd28bd67addbccf41b1c0c188c26bb9f5bdcd91d4d6da9bd558e20d97a1d" +dependencies = [ + "embedded-graphics-core", + "embedded-hal 1.0.0", + "heapless 0.8.0", +] + [[package]] name = "nb" version = "0.1.3" @@ -1771,6 +1840,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "profont" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "016681370a9dd6e7ddb4c1a959922fd59dc45e5ebaa5ff5b13090267898ced34" +dependencies = [ + "embedded-graphics", +] + [[package]] name = "quote" version = "1.0.45" diff --git a/Cargo.toml b/Cargo.toml index aa57862..f0b0806 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,9 +40,11 @@ cam = ["c-its-parser/cam"] # echo DENM events on serial and BLE denm = ["c-its-parser/denm"] # evaluate SPAT information -spat = ["gnss", "c-its-parser/spatem", "c-its-parser/mapem"] +spat = ["gnss", "screen", "c-its-parser/spatem", "c-its-parser/mapem"] # enable some debug printing (without enabling debug logging of the ESP RTOS) spat_debug = [] +# enable ST7789 based display +screen = ["dep:mipidsi", "dep:embedded-hal-bus", "dep:profont"] [dependencies] @@ -81,6 +83,8 @@ esp-radio = { version = "0.18.0", optional = true, features = [ ] } embassy_gps = { version = "0.1.0", optional = true, default-features = false, features = ["esp", "log-04"], git = "https://github.com/jbeyerstedt/embassy_gps.git", branch = "feat/rework-gpsfix" } +embedded-hal-bus = { version = "0.3.0", optional = true } +embedded-graphics = "0.8.2" c-its-parser = { version = "2.2.2", default-features = false, features = [ "time", "geo", @@ -90,7 +94,9 @@ c-its-parser = { version = "2.2.2", default-features = false, features = [ chrono = { version = "0.4.44", default-features = false, features = ["alloc"] } crossbeam-queue = { version = "0.3.12", default-features = false, features = ["alloc"] } geo-types = { version = "0.7.19", default-features = false } +mipidsi = { version = "0.10.0", optional = true } num-traits = { version = "0.2.19", default-features = false, features = ["libm"] } +profont = { version = "0.7.0", optional = true } [dev-dependencies] assert_float_eq = "1.2.0" diff --git a/Readme.md b/Readme.md index 7b386cc..f80ac99 100644 --- a/Readme.md +++ b/Readme.md @@ -7,7 +7,8 @@ The code is currently tailored to: - Seeed XIAO ESP32-C5 (8MB PSRAM) - Seeed XIAO L67K (connected on GPIO12 (RX), GPIO11 (TX), GPIO1 (wakeup) and GPIO25 (reset)) only available with `gnss` feature (which is included in `spat` feature) -- TODO: display (when using `spat` feature) +- Some ST7789 172*320px screen (connected on GPIO8 (SCL/ SCK), GPIO10 (SDA/ MOSI), GPIO7 (reset), GPIO23 (DC), GPIO24 (CS), TODO GPIO0 (BL)) +only available with `screen` feature (which is included in `spat` feature) ## Features diff --git a/src/applogic/mod.rs b/src/applogic/mod.rs index 158812c..8065e88 100644 --- a/src/applogic/mod.rs +++ b/src/applogic/mod.rs @@ -5,15 +5,13 @@ use alloc::rc::Rc; #[cfg(feature = "spat")] use alloc::vec::Vec; -#[cfg(all(target_arch = "riscv32", feature = "spat"))] -use esp_println::println; #[cfg(feature = "spat")] use log::{debug, error}; #[cfg(feature = "gnss")] use log::{info, warn}; #[cfg(feature = "spat")] -use crate::geo_alg; +use crate::{applogic::v2x::map, geo_alg}; pub mod v2x; @@ -63,6 +61,54 @@ pub struct State { glosa_state: GlosaState, } +#[cfg(feature = "spat")] +#[derive(Debug, Default)] +pub struct SignalGroupData { + pub phase: SignalPhase, + pub maneuver: map::Maneuvers, + pub min_end_sec: i16, +} + +#[cfg(feature = "spat")] +#[derive(Debug, Default)] +pub enum SignalPhase { + #[default] + Unknown, + Red, + RedYellow, + Green, + Yellow, +} + +impl core::fmt::Display for SignalPhase { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + SignalPhase::Unknown => write!(f, "__"), + SignalPhase::Red => write!(f, "RE"), + SignalPhase::RedYellow => write!(f, "RY"), + SignalPhase::Green => write!(f, "GN"), + SignalPhase::Yellow => write!(f, "YE"), + } + } +} + +impl From for SignalPhase { + fn from(value: c_its_parser::standards::dsrc_2_2_1::etsi_its_dsrc::MovementPhaseState) -> Self { + use c_its_parser::standards::dsrc_2_2_1::etsi_its_dsrc::MovementPhaseState; + + match value { + MovementPhaseState::stop_And_Remain => SignalPhase::Red, + MovementPhaseState::pre_Movement => SignalPhase::RedYellow, + MovementPhaseState::permissive_Movement_Allowed + | MovementPhaseState::protected_Movement_Allowed => SignalPhase::Green, + MovementPhaseState::permissive_clearance | MovementPhaseState::protected_clearance => { + SignalPhase::Yellow + } + _ => SignalPhase::Unknown, + } + } +} + #[cfg(feature = "spat")] #[derive(Debug, Default)] enum GlosaState { @@ -172,12 +218,12 @@ impl State { } #[cfg(feature = "spat")] - pub fn run_glosa(&mut self) { + pub fn run_glosa(&mut self) -> Vec { let Some(own_heading) = self.heading_deg else { - return; + return alloc::vec![]; }; - let state_update = match &self.glosa_state { + let (state_update, sig_grps) = match &self.glosa_state { // search for upcoming intersection GlosaState::Idle => { debug!( @@ -193,9 +239,9 @@ impl State { search_result.signal_groups ); - Some(GlosaState::Locked(search_result)) + (Some(GlosaState::Locked(search_result)), None) } else { - None + (None, None) } } GlosaState::Locked(info) => { @@ -213,7 +259,7 @@ impl State { &self.position, ) { info!("GLOSA: Intersection was left -> back to idle"); - Some(GlosaState::Idle) + (Some(GlosaState::Idle), None) } else { // get signal groups which are in our approach let mut approach_sig_grps = intersection @@ -231,22 +277,36 @@ impl State { approach_sig_grps.sort_by_key(|v| v.maneuvers()); // display signal phases - for sig in approach_sig_grps { - if let Some(phase) = sig.phase_to_str() - && let Some(manv) = sig.maneuvers() - { - use core::ops::Sub; + let sig_grps = approach_sig_grps + .iter() + .filter_map(|sig| { + if let Some(manv) = sig.maneuvers() { + use core::ops::Sub; - let min_end = sig.min_end_time(); - let dur = min_end.sub(self.time.and_utc()).as_seconds_f32(); - println!( - "GLOSA: {manv} {phase} for {dur:.1} s (until {})", - sig.min_end_time() - ); - } - } + use num_traits::Float; - None // keep state + let min_end = sig.min_end_time(); + let dur = min_end.sub(self.time.and_utc()).as_seconds_f32(); + + let phase = sig + .phase() + .map(core::convert::Into::into) + .unwrap_or_default(); + #[allow(clippy::cast_possible_truncation)] + let min_end_sec = dur.round() as i16; + + Some(SignalGroupData { + phase, + maneuver: manv, + min_end_sec, + }) + } else { + None + } + }) + .collect::>(); + + (None, Some(sig_grps)) // keep state, return signal groups } } }; @@ -255,6 +315,8 @@ impl State { if let Some(new) = state_update { self.glosa_state = new; } + + sig_grps.unwrap_or_default() } #[cfg(feature = "spat")] diff --git a/src/applogic/v2x/map.rs b/src/applogic/v2x/map.rs index b49e818..0df7f0f 100644 --- a/src/applogic/v2x/map.rs +++ b/src/applogic/v2x/map.rs @@ -472,9 +472,9 @@ pub enum UsageType { #[derive(Debug, Default, Copy, Clone, PartialEq, Eq)] pub struct Maneuvers { - left_allowed: bool, - staight_allowed: bool, - right_allowed: bool, + pub left_allowed: bool, + pub staight_allowed: bool, + pub right_allowed: bool, } impl core::fmt::Display for Maneuvers { diff --git a/src/main.rs b/src/main.rs index f00817f..6e2cf24 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,6 +18,10 @@ use embassy_gps::gps::l76k; use esp_backtrace as _; use esp_hal::clock::CpuClock; use esp_hal::timer::timg::TimerGroup; +#[cfg(feature = "screen")] +use esp_hal::{delay, gpio, spi, time}; +#[cfg(all(target_arch = "riscv32", feature = "spat"))] +use esp_println::println; use esp_radio::wifi; use log::{debug, error, info, warn}; @@ -28,6 +32,8 @@ mod applogic; mod cache; mod geo_alg; mod radio; +#[cfg(feature = "screen")] +mod screen; const WIFI_CHANNEL: radio::Channel = 180; @@ -106,8 +112,53 @@ async fn main(spawner: Spawner) -> ! { ) .expect("Failed to spawn GNSS task"), ); + #[cfg(feature = "gnss")] info!("GNSS task started, waiting for GNSS fix..."); + // ST7789 172*320px screen + // SCL: D8/ GPIO8 (SPI_SCK) + // SDA: D10/ GPIO10 (SPI_MOSI) + // RES: D3/ GPIO7 + // DC: D4/ GPIO23 + // CS: D5/ GPIO24 + // BL: TODO (hard-wire to high? or D1/ GPIO0) + #[cfg(feature = "screen")] + let mut spi_buffer = [0_u8; 512]; + #[cfg(feature = "screen")] + let mut display = { + let (spi, cs) = setup_st7789_spi( + peripherals.GPIO8, + peripherals.GPIO10, + peripherals.GPIO24, + peripherals.GPIO9, + peripherals.SPI2, + ) + .expect("Failed to build SPI config"); + let output_config = gpio::OutputConfig::default(); + let dc_output = gpio::Output::new(peripherals.GPIO23, gpio::Level::Low, output_config); + let rst_output = gpio::Output::new(peripherals.GPIO7, gpio::Level::High, output_config); + let _ = gpio::Output::new(peripherals.GPIO0, gpio::Level::High, output_config); // backlight + + let mut display_delay = delay::Delay::new(); + + let spi_device = embedded_hal_bus::spi::ExclusiveDevice::new_no_delay(spi, cs) + .expect("Failed to initialize SPI device"); + + mipidsi::Builder::new( + mipidsi::models::ST7789, + mipidsi::interface::SpiInterface::new(spi_device, dc_output, &mut spi_buffer), + ) + .display_size(screen::HEIGHT + 34, screen::WIDTH) // somehow this display has 34 px of invisible space on the left (in native orientation) + .reset_pin(rst_output) + .invert_colors(mipidsi::options::ColorInversion::Inverted) + .orientation(mipidsi::options::Orientation::new().rotate(mipidsi::options::Rotation::Deg90)) + .init(&mut display_delay) + .expect("Failed to initialize display") + }; + + #[cfg(feature = "screen")] + let _ = screen::draw_slash_screen(&mut display).inspect_err(log_screen_error); + let mut state = applogic::State::new(); loop { embassy_time::Timer::after(embassy_time::Duration::from_millis(5)).await; @@ -124,7 +175,18 @@ async fn main(spawner: Spawner) -> ! { // run GLOSA algorithm #[cfg(feature = "spat")] - state.run_glosa(); + let signal_groups = state.run_glosa(); + + for sig in &signal_groups { + println!( + "GLOSA: {} {} for {} s", + sig.maneuver, sig.phase, sig.min_end_sec + ); + } + + #[cfg(feature = "screen")] + let _ = screen::draw_glosa(&mut display, &signal_groups) + .inspect_err(log_screen_error); } } }); @@ -284,3 +346,41 @@ async fn gnss_task( } } } + +/// Builds the SPI interface for the ST7789 display +/// +/// Returns (spi master, chip-select) tuple or SPI config error +#[cfg(feature = "screen")] +fn setup_st7789_spi( + scl: esp_hal::peripherals::GPIO8<'static>, + sda: esp_hal::peripherals::GPIO10<'static>, + cs: esp_hal::peripherals::GPIO24<'static>, + miso: esp_hal::peripherals::GPIO9<'static>, + spi: esp_hal::peripherals::SPI2<'static>, +) -> Result< + ( + spi::master::Spi<'static, esp_hal::Blocking>, + gpio::Output<'static>, + ), + spi::master::ConfigError, +> { + let spi_config = spi::master::Config::default() + .with_frequency(time::Rate::from_mhz(10)) + .with_mode(spi::Mode::_0); + let spi: spi::master::Spi<'_, esp_hal::Blocking> = spi::master::Spi::new(spi, spi_config)? + .with_sck(scl) + .with_mosi(sda) + .with_miso(miso); + + let cs_output = gpio::Output::new(cs, gpio::Level::High, gpio::OutputConfig::default()); + + Ok((spi, cs_output)) +} + +#[cfg(feature = "screen")] +fn log_screen_error(error: &E) +where + E: core::fmt::Debug, +{ + error!("TODO: {error:?}"); +} diff --git a/src/screen.rs b/src/screen.rs new file mode 100644 index 0000000..7b84908 --- /dev/null +++ b/src/screen.rs @@ -0,0 +1,186 @@ +//! Screen Output + +use alloc::vec; + +use embedded_graphics::prelude::*; +use embedded_graphics::{geometry, mono_font, pixelcolor, primitives, text}; + +use crate::applogic; + +pub const WIDTH: u16 = 320; +pub const HEIGHT: u16 = 172; + +#[allow(unused)] +pub fn clear(target: &mut D) -> Result<(), D::Error> +where + D: DrawTarget, +{ + let style = primitives::PrimitiveStyleBuilder::new() + .fill_color(pixelcolor::Rgb565::BLACK) + .build(); + primitives::Rectangle::new( + geometry::Point::new(0, 0), + geometry::Size::new(WIDTH.into(), HEIGHT.into()), + ) + .into_styled(style) + .draw(target)?; + + Ok(()) +} + +#[allow(unused)] +pub fn test_img(target: &mut D) -> Result<(), D::Error> +where + D: DrawTarget, + C: RgbColor, +{ + mipidsi::TestImage::new().draw(target)?; + + Ok(()) +} + +#[allow(unused)] +pub fn draw_glosa(target: &mut D, data: &[applogic::SignalGroupData]) -> Result<(), D::Error> +where + D: DrawTarget, +{ + let num_signals = data.len(); + + if num_signals == 0 { + return clear(target); + } + + #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)] + let sig_width = (WIDTH as i32) / (num_signals as i32); + + let text_style = + mono_font::MonoTextStyle::new(&profont::PROFONT_24_POINT, pixelcolor::Rgb565::WHITE); + let blank_style = primitives::PrimitiveStyleBuilder::new() + .fill_color(pixelcolor::Rgb565::CSS_GRAY) + .build(); + let rd_style = primitives::PrimitiveStyleBuilder::new() + .fill_color(pixelcolor::Rgb565::CSS_RED) + .build(); + let ye_style = primitives::PrimitiveStyleBuilder::new() + .fill_color(pixelcolor::Rgb565::CSS_YELLOW) // TODO: select nicer color + .build(); + let gn_style = primitives::PrimitiveStyleBuilder::new() + .fill_color(pixelcolor::Rgb565::CSS_GREEN) + .build(); + + // clear maneuver and timing area + let style = primitives::PrimitiveStyleBuilder::new() + .fill_color(pixelcolor::Rgb565::BLACK) + .build(); + primitives::Rectangle::new( + geometry::Point::new(0, 105), + geometry::Size::new(WIDTH.into(), 67), + ) + .into_styled(style) + .draw(target)?; + + for (idx, sig) in data.iter().enumerate() { + #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)] + let offset = (idx as i32) * sig_width; + + let circle_dia = 50; + let circle_margin = (sig_width - circle_dia) / 2; + let centerline = offset + (sig_width / 2); + + // draw phase color + let style = match sig.phase { + applogic::SignalPhase::Unknown => blank_style, + applogic::SignalPhase::Red => rd_style, + applogic::SignalPhase::Green => gn_style, + applogic::SignalPhase::RedYellow | applogic::SignalPhase::Yellow => ye_style, + }; + primitives::Circle::new( + geometry::Point::new(offset + circle_margin, 10), + circle_dia as u32, + ) + .into_styled(style) + .draw(target)?; + + // TODO: draw lane type? + + // draw maneuver + text::Text::with_alignment( + &alloc::format!("{}", maneuver_to_str(&sig.maneuver)), + geometry::Point::new(centerline, 130), + text_style, + text::Alignment::Center, + ) + .draw(target)?; + + // draw timing indicator + text::Text::with_alignment( + &alloc::format!("{}s", sig.min_end_sec), + geometry::Point::new(centerline, 160), + text_style, + text::Alignment::Center, + ) + .draw(target)?; + } + + Ok(()) +} + +#[allow(unused)] +pub fn draw_slash_screen(target: &mut D) -> Result<(), D::Error> +where + D: DrawTarget, +{ + let text_style = + mono_font::MonoTextStyle::new(&profont::PROFONT_18_POINT, pixelcolor::Rgb565::WHITE); + let blank_style = primitives::PrimitiveStyleBuilder::new() + .fill_color(pixelcolor::Rgb565::CSS_GRAY) + .build(); + let rd_style = primitives::PrimitiveStyleBuilder::new() + .fill_color(pixelcolor::Rgb565::CSS_RED) + .build(); + let ye_style = primitives::PrimitiveStyleBuilder::new() + .fill_color(pixelcolor::Rgb565::CSS_YELLOW) // TODO: select nicer color + .build(); + let gn_style = primitives::PrimitiveStyleBuilder::new() + .fill_color(pixelcolor::Rgb565::CSS_GREEN) + .build(); + + clear(target)?; + + let sig_width = (WIDTH as i32) / 3; + let circle_dia = 50; + let circle_margin = (sig_width - circle_dia) / 2; + + for (idx, style) in vec![rd_style, ye_style, gn_style].iter().enumerate() { + #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)] + let offset = (idx as i32) * sig_width; + + primitives::Circle::new( + geometry::Point::new(offset + circle_margin, 10), + circle_dia as u32, + ) + .into_styled(*style) + .draw(target)?; + } + + // draw welcome text + let centerline = (WIDTH as i32) / 2; + text::Text::with_alignment( + "C-ITS Signal Phase Display\nWaiting for GNSS fix...", + geometry::Point::new(centerline, 130), + text_style, + text::Alignment::Center, + ) + .draw(target)?; + + Ok(()) +} + +fn maneuver_to_str(value: &applogic::v2x::map::Maneuvers) -> alloc::string::String { + alloc::format!( + "{}{}{}", + if value.left_allowed { "<" } else { " " }, + if value.staight_allowed { "^" } else { " " }, + if value.right_allowed { ">" } else { " " }, + ) +} From f7325859890fdbea097834fd20f920b303ccfbf9 Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Fri, 19 Jun 2026 22:01:42 +0200 Subject: [PATCH 10/49] screen: Initialize before GNSS and WiFI --- src/main.rs | 71 +++++++++++++++++++++++++++-------------------------- 1 file changed, 36 insertions(+), 35 deletions(-) diff --git a/src/main.rs b/src/main.rs index 6e2cf24..1fa1bfb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -80,41 +80,6 @@ async fn main(spawner: Spawner) -> ! { info!("Embassy initialized!"); - let (_wifi_controller, interfaces) = esp_radio::wifi::new( - peripherals.WIFI, - esp_radio::wifi::ControllerConfig::default(), - ) - .expect("Failed to initialize Wi-Fi controller"); - - critical_section::with(|cs| { - WIFI_RX_QUEUE.borrow(cs).replace(Some(ArrayQueue::new(2))); - }); - - let mut sniffer = interfaces.sniffer; - radio::setup_wifi_sniffer(WIFI_CHANNEL, &mut sniffer, handle_frame) - .expect("Fatal error initializing 802.11p sniffer"); - - info!("WiFi promiscuous mode on channel {WIFI_CHANNEL} running"); - - // Seeed XIAO L67K: - // RX: D7/ GPIO12 - // TX: D6/ GPIO11 - // WAKEUP: D0/ GPIO1 -> HIGH for active, LOW for Sleep - // RESET: D2/ GPIO25 -> HIGH for normal, LOW for Reset - #[cfg(feature = "gnss")] - spawner.spawn( - gnss_task( - peripherals.GPIO1, - peripherals.GPIO25, - peripherals.UART0, - peripherals.GPIO12, - peripherals.GPIO11, - ) - .expect("Failed to spawn GNSS task"), - ); - #[cfg(feature = "gnss")] - info!("GNSS task started, waiting for GNSS fix..."); - // ST7789 172*320px screen // SCL: D8/ GPIO8 (SPI_SCK) // SDA: D10/ GPIO10 (SPI_MOSI) @@ -159,6 +124,42 @@ async fn main(spawner: Spawner) -> ! { #[cfg(feature = "screen")] let _ = screen::draw_slash_screen(&mut display).inspect_err(log_screen_error); + // Setup WiFi + let (_wifi_controller, interfaces) = esp_radio::wifi::new( + peripherals.WIFI, + esp_radio::wifi::ControllerConfig::default(), + ) + .expect("Failed to initialize Wi-Fi controller"); + + critical_section::with(|cs| { + WIFI_RX_QUEUE.borrow(cs).replace(Some(ArrayQueue::new(2))); + }); + + let mut sniffer = interfaces.sniffer; + radio::setup_wifi_sniffer(WIFI_CHANNEL, &mut sniffer, handle_frame) + .expect("Fatal error initializing 802.11p sniffer"); + + info!("WiFi promiscuous mode on channel {WIFI_CHANNEL} running"); + + // Seeed XIAO L67K: + // RX: D7/ GPIO12 + // TX: D6/ GPIO11 + // WAKEUP: D0/ GPIO1 -> HIGH for active, LOW for Sleep + // RESET: D2/ GPIO25 -> HIGH for normal, LOW for Reset + #[cfg(feature = "gnss")] + spawner.spawn( + gnss_task( + peripherals.GPIO1, + peripherals.GPIO25, + peripherals.UART0, + peripherals.GPIO12, + peripherals.GPIO11, + ) + .expect("Failed to spawn GNSS task"), + ); + #[cfg(feature = "gnss")] + info!("GNSS task started, waiting for GNSS fix..."); + let mut state = applogic::State::new(); loop { embassy_time::Timer::after(embassy_time::Duration::from_millis(5)).await; From ff45df82047413497eb153c18a65fd258e549c7b Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Fri, 19 Jun 2026 22:03:59 +0200 Subject: [PATCH 11/49] spat: Show signal groups without timing information --- src/applogic/mod.rs | 16 ++++++++++------ src/applogic/v2x/map.rs | 18 +++++++++++------- src/main.rs | 9 +++++---- src/screen.rs | 16 +++++++++------- 4 files changed, 35 insertions(+), 24 deletions(-) diff --git a/src/applogic/mod.rs b/src/applogic/mod.rs index 8065e88..f0f89f9 100644 --- a/src/applogic/mod.rs +++ b/src/applogic/mod.rs @@ -66,7 +66,7 @@ pub struct State { pub struct SignalGroupData { pub phase: SignalPhase, pub maneuver: map::Maneuvers, - pub min_end_sec: i16, + pub min_end_sec: Option, } #[cfg(feature = "spat")] @@ -285,15 +285,19 @@ impl State { use num_traits::Float; - let min_end = sig.min_end_time(); - let dur = min_end.sub(self.time.and_utc()).as_seconds_f32(); - let phase = sig .phase() .map(core::convert::Into::into) .unwrap_or_default(); - #[allow(clippy::cast_possible_truncation)] - let min_end_sec = dur.round() as i16; + + let min_end_sec = sig.min_end_time().map(|time| { + let dur = time.sub(self.time.and_utc()).as_seconds_f32(); + + #[allow(clippy::cast_possible_truncation)] + let min_end_sec = dur.round() as i16; + + min_end_sec + }); Some(SignalGroupData { phase, diff --git a/src/applogic/v2x/map.rs b/src/applogic/v2x/map.rs index 0df7f0f..44f4ef9 100644 --- a/src/applogic/v2x/map.rs +++ b/src/applogic/v2x/map.rs @@ -154,15 +154,19 @@ impl Intersection { for state in &value.states.0 { let sig_grp = state.signal_group.0; - if let Some(event) = state.state_time_speed.0.first() - && let Some(timing) = &event.timing - { + if let Some(event) = state.state_time_speed.0.first() { // get write access to SignalGroup item if let Some(idx) = self.signal_groups.iter().position(|i| i.id == sig_grp) && let Some(sig_grp) = self.signal_groups.get_mut(idx) { sig_grp.phase = Some(event.event_state); - sig_grp.min_end_time = timing.min_end_time.to_datetime_from_moy(moy, year); + if let Some(timing) = &event.timing + && !timing.min_end_time.is_out_of_range() + && !timing.min_end_time.is_unknown() + { + sig_grp.min_end_time = + Some(timing.min_end_time.to_datetime_from_moy(moy, year)); + } } } else { // warn!("SPAT: SG {sig_grp:03} has no timing in first event"); @@ -211,7 +215,7 @@ impl Intersection { maneuvers, target_lanes, phase: None, - min_end_time: chrono::DateTime::default(), + min_end_time: None, } }) .collect() @@ -397,7 +401,7 @@ pub struct SignalGroup { // SPAT data /// current signal phase phase: Option, - min_end_time: chrono::DateTime, + min_end_time: Option>, // max_end_time: Option>, // likely_time: Option>, } @@ -425,7 +429,7 @@ impl SignalGroup { pub fn phase(&self) -> Option { self.phase } - pub fn min_end_time(&self) -> chrono::prelude::DateTime { + pub fn min_end_time(&self) -> Option> { self.min_end_time } diff --git a/src/main.rs b/src/main.rs index 1fa1bfb..9c8dfc4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -179,10 +179,11 @@ async fn main(spawner: Spawner) -> ! { let signal_groups = state.run_glosa(); for sig in &signal_groups { - println!( - "GLOSA: {} {} for {} s", - sig.maneuver, sig.phase, sig.min_end_sec - ); + let duration_str = sig + .min_end_sec + .map(|v| alloc::format!("for {v} s")) + .unwrap_or_default(); + println!("GLOSA: {} {} {duration_str}", sig.maneuver, sig.phase); } #[cfg(feature = "screen")] diff --git a/src/screen.rs b/src/screen.rs index 7b84908..e3fc88a 100644 --- a/src/screen.rs +++ b/src/screen.rs @@ -113,13 +113,15 @@ where .draw(target)?; // draw timing indicator - text::Text::with_alignment( - &alloc::format!("{}s", sig.min_end_sec), - geometry::Point::new(centerline, 160), - text_style, - text::Alignment::Center, - ) - .draw(target)?; + if let Some(min_end_sec) = sig.min_end_sec { + text::Text::with_alignment( + &alloc::format!("{min_end_sec}s"), + geometry::Point::new(centerline, 160), + text_style, + text::Alignment::Center, + ) + .draw(target)?; + } } Ok(()) From 47bb1f1284f64a2fd292fac818bbb0a123bd073f Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Fri, 19 Jun 2026 22:04:30 +0200 Subject: [PATCH 12/49] screen: Show placeholder when no signal groups available --- src/main.rs | 2 +- src/screen.rs | 38 ++++++++++++++++++++++---------------- 2 files changed, 23 insertions(+), 17 deletions(-) diff --git a/src/main.rs b/src/main.rs index 9c8dfc4..b86b9f3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -384,5 +384,5 @@ fn log_screen_error(error: &E) where E: core::fmt::Debug, { - error!("TODO: {error:?}"); + error!("graphics failed: {error:?}"); } diff --git a/src/screen.rs b/src/screen.rs index e3fc88a..065d6e6 100644 --- a/src/screen.rs +++ b/src/screen.rs @@ -1,7 +1,5 @@ //! Screen Output -use alloc::vec; - use embedded_graphics::prelude::*; use embedded_graphics::{geometry, mono_font, pixelcolor, primitives, text}; @@ -46,13 +44,6 @@ where { let num_signals = data.len(); - if num_signals == 0 { - return clear(target); - } - - #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)] - let sig_width = (WIDTH as i32) / (num_signals as i32); - let text_style = mono_font::MonoTextStyle::new(&profont::PROFONT_24_POINT, pixelcolor::Rgb565::WHITE); let blank_style = primitives::PrimitiveStyleBuilder::new() @@ -79,6 +70,21 @@ where .into_styled(style) .draw(target)?; + if num_signals == 0 { + text::Text::with_alignment( + "No upcoming SGs", + geometry::Point::new(i32::from(WIDTH) / 2, 130), + text_style, + text::Alignment::Center, + ) + .draw(target)?; + + return Ok(()); + } + + #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)] + let sig_width = i32::from(WIDTH) / (num_signals as i32); + for (idx, sig) in data.iter().enumerate() { #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)] let offset = (idx as i32) * sig_width; @@ -96,7 +102,7 @@ where }; primitives::Circle::new( geometry::Point::new(offset + circle_margin, 10), - circle_dia as u32, + circle_dia.cast_unsigned(), ) .into_styled(style) .draw(target)?; @@ -105,7 +111,7 @@ where // draw maneuver text::Text::with_alignment( - &alloc::format!("{}", maneuver_to_str(&sig.maneuver)), + &maneuver_to_str(sig.maneuver), geometry::Point::new(centerline, 130), text_style, text::Alignment::Center, @@ -149,24 +155,24 @@ where clear(target)?; - let sig_width = (WIDTH as i32) / 3; + let sig_width = i32::from(WIDTH) / 3; let circle_dia = 50; let circle_margin = (sig_width - circle_dia) / 2; - for (idx, style) in vec![rd_style, ye_style, gn_style].iter().enumerate() { + for (idx, style) in [rd_style, ye_style, gn_style].iter().enumerate() { #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)] let offset = (idx as i32) * sig_width; primitives::Circle::new( geometry::Point::new(offset + circle_margin, 10), - circle_dia as u32, + circle_dia.cast_unsigned(), ) .into_styled(*style) .draw(target)?; } // draw welcome text - let centerline = (WIDTH as i32) / 2; + let centerline = i32::from(WIDTH) / 2; text::Text::with_alignment( "C-ITS Signal Phase Display\nWaiting for GNSS fix...", geometry::Point::new(centerline, 130), @@ -178,7 +184,7 @@ where Ok(()) } -fn maneuver_to_str(value: &applogic::v2x::map::Maneuvers) -> alloc::string::String { +fn maneuver_to_str(value: applogic::v2x::map::Maneuvers) -> alloc::string::String { alloc::format!( "{}{}{}", if value.left_allowed { "<" } else { " " }, From 712550160f965ca59cbd27e22cf09292f07127bc Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Fri, 19 Jun 2026 22:17:52 +0200 Subject: [PATCH 13/49] screen: Increase SPI speed --- src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index b86b9f3..7c23a1a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -367,7 +367,7 @@ fn setup_st7789_spi( spi::master::ConfigError, > { let spi_config = spi::master::Config::default() - .with_frequency(time::Rate::from_mhz(10)) + .with_frequency(time::Rate::from_mhz(40)) .with_mode(spi::Mode::_0); let spi: spi::master::Spi<'_, esp_hal::Blocking> = spi::master::Spi::new(spi, spi_config)? .with_sck(scl) From c771afe7447e5cec12d5809ff89c030539ca2088 Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Sat, 20 Jun 2026 19:53:01 +0200 Subject: [PATCH 14/49] spat: Use likely time, if available --- src/applogic/mod.rs | 20 ++++++++++++-------- src/applogic/v2x/map.rs | 25 ++++++++++++++++++------- src/main.rs | 2 +- src/screen.rs | 4 ++-- 4 files changed, 33 insertions(+), 18 deletions(-) diff --git a/src/applogic/mod.rs b/src/applogic/mod.rs index f0f89f9..cdb057e 100644 --- a/src/applogic/mod.rs +++ b/src/applogic/mod.rs @@ -66,7 +66,7 @@ pub struct State { pub struct SignalGroupData { pub phase: SignalPhase, pub maneuver: map::Maneuvers, - pub min_end_sec: Option, + pub end_sec: Option, } #[cfg(feature = "spat")] @@ -290,19 +290,23 @@ impl State { .map(core::convert::Into::into) .unwrap_or_default(); - let min_end_sec = sig.min_end_time().map(|time| { - let dur = time.sub(self.time.and_utc()).as_seconds_f32(); + let end_sec = + sig.likely_time() + .or_else(|| sig.min_end_time()) + .map(|time| { + let dur = + time.sub(self.time.and_utc()).as_seconds_f32(); - #[allow(clippy::cast_possible_truncation)] - let min_end_sec = dur.round() as i16; + #[allow(clippy::cast_possible_truncation)] + let end_sec = dur.round() as i16; - min_end_sec - }); + end_sec + }); Some(SignalGroupData { phase, maneuver: manv, - min_end_sec, + end_sec, }) } else { None diff --git a/src/applogic/v2x/map.rs b/src/applogic/v2x/map.rs index 44f4ef9..c8f781f 100644 --- a/src/applogic/v2x/map.rs +++ b/src/applogic/v2x/map.rs @@ -160,12 +160,19 @@ impl Intersection { && let Some(sig_grp) = self.signal_groups.get_mut(idx) { sig_grp.phase = Some(event.event_state); - if let Some(timing) = &event.timing - && !timing.min_end_time.is_out_of_range() - && !timing.min_end_time.is_unknown() - { - sig_grp.min_end_time = - Some(timing.min_end_time.to_datetime_from_moy(moy, year)); + if let Some(timing) = &event.timing { + if !timing.min_end_time.is_out_of_range() + && !timing.min_end_time.is_unknown() + { + sig_grp.min_end_time = + Some(timing.min_end_time.to_datetime_from_moy(moy, year)); + } + if let Some(likely) = &timing.likely_time + && !likely.is_out_of_range() + && !likely.is_unknown() + { + sig_grp.likely_time = Some(likely.to_datetime_from_moy(moy, year)); + } } } } else { @@ -216,6 +223,7 @@ impl Intersection { target_lanes, phase: None, min_end_time: None, + likely_time: None, } }) .collect() @@ -403,7 +411,7 @@ pub struct SignalGroup { phase: Option, min_end_time: Option>, // max_end_time: Option>, - // likely_time: Option>, + likely_time: Option>, } impl core::fmt::Display for SignalGroup { @@ -432,6 +440,9 @@ impl SignalGroup { pub fn min_end_time(&self) -> Option> { self.min_end_time } + pub fn likely_time(&self) -> Option> { + self.likely_time + } pub fn phase_to_str(&self) -> Option { self.phase.map(|v| { diff --git a/src/main.rs b/src/main.rs index 7c23a1a..850e495 100644 --- a/src/main.rs +++ b/src/main.rs @@ -180,7 +180,7 @@ async fn main(spawner: Spawner) -> ! { for sig in &signal_groups { let duration_str = sig - .min_end_sec + .end_sec .map(|v| alloc::format!("for {v} s")) .unwrap_or_default(); println!("GLOSA: {} {} {duration_str}", sig.maneuver, sig.phase); diff --git a/src/screen.rs b/src/screen.rs index 065d6e6..28c1eb8 100644 --- a/src/screen.rs +++ b/src/screen.rs @@ -119,9 +119,9 @@ where .draw(target)?; // draw timing indicator - if let Some(min_end_sec) = sig.min_end_sec { + if let Some(end_sec) = sig.end_sec { text::Text::with_alignment( - &alloc::format!("{min_end_sec}s"), + &alloc::format!("{end_sec}s"), geometry::Point::new(centerline, 160), text_style, text::Alignment::Center, From 1b504b1ee2ca1cf7877cacb2a25157596d2a58df Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Sat, 20 Jun 2026 19:54:04 +0200 Subject: [PATCH 15/49] spat: fix `get_search_angle` --- src/applogic/mod.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/applogic/mod.rs b/src/applogic/mod.rs index cdb057e..e4ce98f 100644 --- a/src/applogic/mod.rs +++ b/src/applogic/mod.rs @@ -475,8 +475,8 @@ impl State { #[cfg(feature = "spat")] fn get_search_angle(distance: f32) -> f32 { - let (dist_near, angle_near) = GLOSA_SEARCH_ANGLE_PARAMS[0]; - let (dist_far, angle_far) = GLOSA_SEARCH_ANGLE_PARAMS[1]; + let (dist_far, angle_far) = GLOSA_SEARCH_ANGLE_PARAMS[0]; + let (dist_near, angle_near) = GLOSA_SEARCH_ANGLE_PARAMS[1]; if distance > dist_far { angle_far From 696bc97ca51c8c76805d7e23474a0183eaca54ae Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Sat, 20 Jun 2026 20:03:21 +0200 Subject: [PATCH 16/49] add missing feature flags --- src/applogic/mod.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/applogic/mod.rs b/src/applogic/mod.rs index e4ce98f..e9d28ba 100644 --- a/src/applogic/mod.rs +++ b/src/applogic/mod.rs @@ -80,6 +80,7 @@ pub enum SignalPhase { Yellow, } +#[cfg(feature = "spat")] impl core::fmt::Display for SignalPhase { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { match self { @@ -92,6 +93,7 @@ impl core::fmt::Display for SignalPhase { } } +#[cfg(feature = "spat")] impl From for SignalPhase { fn from(value: c_its_parser::standards::dsrc_2_2_1::etsi_its_dsrc::MovementPhaseState) -> Self { use c_its_parser::standards::dsrc_2_2_1::etsi_its_dsrc::MovementPhaseState; From e0242512607295ac168f8b1f7ee515bc1cc1153c Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Sat, 20 Jun 2026 19:39:50 +0200 Subject: [PATCH 17/49] screen: clear sceen on first GNSS fix --- src/main.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main.rs b/src/main.rs index 850e495..f5766fc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -169,8 +169,13 @@ async fn main(spawner: Spawner) -> ! { let gnss_update_ref = GNSS_UPDATE.borrow(cs); if let Some(fix) = gnss_update_ref.replace(None) { + let prev_state = state.initialized(); state.update_with_gpsfix(&fix); + if prev_state != state.initialized() { + let _ = screen::clear(&mut display).inspect_err(log_screen_error); + } + if fix.get_timestamp().is_some() { info!("{}", state.print_fix()); From 26a8d0d18a0cccadfb1dcb49f2f7d44570987c37 Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Sat, 20 Jun 2026 20:01:31 +0200 Subject: [PATCH 18/49] main: Make `critical_section`s as short as possible --- src/main.rs | 143 ++++++++++++++++++++++++++++------------------------ 1 file changed, 77 insertions(+), 66 deletions(-) diff --git a/src/main.rs b/src/main.rs index f5766fc..76afcb4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -162,25 +162,32 @@ async fn main(spawner: Spawner) -> ! { let mut state = applogic::State::new(); loop { - embassy_time::Timer::after(embassy_time::Duration::from_millis(5)).await; + embassy_time::Timer::after(embassy_time::Duration::from_millis(1)).await; #[cfg(feature = "gnss")] - critical_section::with(|cs| { - let gnss_update_ref = GNSS_UPDATE.borrow(cs); + { + let mut new_position = false; + critical_section::with(|cs| { + let gnss_update_ref = GNSS_UPDATE.borrow(cs); - if let Some(fix) = gnss_update_ref.replace(None) { - let prev_state = state.initialized(); - state.update_with_gpsfix(&fix); + if let Some(fix) = gnss_update_ref.replace(None) { + let prev_state = state.initialized(); + state.update_with_gpsfix(&fix); - if prev_state != state.initialized() { - let _ = screen::clear(&mut display).inspect_err(log_screen_error); + if prev_state != state.initialized() { + let _ = screen::clear(&mut display).inspect_err(log_screen_error); + } + + new_position = true; } + }); - if fix.get_timestamp().is_some() { - info!("{}", state.print_fix()); + if new_position { + info!("{}", state.print_fix()); - // run GLOSA algorithm - #[cfg(feature = "spat")] + // run GLOSA algorithm + #[cfg(feature = "spat")] + { let signal_groups = state.run_glosa(); for sig in &signal_groups { @@ -196,69 +203,73 @@ async fn main(spawner: Spawner) -> ! { .inspect_err(log_screen_error); } } - }); + } + let mut new_data = None; critical_section::with(|cs| { let queue_rc = WIFI_RX_QUEUE.borrow(cs).borrow(); // unwrap is fine b/c we stored something in it before let queue = queue_rc.as_ref().unwrap(); if let Some(data) = queue.pop() { - match c_its_parser::de::decode(&data, c_its_parser::Headers::IEEE802LlcGnBtp) { - Ok(msg) => { - use c_its_parser::ItsMessage; - use c_its_parser::standards::extensions::ItsMessageId; - - debug!("Got new {:?} message", ItsMessageId::from(&msg)); - - match msg { - #[cfg(feature = "spat")] - ItsMessage::Mapem { - geonetworking: _, - transport: _, - etsi, - } => state.handle_mapem(&etsi), - #[cfg(feature = "spat")] - ItsMessage::Spatem { - geonetworking: _, - transport: _, - etsi, - } => { - if state.initialized() { - state.handle_spatem(&etsi); - } - } - - #[cfg(feature = "cam")] - ItsMessage::Cam { - geonetworking: _, - transport: _, - etsi, - } => applogic::cam::handle_cam(&etsi), - - #[cfg(feature = "denm")] - ItsMessage::DenmV2 { - geonetworking: _, - transport: _, - etsi, - } => applogic::denm::handle_denm(&etsi), - #[cfg(feature = "denm")] - ItsMessage::DenmV1 { - geonetworking: _, - transport: _, - etsi: _, - } => { - // irrelevant since all received DENMs should be parsed as v2 - } - } - } - Err(err) => { - // will give false-positives when a message is received which wasn't enabled - debug!("Failed to parse V2X message: {err}"); - } - } + new_data = Some(data); } }); + if let Some(data) = new_data { + match c_its_parser::de::decode(&data, c_its_parser::Headers::IEEE802LlcGnBtp) { + Ok(msg) => { + use c_its_parser::ItsMessage; + use c_its_parser::standards::extensions::ItsMessageId; + + debug!("Got new {:?} message", ItsMessageId::from(&msg)); + + match msg { + #[cfg(feature = "spat")] + ItsMessage::Mapem { + geonetworking: _, + transport: _, + etsi, + } => state.handle_mapem(&etsi), + #[cfg(feature = "spat")] + ItsMessage::Spatem { + geonetworking: _, + transport: _, + etsi, + } => { + if state.initialized() { + state.handle_spatem(&etsi); + } + } + + #[cfg(feature = "cam")] + ItsMessage::Cam { + geonetworking: _, + transport: _, + etsi, + } => applogic::cam::handle_cam(&etsi), + + #[cfg(feature = "denm")] + ItsMessage::DenmV2 { + geonetworking: _, + transport: _, + etsi, + } => applogic::denm::handle_denm(&etsi), + #[cfg(feature = "denm")] + ItsMessage::DenmV1 { + geonetworking: _, + transport: _, + etsi: _, + } => { + // irrelevant since all received DENMs should be parsed as v2 + } + } + } + Err(err) => { + // will give false-positives when a message is received which wasn't enabled + debug!("Failed to parse V2X message: {err}"); + } + } + } // maintenance state.prune(); From dc547b8178dba014d96f1e5c7f88a694adf52138 Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Sat, 20 Jun 2026 23:06:38 +0200 Subject: [PATCH 19/49] spat: Increase TLC cache lifetime --- src/applogic/mod.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/applogic/mod.rs b/src/applogic/mod.rs index e9d28ba..f342d21 100644 --- a/src/applogic/mod.rs +++ b/src/applogic/mod.rs @@ -22,6 +22,8 @@ pub mod denm; #[cfg(feature = "spat")] pub mod tlc; +#[cfg(feature = "spat")] +const TLC_CACHE_LIFETIME: chrono::Duration = chrono::Duration::minutes(1); #[cfg(feature = "spat")] const PRUNE_INTERVAL: chrono::Duration = chrono::Duration::seconds(5); @@ -143,7 +145,7 @@ impl State { #[cfg(feature = "spat")] last_prune: chrono::NaiveDateTime::default(), #[cfg(feature = "spat")] - tlc_cache: tlc::Data::new(chrono::Duration::seconds(20)), + tlc_cache: tlc::Data::new(TLC_CACHE_LIFETIME), #[cfg(feature = "spat")] glosa_state: GlosaState::default(), } From c6f9aea38c12a453bce10e5db9f74823c917f9bc Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Sun, 28 Jun 2026 00:25:32 +0200 Subject: [PATCH 20/49] screen: Fix feature flags to make tests work again --- Cargo.toml | 8 ++++---- src/applogic/mod.rs | 1 + 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index f0b0806..20830df 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ path = "./src/test.rs" required-features = ["std"] [features] -default = ["esp", "spat"] +default = ["esp", "spat", "gnss", "screen"] # build for ESP (needed to build test for `host-tuple`) esp = [ @@ -40,11 +40,11 @@ cam = ["c-its-parser/cam"] # echo DENM events on serial and BLE denm = ["c-its-parser/denm"] # evaluate SPAT information -spat = ["gnss", "screen", "c-its-parser/spatem", "c-its-parser/mapem"] +spat = ["c-its-parser/spatem", "c-its-parser/mapem"] # enable some debug printing (without enabling debug logging of the ESP RTOS) spat_debug = [] # enable ST7789 based display -screen = ["dep:mipidsi", "dep:embedded-hal-bus", "dep:profont"] +screen = ["dep:mipidsi", "dep:embedded-graphics", "dep:embedded-hal-bus", "dep:profont"] [dependencies] @@ -84,7 +84,7 @@ esp-radio = { version = "0.18.0", optional = true, features = [ embassy_gps = { version = "0.1.0", optional = true, default-features = false, features = ["esp", "log-04"], git = "https://github.com/jbeyerstedt/embassy_gps.git", branch = "feat/rework-gpsfix" } embedded-hal-bus = { version = "0.3.0", optional = true } -embedded-graphics = "0.8.2" +embedded-graphics = { version = "0.8.2", optional = true } c-its-parser = { version = "2.2.2", default-features = false, features = [ "time", "geo", diff --git a/src/applogic/mod.rs b/src/applogic/mod.rs index f342d21..e92f11c 100644 --- a/src/applogic/mod.rs +++ b/src/applogic/mod.rs @@ -559,6 +559,7 @@ mod tests { use super::*; use crate::init_test_env_logger; + #[cfg(feature = "spat")] use crate::testdata::{MAPEM_HH_560, MAPEM_HH_1328}; #[cfg(feature = "spat")] From f0339f62617f53079109e46cff91e09d5e7c8bf9 Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Sun, 28 Jun 2026 00:27:24 +0200 Subject: [PATCH 21/49] applogic: Fix feature flags --- src/applogic/mod.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/applogic/mod.rs b/src/applogic/mod.rs index e92f11c..120864f 100644 --- a/src/applogic/mod.rs +++ b/src/applogic/mod.rs @@ -5,10 +5,11 @@ use alloc::rc::Rc; #[cfg(feature = "spat")] use alloc::vec::Vec; +use log::info; +#[cfg(any(feature = "gnss", feature = "spat"))] +use log::warn; #[cfg(feature = "spat")] use log::{debug, error}; -#[cfg(feature = "gnss")] -use log::{info, warn}; #[cfg(feature = "spat")] use crate::{applogic::v2x::map, geo_alg}; From 6c9cbbd6e6da0e2b5838b644aa6faa2f392eabff Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Sun, 28 Jun 2026 00:35:54 +0200 Subject: [PATCH 22/49] applogic: Fix maneuver ordering --- src/applogic/v2x/map.rs | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/src/applogic/v2x/map.rs b/src/applogic/v2x/map.rs index c8f781f..bac8a69 100644 --- a/src/applogic/v2x/map.rs +++ b/src/applogic/v2x/map.rs @@ -534,9 +534,12 @@ impl Ord for Maneuvers { core::cmp::Ordering::Greater } } - // (only straight) > omni + // (only straight) > omni or left or left+straight (false, true, false) => { - if other.eq(&Maneuvers::new(true, true, true)) { + if other.eq(&Maneuvers::new(true, true, true)) + || other.eq(&Maneuvers::new(true, false, false)) + || other.eq(&Maneuvers::new(true, true, false)) + { core::cmp::Ordering::Greater } else { core::cmp::Ordering::Less @@ -1030,5 +1033,21 @@ mod tests { assert_eq!(straight, sorted[3]); assert_eq!(straight_and_right, sorted[4]); assert_eq!(right, sorted[5]); + + // was a bug at 2026-06-21 + let maneuvers = vec![right, left, straight_and_left, straight]; + + let mut sorted = maneuvers.clone(); + sorted.sort(); + + println!("Sorted maneuvers:"); + for manv in &sorted { + println!("- {manv}"); + } + + assert_eq!(left, sorted[0]); + assert_eq!(straight_and_left, sorted[1]); + assert_eq!(straight, sorted[2]); + assert_eq!(right, sorted[3]); } } From a62d25e651cedca31c453940722292ea243a5e79 Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Thu, 16 Jul 2026 09:44:30 +0200 Subject: [PATCH 23/49] chore(denm): Update c-its-parser and use conversions from there --- Cargo.lock | 4 +- Cargo.toml | 2 +- src/applogic/denm.rs | 143 +------------------------------------------ 3 files changed, 6 insertions(+), 143 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 82c05fa..0b7e161 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -186,9 +186,9 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "c-its-parser" -version = "2.2.2" +version = "2.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee4515d183029336d4c1ccb4b5e66dfaf167407b83c1729a2b53fdded1e68a24" +checksum = "993244dbda1234597ca13fe2e2565fc6f2ece585040404faa011482b55ed544e" dependencies = [ "chrono", "etherparse", diff --git a/Cargo.toml b/Cargo.toml index 20830df..3eddcc6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -85,7 +85,7 @@ esp-radio = { version = "0.18.0", optional = true, features = [ embassy_gps = { version = "0.1.0", optional = true, default-features = false, features = ["esp", "log-04"], git = "https://github.com/jbeyerstedt/embassy_gps.git", branch = "feat/rework-gpsfix" } embedded-hal-bus = { version = "0.3.0", optional = true } embedded-graphics = { version = "0.8.2", optional = true } -c-its-parser = { version = "2.2.2", default-features = false, features = [ +c-its-parser = { version = "2.2.4", default-features = false, features = [ "time", "geo", "libm", diff --git a/src/applogic/denm.rs b/src/applogic/denm.rs index 095720f..9d46dbb 100644 --- a/src/applogic/denm.rs +++ b/src/applogic/denm.rs @@ -1,6 +1,5 @@ //! C-ITS DENM Evaluation -use c_its_parser::standards::cdd_2_2_1::etsi_its_cdd; use c_its_parser::standards::denm_2_2_1::denm_pdu_description; use log::info; @@ -28,154 +27,18 @@ pub fn handle_denm(etsi: &denm_pdu_description::DENM) { // let direction = mgmt.traffic_direction; - let (cc, scc) = cause_code_choice_to_numbers(&sit.event_type.cc_and_scc); + let (cc, scc) = sit.event_type.cc_and_scc.to_u8_tuple(); if let Some(hdg) = heading { info!( "DENN: {station_id}/{seq_num} CC {cc}/{scc} at {lat_deg:.3}°N, {lon_deg:.3}°E, hdg {hdg:.1}° until {end_time:?}" - ) + ); } else { info!( "DENN: {station_id}/{seq_num} CC {cc}/{scc} at {lat_deg:.3}°N, {lon_deg:.3}°E until {end_time:?}" - ) + ); } } else { info!("DENM: One of situation or location container not available"); } } - -fn cause_code_choice_to_numbers(choice: &etsi_its_cdd::CauseCodeChoice) -> (u8, u8) { - match choice { - etsi_its_cdd::CauseCodeChoice::reserved0(scc) => (0, scc.0), - etsi_its_cdd::CauseCodeChoice::trafficCondition1(scc) => (1, scc.0), - etsi_its_cdd::CauseCodeChoice::accident2(scc) => (2, scc.0), - etsi_its_cdd::CauseCodeChoice::roadworks3(scc) => (3, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved4(scc) => (4, scc.0), - etsi_its_cdd::CauseCodeChoice::impassability5(scc) => (5, scc.0), - etsi_its_cdd::CauseCodeChoice::adverseWeatherCondition_Adhesion6(scc) => (6, scc.0), - etsi_its_cdd::CauseCodeChoice::aquaplaning7(scc) => (7, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved8(scc) => (8, scc.0), - etsi_its_cdd::CauseCodeChoice::hazardousLocation_SurfaceCondition9(scc) => (9, scc.0), - etsi_its_cdd::CauseCodeChoice::hazardousLocation_ObstacleOnTheRoad10(scc) => (10, scc.0), - etsi_its_cdd::CauseCodeChoice::hazardousLocation_AnimalOnTheRoad11(scc) => (11, scc.0), - etsi_its_cdd::CauseCodeChoice::humanPresenceOnTheRoad12(scc) => (12, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved13(scc) => (13, scc.0), - etsi_its_cdd::CauseCodeChoice::wrongWayDriving14(scc) => (14, scc.0), - etsi_its_cdd::CauseCodeChoice::rescueAndRecoveryWorkInProgress15(scc) => (15, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved16(scc) => (16, scc.0), - etsi_its_cdd::CauseCodeChoice::adverseWeatherCondition_ExtremeWeatherCondition17(scc) => { - (17, scc.0) - } - etsi_its_cdd::CauseCodeChoice::adverseWeatherCondition_Visibility18(scc) => (18, scc.0), - etsi_its_cdd::CauseCodeChoice::adverseWeatherCondition_Precipitation19(scc) => (19, scc.0), - etsi_its_cdd::CauseCodeChoice::violence20(scc) => (20, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved21(scc) => (21, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved22(scc) => (22, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved23(scc) => (23, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved24(scc) => (24, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved25(scc) => (25, scc.0), - etsi_its_cdd::CauseCodeChoice::slowVehicle26(scc) => (26, scc.0), - etsi_its_cdd::CauseCodeChoice::dangerousEndOfQueue27(scc) => (27, scc.0), - etsi_its_cdd::CauseCodeChoice::publicTransportVehicleApproaching28(scc) => (28, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved29(scc) => (29, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved30(scc) => (30, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved31(scc) => (31, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved32(scc) => (32, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved33(scc) => (33, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved34(scc) => (34, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved35(scc) => (35, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved36(scc) => (36, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved37(scc) => (37, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved38(scc) => (38, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved39(scc) => (39, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved40(scc) => (40, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved41(scc) => (41, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved42(scc) => (42, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved43(scc) => (43, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved44(scc) => (44, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved45(scc) => (45, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved46(scc) => (46, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved47(scc) => (47, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved48(scc) => (48, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved49(scc) => (49, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved50(scc) => (50, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved51(scc) => (51, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved52(scc) => (52, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved53(scc) => (53, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved54(scc) => (54, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved55(scc) => (55, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved56(scc) => (56, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved57(scc) => (57, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved58(scc) => (58, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved59(scc) => (59, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved60(scc) => (60, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved61(scc) => (61, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved62(scc) => (62, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved63(scc) => (63, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved64(scc) => (64, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved65(scc) => (65, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved66(scc) => (66, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved67(scc) => (67, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved68(scc) => (68, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved69(scc) => (69, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved70(scc) => (70, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved71(scc) => (71, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved72(scc) => (72, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved73(scc) => (73, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved74(scc) => (74, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved75(scc) => (75, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved76(scc) => (76, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved77(scc) => (77, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved78(scc) => (78, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved79(scc) => (79, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved80(scc) => (80, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved81(scc) => (81, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved82(scc) => (82, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved83(scc) => (83, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved84(scc) => (84, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved85(scc) => (85, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved86(scc) => (86, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved87(scc) => (87, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved88(scc) => (88, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved89(scc) => (89, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved90(scc) => (90, scc.0), - etsi_its_cdd::CauseCodeChoice::vehicleBreakdown91(scc) => (91, scc.0), - etsi_its_cdd::CauseCodeChoice::postCrash92(scc) => (92, scc.0), - etsi_its_cdd::CauseCodeChoice::humanProblem93(scc) => (93, scc.0), - etsi_its_cdd::CauseCodeChoice::stationaryVehicle94(scc) => (94, scc.0), - etsi_its_cdd::CauseCodeChoice::emergencyVehicleApproaching95(scc) => (95, scc.0), - etsi_its_cdd::CauseCodeChoice::hazardousLocation_DangerousCurve96(scc) => (96, scc.0), - etsi_its_cdd::CauseCodeChoice::collisionRisk97(scc) => (97, scc.0), - etsi_its_cdd::CauseCodeChoice::signalViolation98(scc) => (98, scc.0), - etsi_its_cdd::CauseCodeChoice::dangerousSituation99(scc) => (99, scc.0), - etsi_its_cdd::CauseCodeChoice::railwayLevelCrossing100(scc) => (100, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved101(scc) => (101, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved102(scc) => (102, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved103(scc) => (103, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved104(scc) => (104, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved105(scc) => (105, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved106(scc) => (106, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved107(scc) => (107, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved108(scc) => (108, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved109(scc) => (109, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved110(scc) => (110, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved111(scc) => (111, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved112(scc) => (112, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved113(scc) => (113, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved114(scc) => (114, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved115(scc) => (115, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved116(scc) => (116, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved117(scc) => (117, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved118(scc) => (118, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved119(scc) => (119, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved120(scc) => (120, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved121(scc) => (121, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved122(scc) => (122, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved123(scc) => (123, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved124(scc) => (124, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved125(scc) => (125, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved126(scc) => (126, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved127(scc) => (127, scc.0), - etsi_its_cdd::CauseCodeChoice::reserved128(scc) => (128, scc.0), - } -} From cc31d6226e829c61ce998ba447e9c2081987a87a Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Mon, 22 Jun 2026 22:54:37 +0200 Subject: [PATCH 24/49] spat: ensure that "our" intersection is kept in cache Sometimes we may not receive V2X data for longer than the TLC cache time. This leads to no SPAT updates in the GLOSA algorithm since that uses a cached reference to the intersection instead of querying the tlc cache every time. --- src/applogic/mod.rs | 102 ++++++++++++++++++++++++-------------------- src/applogic/tlc.rs | 11 ++++- 2 files changed, 66 insertions(+), 47 deletions(-) diff --git a/src/applogic/mod.rs b/src/applogic/mod.rs index 120864f..18d2726 100644 --- a/src/applogic/mod.rs +++ b/src/applogic/mod.rs @@ -255,48 +255,57 @@ impl State { info.intersection_id, info.lane_id ); - // determine if intersection is left - let intersection = info.intersection.borrow(); - if Self::is_intersection_left( - &intersection, - info.lane_id, - info.approach_id, - &self.position, - ) { - info!("GLOSA: Intersection was left -> back to idle"); + // ensure that "our" intersection is kept in TLC cache + if self + .tlc_cache + .keep_intersection(info.intersection_id, self.time) + .is_err() + { + info!("GLOSA: Intersection not known any more"); (Some(GlosaState::Idle), None) } else { - // get signal groups which are in our approach - let mut approach_sig_grps = intersection - .signal_groups() - .iter() - .filter(|i| { - info.signal_groups - .iter() - .find(|sg_id| **sg_id == i.id()) - .is_some() - }) - .collect::>(); + // determine if intersection is left + let intersection = info.intersection.borrow(); + if Self::is_intersection_left( + &intersection, + info.lane_id, + info.approach_id, + &self.position, + ) { + info!("GLOSA: Intersection was left -> back to idle"); + (Some(GlosaState::Idle), None) + } else { + // get signal groups which are in our approach + let mut approach_sig_grps = intersection + .signal_groups() + .iter() + .filter(|i| { + info.signal_groups + .iter() + .find(|sg_id| **sg_id == i.id()) + .is_some() + }) + .collect::>(); - // sort signal groups by maneuver - approach_sig_grps.sort_by_key(|v| v.maneuvers()); + // sort signal groups by maneuver + approach_sig_grps.sort_by_key(|v| v.maneuvers()); - // display signal phases - let sig_grps = approach_sig_grps - .iter() - .filter_map(|sig| { - if let Some(manv) = sig.maneuvers() { - use core::ops::Sub; + // display signal phases + let sig_grps = approach_sig_grps + .iter() + .filter_map(|sig| { + if let Some(manv) = sig.maneuvers() { + use core::ops::Sub; - use num_traits::Float; + use num_traits::Float; - let phase = sig - .phase() - .map(core::convert::Into::into) - .unwrap_or_default(); + let phase = sig + .phase() + .map(core::convert::Into::into) + .unwrap_or_default(); - let end_sec = - sig.likely_time() + let end_sec = sig + .likely_time() .or_else(|| sig.min_end_time()) .map(|time| { let dur = @@ -308,18 +317,19 @@ impl State { end_sec }); - Some(SignalGroupData { - phase, - maneuver: manv, - end_sec, - }) - } else { - None - } - }) - .collect::>(); + Some(SignalGroupData { + phase, + maneuver: manv, + end_sec, + }) + } else { + None + } + }) + .collect::>(); - (None, Some(sig_grps)) // keep state, return signal groups + (None, Some(sig_grps)) // keep state, return signal groups + } } } }; diff --git a/src/applogic/tlc.rs b/src/applogic/tlc.rs index 96cbd8a..d8671c8 100644 --- a/src/applogic/tlc.rs +++ b/src/applogic/tlc.rs @@ -10,7 +10,7 @@ use chrono::Datelike; use esp_println::println; use super::v2x::map; -use crate::cache::Cache; +use crate::cache::{Cache, CacheError}; pub struct Data { intersections: Cache, @@ -86,6 +86,15 @@ impl Data { self.intersections.prune(time_now); } + /// Bumps the timestamp of a certain intersection to keep it cached + pub fn keep_intersection( + &mut self, + intersection_id: u16, + time_now: chrono::NaiveDateTime, + ) -> Result<(), CacheError> { + self.intersections.update(time_now, intersection_id, |_| {}) + } + pub fn handle_spatem(&mut self, time_now: chrono::NaiveDateTime, etsi: &SPATEM) { for int in &etsi.spat.intersections.0 { let int_id = int.id.id.0; From c1315f10aa6383f19964d1317bbcb275795e1120 Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Mon, 22 Jun 2026 23:02:09 +0200 Subject: [PATCH 25/49] spat: Process SPATEMs for all intersections in cache Otherwise it can take a while until SPAT data is available after the intersection was "locked". --- src/applogic/mod.rs | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/src/applogic/mod.rs b/src/applogic/mod.rs index 18d2726..b9e6dd1 100644 --- a/src/applogic/mod.rs +++ b/src/applogic/mod.rs @@ -208,18 +208,7 @@ impl State { #[cfg(feature = "spat")] pub fn handle_spatem(&mut self, etsi: &tlc::SPATEM) { - // only update "our" intersection - if let GlosaState::Locked(data) = &self.glosa_state - && etsi - .spat - .intersections - .0 - .iter() - .find(|i| i.id.id.0 == data.intersection_id) - .is_some() - { - self.tlc_cache.handle_spatem(self.time, etsi); - } + self.tlc_cache.handle_spatem(self.time, etsi); } #[cfg(feature = "spat")] From fc0c9b61da608ccccbdf0bdb791ac9108b31b3eb Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Mon, 22 Jun 2026 22:59:47 +0200 Subject: [PATCH 26/49] screen, spat: Add more information and update indicator --- src/applogic/mod.rs | 91 +++++++-- src/main.rs | 49 ++--- src/screen.rs | 448 +++++++++++++++++++++++++++++--------------- 3 files changed, 408 insertions(+), 180 deletions(-) diff --git a/src/applogic/mod.rs b/src/applogic/mod.rs index b9e6dd1..ed8b546 100644 --- a/src/applogic/mod.rs +++ b/src/applogic/mod.rs @@ -65,7 +65,46 @@ pub struct State { } #[cfg(feature = "spat")] -#[derive(Debug, Default)] +#[derive(Debug)] +pub enum GlosaOutput { + Error(alloc::string::String), + Searching(GlosaSearching), + Found(GlosaFound), + Locked(GlosaSignalInfo), +} + +#[cfg(feature = "spat")] +#[derive(Debug, PartialEq, Eq)] +pub struct GlosaSearching { + pub known_intersection_ids: Vec, +} + +#[cfg(feature = "spat")] +#[derive(Debug, PartialEq, Eq)] +pub struct GlosaFound { + pub intersection_id: u16, + pub approach_id: Option, +} + +#[cfg(feature = "spat")] +impl From<&TlData> for GlosaFound { + fn from(value: &TlData) -> Self { + Self { + intersection_id: value.intersection_id, + approach_id: value.approach_id, + } + } +} + +#[cfg(feature = "spat")] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GlosaSignalInfo { + pub intersection_id: u16, + pub signal_groups: Vec, +} + +#[cfg(feature = "spat")] +#[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct SignalGroupData { pub phase: SignalPhase, pub maneuver: map::Maneuvers, @@ -73,7 +112,7 @@ pub struct SignalGroupData { } #[cfg(feature = "spat")] -#[derive(Debug, Default)] +#[derive(Debug, Default, Clone, PartialEq, Eq)] pub enum SignalPhase { #[default] Unknown, @@ -212,17 +251,20 @@ impl State { } #[cfg(feature = "spat")] - pub fn run_glosa(&mut self) -> Vec { + pub fn run_glosa(&mut self) -> GlosaOutput { let Some(own_heading) = self.heading_deg else { - return alloc::vec![]; + use alloc::string::ToString; + + return GlosaOutput::Error("Heading unknown".to_string()); }; - let (state_update, sig_grps) = match &self.glosa_state { + let known_intersection_ids = self.tlc_cache.known_intersections(); + let (state_update, output_data) = match &self.glosa_state { // search for upcoming intersection GlosaState::Idle => { debug!( "GLOSA: Searching with {} known intersections", - self.tlc_cache.known_intersections().len() + known_intersection_ids.len() ); if let Some(search_result) = @@ -233,9 +275,18 @@ impl State { search_result.signal_groups ); - (Some(GlosaState::Locked(search_result)), None) + let glosa_found_data = (&search_result).into(); + ( + Some(GlosaState::Locked(search_result)), + GlosaOutput::Found(glosa_found_data), + ) } else { - (None, None) + ( + None, + GlosaOutput::Searching(GlosaSearching { + known_intersection_ids, + }), + ) } } GlosaState::Locked(info) => { @@ -251,7 +302,12 @@ impl State { .is_err() { info!("GLOSA: Intersection not known any more"); - (Some(GlosaState::Idle), None) + ( + Some(GlosaState::Idle), + GlosaOutput::Searching(GlosaSearching { + known_intersection_ids, + }), + ) } else { // determine if intersection is left let intersection = info.intersection.borrow(); @@ -262,7 +318,12 @@ impl State { &self.position, ) { info!("GLOSA: Intersection was left -> back to idle"); - (Some(GlosaState::Idle), None) + ( + Some(GlosaState::Idle), + GlosaOutput::Searching(GlosaSearching { + known_intersection_ids, + }), + ) } else { // get signal groups which are in our approach let mut approach_sig_grps = intersection @@ -317,7 +378,13 @@ impl State { }) .collect::>(); - (None, Some(sig_grps)) // keep state, return signal groups + ( + None, + GlosaOutput::Locked(GlosaSignalInfo { + intersection_id: intersection.id(), + signal_groups: sig_grps, + }), + ) // keep state, return signal groups } } } @@ -328,7 +395,7 @@ impl State { self.glosa_state = new; } - sig_grps.unwrap_or_default() + output_data } #[cfg(feature = "spat")] diff --git a/src/main.rs b/src/main.rs index 76afcb4..db182b4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -36,6 +36,10 @@ mod radio; mod screen; const WIFI_CHANNEL: radio::Channel = 180; +#[cfg(feature = "screen")] +pub const DISPLAY_WIDTH: u16 = 320; +#[cfg(feature = "screen")] +pub const DISPLAY_HEIGHT: u16 = 172; // This creates a default app-descriptor required by the esp-idf bootloader. // For more information see: @@ -84,13 +88,13 @@ async fn main(spawner: Spawner) -> ! { // SCL: D8/ GPIO8 (SPI_SCK) // SDA: D10/ GPIO10 (SPI_MOSI) // RES: D3/ GPIO7 - // DC: D4/ GPIO23 - // CS: D5/ GPIO24 - // BL: TODO (hard-wire to high? or D1/ GPIO0) + // DC: D4/ GPIO23 (display clear) + // CS: D5/ GPIO24 (chip select) + // BL: D1/ GPIO0 (backlight) #[cfg(feature = "screen")] let mut spi_buffer = [0_u8; 512]; #[cfg(feature = "screen")] - let mut display = { + let mut screen = { let (spi, cs) = setup_st7789_spi( peripherals.GPIO8, peripherals.GPIO10, @@ -109,20 +113,24 @@ async fn main(spawner: Spawner) -> ! { let spi_device = embedded_hal_bus::spi::ExclusiveDevice::new_no_delay(spi, cs) .expect("Failed to initialize SPI device"); - mipidsi::Builder::new( + let display = mipidsi::Builder::new( mipidsi::models::ST7789, mipidsi::interface::SpiInterface::new(spi_device, dc_output, &mut spi_buffer), ) - .display_size(screen::HEIGHT + 34, screen::WIDTH) // somehow this display has 34 px of invisible space on the left (in native orientation) + .display_size(DISPLAY_HEIGHT + 34, DISPLAY_WIDTH) // somehow this display has 34 px of invisible space on the left (in native orientation) .reset_pin(rst_output) .invert_colors(mipidsi::options::ColorInversion::Inverted) .orientation(mipidsi::options::Orientation::new().rotate(mipidsi::options::Rotation::Deg90)) .init(&mut display_delay) - .expect("Failed to initialize display") + .expect("Failed to initialize display"); + + screen::Handler::new(display, DISPLAY_WIDTH, DISPLAY_HEIGHT) }; #[cfg(feature = "screen")] - let _ = screen::draw_slash_screen(&mut display).inspect_err(log_screen_error); + let _ = screen + .update(screen::ScreenState::Initial) + .inspect_err(log_screen_error); // Setup WiFi let (_wifi_controller, interfaces) = esp_radio::wifi::new( @@ -171,13 +179,7 @@ async fn main(spawner: Spawner) -> ! { let gnss_update_ref = GNSS_UPDATE.borrow(cs); if let Some(fix) = gnss_update_ref.replace(None) { - let prev_state = state.initialized(); state.update_with_gpsfix(&fix); - - if prev_state != state.initialized() { - let _ = screen::clear(&mut display).inspect_err(log_screen_error); - } - new_position = true; } }); @@ -188,18 +190,21 @@ async fn main(spawner: Spawner) -> ! { // run GLOSA algorithm #[cfg(feature = "spat")] { - let signal_groups = state.run_glosa(); + let glosa_data = state.run_glosa(); - for sig in &signal_groups { - let duration_str = sig - .end_sec - .map(|v| alloc::format!("for {v} s")) - .unwrap_or_default(); - println!("GLOSA: {} {} {duration_str}", sig.maneuver, sig.phase); + if let applogic::GlosaOutput::Locked(data) = &glosa_data { + for sig in &data.signal_groups { + let duration_str = sig + .end_sec + .map(|v| alloc::format!("for {v} s")) + .unwrap_or_default(); + println!("GLOSA: {} {} {duration_str}", sig.maneuver, sig.phase); + } } #[cfg(feature = "screen")] - let _ = screen::draw_glosa(&mut display, &signal_groups) + let _ = screen + .update(glosa_data.into()) .inspect_err(log_screen_error); } } diff --git a/src/screen.rs b/src/screen.rs index 28c1eb8..d72d0f3 100644 --- a/src/screen.rs +++ b/src/screen.rs @@ -5,183 +5,339 @@ use embedded_graphics::{geometry, mono_font, pixelcolor, primitives, text}; use crate::applogic; -pub const WIDTH: u16 = 320; -pub const HEIGHT: u16 = 172; - -#[allow(unused)] -pub fn clear(target: &mut D) -> Result<(), D::Error> +pub struct Handler where D: DrawTarget, { - let style = primitives::PrimitiveStyleBuilder::new() - .fill_color(pixelcolor::Rgb565::BLACK) - .build(); - primitives::Rectangle::new( - geometry::Point::new(0, 0), - geometry::Size::new(WIDTH.into(), HEIGHT.into()), - ) - .into_styled(style) - .draw(target)?; + target: D, + width: u16, + height: u16, - Ok(()) + // internal state + prev_state: ScreenState, + hb_state: bool, } -#[allow(unused)] -pub fn test_img(target: &mut D) -> Result<(), D::Error> -where - D: DrawTarget, - C: RgbColor, -{ - mipidsi::TestImage::new().draw(target)?; - - Ok(()) +#[derive(Debug, Default, PartialEq, Eq)] +pub enum ScreenState { + #[default] + Uninitialized, + /// aka. "no GNSS fix" or "splash screen" + Initial, + Error(alloc::string::String), + #[cfg(feature = "spat")] + GlosaSearching(applogic::GlosaSearching), + #[cfg(feature = "spat")] + GlosaFound(applogic::GlosaFound), + #[cfg(feature = "spat")] + GlosaLocked(applogic::GlosaSignalInfo), } -#[allow(unused)] -pub fn draw_glosa(target: &mut D, data: &[applogic::SignalGroupData]) -> Result<(), D::Error> +#[cfg(feature = "spat")] +impl From for ScreenState { + fn from(value: applogic::GlosaOutput) -> Self { + match value { + applogic::GlosaOutput::Searching(glosa_searching) => { + Self::GlosaSearching(glosa_searching) + } + applogic::GlosaOutput::Found(glosa_found) => Self::GlosaFound(glosa_found), + applogic::GlosaOutput::Locked(glosa_signal_info) => { + Self::GlosaLocked(glosa_signal_info) + } + applogic::GlosaOutput::Error(err_str) => Self::Error(err_str), + } + } +} + +impl Handler where D: DrawTarget, { - let num_signals = data.len(); + const COLOR_BG: pixelcolor::Rgb565 = pixelcolor::Rgb565::BLACK; + const COLOR_FG: pixelcolor::Rgb565 = pixelcolor::Rgb565::WHITE; - let text_style = - mono_font::MonoTextStyle::new(&profont::PROFONT_24_POINT, pixelcolor::Rgb565::WHITE); - let blank_style = primitives::PrimitiveStyleBuilder::new() - .fill_color(pixelcolor::Rgb565::CSS_GRAY) - .build(); - let rd_style = primitives::PrimitiveStyleBuilder::new() - .fill_color(pixelcolor::Rgb565::CSS_RED) - .build(); - let ye_style = primitives::PrimitiveStyleBuilder::new() - .fill_color(pixelcolor::Rgb565::CSS_YELLOW) // TODO: select nicer color - .build(); - let gn_style = primitives::PrimitiveStyleBuilder::new() - .fill_color(pixelcolor::Rgb565::CSS_GREEN) - .build(); + const BK_STYLE: primitives::PrimitiveStyle = + primitives::PrimitiveStyleBuilder::new() + .fill_color(Self::COLOR_BG) + .build(); + const RD_STYLE: primitives::PrimitiveStyle = + primitives::PrimitiveStyleBuilder::new() + .fill_color(pixelcolor::Rgb565::CSS_RED) + .build(); + const YE_STYLE: primitives::PrimitiveStyle = + primitives::PrimitiveStyleBuilder::new() + .fill_color(pixelcolor::Rgb565::new(31, 55, 0)) + .build(); + const GN_STYLE: primitives::PrimitiveStyle = + primitives::PrimitiveStyleBuilder::new() + .fill_color(pixelcolor::Rgb565::new(10, 50, 0)) + .build(); + const BLANK_SIGNAL_STYLE: primitives::PrimitiveStyle = + primitives::PrimitiveStyleBuilder::new() + .fill_color(pixelcolor::Rgb565::CSS_GRAY) + .build(); - // clear maneuver and timing area - let style = primitives::PrimitiveStyleBuilder::new() - .fill_color(pixelcolor::Rgb565::BLACK) - .build(); - primitives::Rectangle::new( - geometry::Point::new(0, 105), - geometry::Size::new(WIDTH.into(), 67), - ) - .into_styled(style) - .draw(target)?; + const DEFAULT_TEXT_STYLE: mono_font::MonoTextStyle<'static, pixelcolor::Rgb565> = + mono_font::MonoTextStyle::new(&profont::PROFONT_24_POINT, Self::COLOR_FG); + const SMALL_TEXT_STYLE: mono_font::MonoTextStyle<'static, pixelcolor::Rgb565> = + mono_font::MonoTextStyle::new(&profont::PROFONT_14_POINT, Self::COLOR_FG); - if num_signals == 0 { - text::Text::with_alignment( - "No upcoming SGs", - geometry::Point::new(i32::from(WIDTH) / 2, 130), - text_style, - text::Alignment::Center, - ) - .draw(target)?; - - return Ok(()); + pub fn new(target: D, width: u16, height: u16) -> Self { + Self { + target, + width, + height, + prev_state: ScreenState::Uninitialized, + hb_state: false, + } } - #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)] - let sig_width = i32::from(WIDTH) / (num_signals as i32); + pub fn update(&mut self, state: ScreenState) -> Result<(), D::Error> { + if self.prev_state != state { + match &state { + ScreenState::Uninitialized | ScreenState::Initial => self.draw_slash_screen()?, + ScreenState::GlosaSearching(_glosa_searching) => self.draw_glosa_searching()?, + ScreenState::GlosaFound(glosa_found) => self.draw_glosa_found(glosa_found)?, + ScreenState::GlosaLocked(glosa_data) => { + let prev_glosa_data = + if let ScreenState::GlosaLocked(prev_glosa_data) = &self.prev_state { + Some(prev_glosa_data.clone()) + } else { + None + }; + + self.draw_glosa(glosa_data, prev_glosa_data)?; + } + ScreenState::Error(msg) => self.draw_message(msg)?, + } + } + + // Update indicator/ heartbeat + { + let update_ind_dia = 10; + let circle_x_pos = i32::from(self.width) - update_ind_dia - 10; + + text::Text::with_alignment( + "HB", + geometry::Point::new(circle_x_pos - 5, 10), + Self::SMALL_TEXT_STYLE, + text::Alignment::Right, + ) + .draw(&mut self.target)?; + + primitives::Circle::new( + geometry::Point::new(circle_x_pos, 2), + update_ind_dia.cast_unsigned(), + ) + .into_styled(if self.hb_state { + Self::GN_STYLE + } else { + Self::BK_STYLE + }) + .draw(&mut self.target)?; + } + + self.prev_state = state; + self.hb_state = !self.hb_state; + + Ok(()) + } + + fn clear(&mut self) -> Result<(), D::Error> + where + D: DrawTarget, + { + primitives::Rectangle::new( + geometry::Point::new(0, 0), + geometry::Size::new(self.width.into(), self.height.into()), + ) + .into_styled(Self::BK_STYLE) + .draw(&mut self.target)?; + + Ok(()) + } + + #[allow(unused)] + pub fn test_img(&mut self) -> Result<(), D::Error> { + mipidsi::TestImage::new().draw(&mut self.target)?; + + Ok(()) + } + + #[allow(unused)] + pub fn draw_message(&mut self, msg: &str) -> Result<(), D::Error> { + // clear whole display and draw text + self.clear()?; + + text::Text::with_alignment( + msg, + geometry::Point::new(i32::from(self.width) / 2, 40), + Self::DEFAULT_TEXT_STYLE, + text::Alignment::Center, + ) + .draw(&mut self.target)?; + + Ok(()) + } + + #[allow(unused)] + fn draw_glosa_searching(&mut self) -> Result<(), D::Error> { + // clear whole display and draw text + self.clear()?; + + text::Text::with_alignment( + "No upcoming SPAT", + geometry::Point::new(i32::from(self.width) / 2, 130), + Self::DEFAULT_TEXT_STYLE, + text::Alignment::Center, + ) + .draw(&mut self.target)?; + + Ok(()) + } + + #[allow(unused)] + fn draw_glosa_found(&mut self, data: &applogic::GlosaFound) -> Result<(), D::Error> { + // clear whole display and draw text + self.clear()?; + + let text = if let Some(appr) = data.approach_id { + alloc::format!("Int. {}\nApproach {appr}", data.intersection_id) + } else { + alloc::format!("Int. {}\nno approach ID available", data.intersection_id) + }; + + text::Text::with_alignment( + &text, + geometry::Point::new(i32::from(self.width) / 2, 130), + Self::DEFAULT_TEXT_STYLE, + text::Alignment::Center, + ) + .draw(&mut self.target)?; + + Ok(()) + } + + #[allow(unused)] + fn draw_glosa( + &mut self, + data: &applogic::GlosaSignalInfo, + prev_data: Option, + ) -> Result<(), D::Error> { + let num_signals = data.signal_groups.len(); + + if let Some(prev) = prev_data + && prev.signal_groups.len() != num_signals + { + // clear everything if number of signals has changed + self.clear(); + } else { + // only clear maneuver and timing area otherwise + + primitives::Rectangle::new( + geometry::Point::new(0, 105), + geometry::Size::new(self.width.into(), 67), + ) + .into_styled(Self::BK_STYLE) + .draw(&mut self.target)?; + } + + // draw intersection ID + text::Text::new( + &alloc::format!("#{}", data.intersection_id), + geometry::Point::new(15, 10), + Self::SMALL_TEXT_STYLE, + ) + .draw(&mut self.target)?; - for (idx, sig) in data.iter().enumerate() { #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)] - let offset = (idx as i32) * sig_width; + let sig_width = i32::from(self.width) / (num_signals as i32); + for (idx, sig) in data.signal_groups.iter().enumerate() { + #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)] + let offset = (idx as i32) * sig_width; + + let circle_dia = 50; + let circle_margin = (sig_width - circle_dia) / 2; + let centerline = offset + (sig_width / 2); + + // draw phase color + let style = match sig.phase { + applogic::SignalPhase::Unknown => Self::BLANK_SIGNAL_STYLE, + applogic::SignalPhase::Red => Self::RD_STYLE, + applogic::SignalPhase::Green => Self::GN_STYLE, + applogic::SignalPhase::RedYellow | applogic::SignalPhase::Yellow => Self::YE_STYLE, + }; + primitives::Circle::new( + geometry::Point::new(offset + circle_margin, 15), + circle_dia.cast_unsigned(), + ) + .into_styled(style) + .draw(&mut self.target)?; + + // TODO: draw lane type? + + // draw maneuver + text::Text::with_alignment( + &maneuver_to_str(sig.maneuver), + geometry::Point::new(centerline, 130), + Self::DEFAULT_TEXT_STYLE, + text::Alignment::Center, + ) + .draw(&mut self.target)?; + + // draw timing indicator + if let Some(end_sec) = sig.end_sec { + text::Text::with_alignment( + &alloc::format!("{end_sec}s"), + geometry::Point::new(centerline, 160), + Self::DEFAULT_TEXT_STYLE, + text::Alignment::Center, + ) + .draw(&mut self.target)?; + } + } + + Ok(()) + } + + #[allow(unused)] + fn draw_slash_screen(&mut self) -> Result<(), D::Error> { + let text_style = mono_font::MonoTextStyle::new(&profont::PROFONT_18_POINT, Self::COLOR_FG); + + self.clear()?; + + let sig_width = i32::from(self.width) / 3; let circle_dia = 50; let circle_margin = (sig_width - circle_dia) / 2; - let centerline = offset + (sig_width / 2); - // draw phase color - let style = match sig.phase { - applogic::SignalPhase::Unknown => blank_style, - applogic::SignalPhase::Red => rd_style, - applogic::SignalPhase::Green => gn_style, - applogic::SignalPhase::RedYellow | applogic::SignalPhase::Yellow => ye_style, - }; - primitives::Circle::new( - geometry::Point::new(offset + circle_margin, 10), - circle_dia.cast_unsigned(), - ) - .into_styled(style) - .draw(target)?; + for (idx, style) in [Self::RD_STYLE, Self::YE_STYLE, Self::GN_STYLE] + .iter() + .enumerate() + { + #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)] + let offset = (idx as i32) * sig_width; - // TODO: draw lane type? + primitives::Circle::new( + geometry::Point::new(offset + circle_margin, 15), + circle_dia.cast_unsigned(), + ) + .into_styled(*style) + .draw(&mut self.target)?; + } - // draw maneuver + // draw welcome text + let centerline = i32::from(self.width) / 2; text::Text::with_alignment( - &maneuver_to_str(sig.maneuver), + "C-ITS Signal Phase Display\nWaiting for GNSS fix...", geometry::Point::new(centerline, 130), text_style, text::Alignment::Center, ) - .draw(target)?; + .draw(&mut self.target)?; - // draw timing indicator - if let Some(end_sec) = sig.end_sec { - text::Text::with_alignment( - &alloc::format!("{end_sec}s"), - geometry::Point::new(centerline, 160), - text_style, - text::Alignment::Center, - ) - .draw(target)?; - } + Ok(()) } - - Ok(()) -} - -#[allow(unused)] -pub fn draw_slash_screen(target: &mut D) -> Result<(), D::Error> -where - D: DrawTarget, -{ - let text_style = - mono_font::MonoTextStyle::new(&profont::PROFONT_18_POINT, pixelcolor::Rgb565::WHITE); - let blank_style = primitives::PrimitiveStyleBuilder::new() - .fill_color(pixelcolor::Rgb565::CSS_GRAY) - .build(); - let rd_style = primitives::PrimitiveStyleBuilder::new() - .fill_color(pixelcolor::Rgb565::CSS_RED) - .build(); - let ye_style = primitives::PrimitiveStyleBuilder::new() - .fill_color(pixelcolor::Rgb565::CSS_YELLOW) // TODO: select nicer color - .build(); - let gn_style = primitives::PrimitiveStyleBuilder::new() - .fill_color(pixelcolor::Rgb565::CSS_GREEN) - .build(); - - clear(target)?; - - let sig_width = i32::from(WIDTH) / 3; - let circle_dia = 50; - let circle_margin = (sig_width - circle_dia) / 2; - - for (idx, style) in [rd_style, ye_style, gn_style].iter().enumerate() { - #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)] - let offset = (idx as i32) * sig_width; - - primitives::Circle::new( - geometry::Point::new(offset + circle_margin, 10), - circle_dia.cast_unsigned(), - ) - .into_styled(*style) - .draw(target)?; - } - - // draw welcome text - let centerline = i32::from(WIDTH) / 2; - text::Text::with_alignment( - "C-ITS Signal Phase Display\nWaiting for GNSS fix...", - geometry::Point::new(centerline, 130), - text_style, - text::Alignment::Center, - ) - .draw(target)?; - - Ok(()) } fn maneuver_to_str(value: applogic::v2x::map::Maneuvers) -> alloc::string::String { From 044a7357785faa7623b961cfebb3368fba0d8fa6 Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Fri, 3 Jul 2026 11:29:46 +0200 Subject: [PATCH 27/49] radio: Enable TX of non-QoS frames --- .clippy.toml | 2 +- src/main.rs | 8 +- src/radio.rs | 213 ++++++++++++++++++++++++++++++++++++++++++++++++--- src/test.rs | 1 + 4 files changed, 212 insertions(+), 12 deletions(-) diff --git a/.clippy.toml b/.clippy.toml index 98e9866..3e969e0 100644 --- a/.clippy.toml +++ b/.clippy.toml @@ -1,2 +1,2 @@ stack-size-threshold = 1024 -doc-valid-idents = ["WiFi", ".."] +doc-valid-idents = ["WiFi", "GeoNetworking", "QoS", ".."] diff --git a/src/main.rs b/src/main.rs index db182b4..1a5d2ef 100644 --- a/src/main.rs +++ b/src/main.rs @@ -133,11 +133,14 @@ async fn main(spawner: Spawner) -> ! { .inspect_err(log_screen_error); // Setup WiFi - let (_wifi_controller, interfaces) = esp_radio::wifi::new( + let (mut wifi_controller, interfaces) = esp_radio::wifi::new( peripherals.WIFI, esp_radio::wifi::ControllerConfig::default(), ) .expect("Failed to initialize Wi-Fi controller"); + if let Err(err) = wifi_controller.set_band_mode(esp_radio::wifi::BandMode::_5G) { + error!("Failed to set WiFi to 5GHz only: {err}"); + } critical_section::with(|cs| { WIFI_RX_QUEUE.borrow(cs).replace(Some(ArrayQueue::new(2))); @@ -149,6 +152,9 @@ async fn main(spawner: Spawner) -> ! { info!("WiFi promiscuous mode on channel {WIFI_CHANNEL} running"); + let mac = esp_hal::efuse::base_mac_address(); + info!("WiFi MAC: {mac}"); + // Seeed XIAO L67K: // RX: D7/ GPIO12 // TX: D6/ GPIO11 diff --git a/src/radio.rs b/src/radio.rs index 652dbdf..325cbf0 100644 --- a/src/radio.rs +++ b/src/radio.rs @@ -1,14 +1,13 @@ //! IEEE 802.11p C-ITS Capture with ESP32-C5 +#![allow(unused)] + +#[cfg(feature = "esp")] +use esp_radio::wifi::sniffer; // ----------------------------- // missing interface definitions // ----------------------------- -use alloc::format; -use alloc::string::String; - -use esp_radio::wifi::sniffer; - pub type Channel = core::ffi::c_int; unsafe extern "C" { @@ -34,8 +33,9 @@ unsafe extern "C" { /// /// Options: /// - `enable`: Boolean option to enable 802.11p mode - /// - `zero`: Unknown, but need to be zero - unsafe fn phy_11p_set(enable: u8, zero: u8); + /// - `half_rate`: 5 MHz mode + unsafe fn phy_11p_set(enable: u8, half_rate: u8); + } /// Enum values for `wifi_promiscuous_pkt_type_t` used in `esp_radio::wifi::sniffer::PromiscuousPkt::frame_type` @@ -73,15 +73,16 @@ impl PromiscuousPktType { } } +#[cfg(feature = "esp")] /// Configure WiFi Sniffer to a certain 802.11p channel pub fn setup_wifi_sniffer( channel: Channel, sniffer: &mut sniffer::Sniffer, callback: fn(sniffer::PromiscuousPkt<'_>), -) -> Result<(), String> { +) -> Result<(), alloc::string::String> { sniffer .set_promiscuous_mode(true) - .map_err(|err| format!("Failed to enable promiscuous mode: {err:?}"))?; + .map_err(|err| alloc::format!("Failed to enable promiscuous mode: {err:?}"))?; sniffer.set_receive_cb(callback); @@ -92,7 +93,199 @@ pub fn setup_wifi_sniffer( phy_11p_set(1, 0); match phy_change_channel(primary, 1, 0, 0) { 0 => Ok(()), - ret => Err(format!("Failed to change channel: {ret}")), + ret => Err(alloc::format!("Failed to change channel: {ret}")), } } } + +#[cfg(feature = "esp")] +/// Sends 802.11p broadcast frame +/// +/// Expects `data` to contain GN header (BTP header and ITS payload) to be a valid C-ITS/ ITS-G5 packet/ frame. +/// +/// # Errors +/// Fails when MAC is not the right size or returns frame send error +pub fn send_80211_bcast_frame( + sniffer: &mut sniffer::Sniffer, + mac_addr: &[u8], + data: &[u8], +) -> Result<(), alloc::string::String> { + const HEADER_SIZE: usize = 24 + 8; + + let mac = mac_addr + .try_into() + .map_err(|err| alloc::format!("Unexpected MAC address size: {err}"))?; + + // assemble frame + let mut frame_buf = alloc::vec::Vec::with_capacity(HEADER_SIZE + data.len()); + frame_buf.extend_from_slice(&DataFrameHeader::new_80211p(mac).to_binary()); + frame_buf.extend_from_slice(&LlcHeader::new_80211p().to_binary()); + frame_buf.extend_from_slice(data); + + // send + sniffer + .send_raw_frame(true, &frame_buf, true) + .map_err(|err| alloc::format!("Failed to send 802.11p frame: {err:?}")) +} + +/// IEEE 802.11p frame header +#[derive(Debug, Default)] +struct DataFrameHeader { + /// byte 0: subtype in upper nibble, type in lower nibble + /// "normal" data frame: 0x08, QoS data frame: 0x88 + pub type_and_subtype: u8, + /// byte 1: frame contol field, 0x00 for 802.11p + pub fcf_flags: u8, + /// byte 2..3: duration in micros, usually 0x0000 for 802.11p (toDS = 0, fromDS = 0) + pub duration: u16, + /// byte 4..9: receiver/ destination for 802.11p + pub address_1: [u8; 6], + /// byte 10..15: transmitter/ source for 802.11p + pub address_2: [u8; 6], + /// byte 16..21: BSSID for 802.11p + pub address_3: [u8; 6], + /// byte 22..24: upper 12 bits: seq. no, lower 4 bits: fragment no + pub sequence_control: u16, + // Note: address 4 is not used in 802.11p + // pub qos: Option<[u8; 2]>, // QoS frames are supported by radio library + // Note: 802.11p does not use HT mode(s) +} + +impl DataFrameHeader { + const BCAST_MAC: [u8; 6] = [0xFF; 6]; + + /// Create an 802.11p (non-QoS) data frame header with a source MAC address + pub(crate) fn new_80211p(source_mac: &[u8; 6]) -> Self { + Self { + type_and_subtype: 0x08, + address_1: Self::BCAST_MAC, + address_2: *source_mac, + address_3: Self::BCAST_MAC, + ..Default::default() + } + } + + pub(crate) fn to_binary(&self) -> [u8; 24] { + let mut buf = [0u8; 24]; + + buf[0] = self.type_and_subtype; + + buf[1] = self.fcf_flags; + + Self::write_u16(&mut buf, 2, self.duration); + Self::write_mac_addr(&mut buf, 4, &self.address_1); + Self::write_mac_addr(&mut buf, 10, &self.address_2); + Self::write_mac_addr(&mut buf, 16, &self.address_3); + Self::write_u16(&mut buf, 22, self.sequence_control); + + buf + } + + fn write_u16(buf: &mut [u8; 24], offset: usize, data: u16) { + for (idx, byte) in data.to_be_bytes().iter().enumerate() { + buf[idx + offset] = *byte; + } + } + + fn write_mac_addr(buf: &mut [u8; 24], offset: usize, data: &[u8; 6]) { + for (idx, byte) in data.iter().enumerate() { + buf[idx + offset] = *byte; + } + } +} + +/// IEEE 802.11p LLC header +#[derive(Debug, Default)] +struct LlcHeader { + /// byte 0: SNAP (0xaa) in 802.11p + pub dsap: u8, // SNAP in + /// byte 1: SNAP (0xaa) in 802.11p + pub lsap: u8, // SNAP in + /// byte 2: SNAP (0xaa) in 802.11p + pub control_1: u8, // 0x03 + // Note: optional second control byte not used in 802.11p + + // SNAP fields + /// set to 0 since ethertype is used for protocol ID in 802.11p + pub oui: [u8; 3], + /// ethertype GeoNetworking (0x8947) in 802.11p + pub protocol_id: u16, +} + +impl LlcHeader { + pub(crate) fn new_80211p() -> Self { + Self { + dsap: 0xaa, + lsap: 0xaa, + control_1: 0x03, + oui: [0; 3], + protocol_id: 0x8947, + } + } + + pub(crate) fn to_binary(&self) -> [u8; 8] { + let mut buf: [u8; 8] = [ + self.dsap, + self.lsap, + self.control_1, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + ]; + + buf[3] = self.oui[0]; + buf[4] = self.oui[1]; + buf[5] = self.oui[2]; + + buf[6] = self.protocol_id.to_be_bytes()[0]; + buf[7] = self.protocol_id.to_be_bytes()[1]; + + buf + } +} + +#[cfg(test)] +mod tests { + + use super::*; + + #[test] + fn data_frame_test() { + // build a typical IEEE 802.11p frame + const OWN_MAC: [u8; 6] = [0x12, 0x34, 0x56, 0x78, 0xab, 0xcd]; + const REF_HEADER: [u8; 24] = [ + 0x08, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x12, 0x34, 0x56, 0x78, + 0xab, 0xcd, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, + ]; + + let frame = DataFrameHeader::new_80211p(&OWN_MAC); + + assert_eq!(0x08, frame.type_and_subtype); // data frame, subtype 0 + assert_eq!(0x0000, frame.fcf_flags); + assert_eq!(0x0000, frame.duration); + assert_eq!([0xFF; 6], frame.address_1); + assert_eq!(OWN_MAC, frame.address_2); + assert_eq!([0xFF; 6], frame.address_3); + assert_eq!(0x0000, frame.sequence_control); + + assert_eq!(REF_HEADER, frame.to_binary()); + } + + #[test] + fn llc_test() { + // build a typical IEEE 802.11p LLC header + const REF_HEADER: [u8; 8] = [0xaa, 0xaa, 0x03, 0x00, 0x00, 0x00, 0x89, 0x47]; + + let llc = LlcHeader::new_80211p(); + + assert_eq!(0xaa, llc.dsap); + assert_eq!(0xaa, llc.lsap); + assert_eq!(0x03, llc.control_1); + assert_eq!([0x00; 3], llc.oui); + assert_eq!(0x8947, llc.protocol_id); + + assert_eq!(REF_HEADER, llc.to_binary()); + } +} diff --git a/src/test.rs b/src/test.rs index 3eb42dc..5a04150 100644 --- a/src/test.rs +++ b/src/test.rs @@ -6,6 +6,7 @@ extern crate alloc; mod applogic; mod cache; mod geo_alg; +mod radio; mod testdata; fn main() { From 3ae65edcbee57cf2d097d4d18cee5f6914825a16 Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Sat, 20 Jun 2026 22:38:39 +0200 Subject: [PATCH 28/49] cache: Add method to update or initialize items --- src/cache.rs | 41 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/src/cache.rs b/src/cache.rs index 7cdb138..d2855b8 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -13,6 +13,14 @@ where fn key(&self) -> K; } +pub trait Initable +where + T: Cachable, + K: core::cmp::PartialEq, +{ + fn init(key: K) -> T; +} + struct CacheItem where T: Cachable, @@ -105,7 +113,7 @@ where K: core::cmp::PartialEq + core::fmt::Display, { /// Creates a new cache with a certain item lifetime - pub fn new(lifetime: chrono::Duration) -> Self { + pub const fn new(lifetime: chrono::Duration) -> Self { Self { lifetime, items: alloc::vec![], @@ -120,6 +128,13 @@ where .is_some() } + /// Determines the index of a certain key + fn find(&mut self, key: &K) -> Option { + self.items + .iter() + .position(|i| i.data.borrow().key() == *key) + } + /// Retrieves all known keys pub fn keys(&self) -> Vec { self.items.iter().map(|i| i.data.borrow().key()).collect() @@ -194,7 +209,7 @@ where K: core::fmt::Display, { // find item - let Some(pos) = self.items.iter().position(|i| i.data.borrow().key() == key) else { + let Some(pos) = self.find(&key) else { return Err(CacheError::KeyNotFound(key)); }; @@ -209,6 +224,28 @@ where Ok(()) } + /// Updates an item in the cache using the provided function or create a new item with default values + pub fn update_or_init(&mut self, time_now: chrono::NaiveDateTime, key: K, mut func: F) + where + F: FnMut(&mut T), + K: core::fmt::Display, + T: Initable, + { + if let Some(pos) = self.find(&key) { + // update existsting item + // unwrap is fine since we fetched the position before + let item = self.items.get_mut(pos).unwrap(); + + let mut data = item.data.borrow_mut(); + func(&mut data); + + item.timestamp = time_now; + } else { + // insert new item + self.items.push(CacheItem::new(time_now, T::init(key))); + } + } + /// Cleans items which timed out from the cache /// /// Call this regularly to prune old items. From d7eeb67538b9a307cc7a9a4b50bab6c98e651c9d Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Mon, 20 Jul 2026 11:36:05 +0200 Subject: [PATCH 29/49] screen: Move setup to module --- src/main.rs | 79 ++++++--------------------------- src/screen.rs | 120 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+), 65 deletions(-) diff --git a/src/main.rs b/src/main.rs index 1a5d2ef..76557b1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,8 +18,6 @@ use embassy_gps::gps::l76k; use esp_backtrace as _; use esp_hal::clock::CpuClock; use esp_hal::timer::timg::TimerGroup; -#[cfg(feature = "screen")] -use esp_hal::{delay, gpio, spi, time}; #[cfg(all(target_arch = "riscv32", feature = "spat"))] use esp_println::println; use esp_radio::wifi; @@ -36,10 +34,6 @@ mod radio; mod screen; const WIFI_CHANNEL: radio::Channel = 180; -#[cfg(feature = "screen")] -pub const DISPLAY_WIDTH: u16 = 320; -#[cfg(feature = "screen")] -pub const DISPLAY_HEIGHT: u16 = 172; // This creates a default app-descriptor required by the esp-idf bootloader. // For more information see: @@ -84,7 +78,7 @@ async fn main(spawner: Spawner) -> ! { info!("Embassy initialized!"); - // ST7789 172*320px screen + // Setup ST7789 screen // SCL: D8/ GPIO8 (SPI_SCK) // SDA: D10/ GPIO10 (SPI_MOSI) // RES: D3/ GPIO7 @@ -95,43 +89,28 @@ async fn main(spawner: Spawner) -> ! { let mut spi_buffer = [0_u8; 512]; #[cfg(feature = "screen")] let mut screen = { - let (spi, cs) = setup_st7789_spi( + // 172*320px in landscape orientation + let (display, width, height) = screen::make_small_display( peripherals.GPIO8, peripherals.GPIO10, + peripherals.GPIO7, + peripherals.GPIO23, peripherals.GPIO24, - peripherals.GPIO9, + peripherals.GPIO0, peripherals.SPI2, + &mut spi_buffer, ) - .expect("Failed to build SPI config"); - let output_config = gpio::OutputConfig::default(); - let dc_output = gpio::Output::new(peripherals.GPIO23, gpio::Level::Low, output_config); - let rst_output = gpio::Output::new(peripherals.GPIO7, gpio::Level::High, output_config); - let _ = gpio::Output::new(peripherals.GPIO0, gpio::Level::High, output_config); // backlight + .expect("Fatal error building screen"); - let mut display_delay = delay::Delay::new(); + let mut screen = screen::Handler::new(display, width, height); - let spi_device = embedded_hal_bus::spi::ExclusiveDevice::new_no_delay(spi, cs) - .expect("Failed to initialize SPI device"); + let _ = screen + .update(screen::ScreenState::Initial) + .inspect_err(log_screen_error); - let display = mipidsi::Builder::new( - mipidsi::models::ST7789, - mipidsi::interface::SpiInterface::new(spi_device, dc_output, &mut spi_buffer), - ) - .display_size(DISPLAY_HEIGHT + 34, DISPLAY_WIDTH) // somehow this display has 34 px of invisible space on the left (in native orientation) - .reset_pin(rst_output) - .invert_colors(mipidsi::options::ColorInversion::Inverted) - .orientation(mipidsi::options::Orientation::new().rotate(mipidsi::options::Rotation::Deg90)) - .init(&mut display_delay) - .expect("Failed to initialize display"); - - screen::Handler::new(display, DISPLAY_WIDTH, DISPLAY_HEIGHT) + screen }; - #[cfg(feature = "screen")] - let _ = screen - .update(screen::ScreenState::Initial) - .inspect_err(log_screen_error); - // Setup WiFi let (mut wifi_controller, interfaces) = esp_radio::wifi::new( peripherals.WIFI, @@ -155,7 +134,7 @@ async fn main(spawner: Spawner) -> ! { let mac = esp_hal::efuse::base_mac_address(); info!("WiFi MAC: {mac}"); - // Seeed XIAO L67K: + // Setup GNSS: Seeed XIAO L67K: // RX: D7/ GPIO12 // TX: D6/ GPIO11 // WAKEUP: D0/ GPIO1 -> HIGH for active, LOW for Sleep @@ -376,36 +355,6 @@ async fn gnss_task( } } -/// Builds the SPI interface for the ST7789 display -/// -/// Returns (spi master, chip-select) tuple or SPI config error -#[cfg(feature = "screen")] -fn setup_st7789_spi( - scl: esp_hal::peripherals::GPIO8<'static>, - sda: esp_hal::peripherals::GPIO10<'static>, - cs: esp_hal::peripherals::GPIO24<'static>, - miso: esp_hal::peripherals::GPIO9<'static>, - spi: esp_hal::peripherals::SPI2<'static>, -) -> Result< - ( - spi::master::Spi<'static, esp_hal::Blocking>, - gpio::Output<'static>, - ), - spi::master::ConfigError, -> { - let spi_config = spi::master::Config::default() - .with_frequency(time::Rate::from_mhz(40)) - .with_mode(spi::Mode::_0); - let spi: spi::master::Spi<'_, esp_hal::Blocking> = spi::master::Spi::new(spi, spi_config)? - .with_sck(scl) - .with_mosi(sda) - .with_miso(miso); - - let cs_output = gpio::Output::new(cs, gpio::Level::High, gpio::OutputConfig::default()); - - Ok((spi, cs_output)) -} - #[cfg(feature = "screen")] fn log_screen_error(error: &E) where diff --git a/src/screen.rs b/src/screen.rs index d72d0f3..b2ddf90 100644 --- a/src/screen.rs +++ b/src/screen.rs @@ -348,3 +348,123 @@ fn maneuver_to_str(value: applogic::v2x::map::Maneuvers) -> alloc::string::Strin if value.right_allowed { ">" } else { " " }, ) } + +/// Creates an [`mipidsi::Display`], width, height tuple for an ST7789 172*320px screen in landscape orientation +/// +/// # Errors +/// Human-readable fatal errors +#[allow(clippy::too_many_arguments, clippy::type_complexity)] +pub fn make_small_display( + scl: IoScl, + sda: IoSda, + res: IoRes, + dc: IoDc, + cs: IoCs, + bl: IoBl, + spi: Spi, + spi_buffer: &mut [u8], +) -> Result< + ( + mipidsi::Display< + mipidsi::interface::SpiInterface< + '_, + embedded_hal_bus::spi::ExclusiveDevice< + esp_hal::spi::master::Spi<'_, esp_hal::Blocking>, + esp_hal::gpio::Output<'_>, + embedded_hal_bus::spi::NoDelay, + >, + esp_hal::gpio::Output<'_>, + >, + mipidsi::models::ST7789, + esp_hal::gpio::Output<'_>, + >, + u16, + u16, + ), + alloc::string::String, +> +where + IoScl: esp_hal::gpio::interconnect::PeripheralOutput<'static>, + IoSda: esp_hal::gpio::interconnect::PeripheralOutput<'static>, + IoRes: + esp_hal::gpio::interconnect::PeripheralOutput<'static> + esp_hal::gpio::OutputPin + 'static, + IoDc: + esp_hal::gpio::interconnect::PeripheralOutput<'static> + esp_hal::gpio::OutputPin + 'static, + IoCs: + esp_hal::gpio::interconnect::PeripheralOutput<'static> + esp_hal::gpio::OutputPin + 'static, + IoBl: + esp_hal::gpio::interconnect::PeripheralOutput<'static> + esp_hal::gpio::OutputPin + 'static, + Spi: esp_hal::spi::master::Instance + 'static, +{ + const DISPLAY_WIDTH: u16 = 320; + const DISPLAY_HEIGHT: u16 = 172; + const DISPLAY_HIDDEN_X: u16 = 34; // somehow this display has 34 px of invisible space on the left (in native orientation) + + let (spi, cs) = setup_st7789_spi(scl, sda, cs, spi) + .map_err(|err| alloc::format!("Failed to build SPI config: {err}"))?; + + let output_config = esp_hal::gpio::OutputConfig::default(); + let dc_output = esp_hal::gpio::Output::new(dc, esp_hal::gpio::Level::Low, output_config); + let rst_output = esp_hal::gpio::Output::new(res, esp_hal::gpio::Level::High, output_config); + let _ = esp_hal::gpio::Output::new(bl, esp_hal::gpio::Level::High, output_config); // enable backlight + + let mut display_delay = esp_hal::delay::Delay::new(); + + let spi_device = embedded_hal_bus::spi::ExclusiveDevice::new_no_delay(spi, cs) + .map_err(|err| alloc::format!("Failed to initialize SPI device: {err}"))?; + + let display = mipidsi::Builder::new( + mipidsi::models::ST7789, + mipidsi::interface::SpiInterface::new(spi_device, dc_output, spi_buffer), + ) + .display_size(DISPLAY_HEIGHT + DISPLAY_HIDDEN_X, DISPLAY_WIDTH) + .reset_pin(rst_output) + .invert_colors(mipidsi::options::ColorInversion::Inverted) + .orientation(mipidsi::options::Orientation::new().rotate(mipidsi::options::Rotation::Deg90)) + .init(&mut display_delay) + .map_err(|err| alloc::format!("Failed to initialize display: {err:?}"))?; + + Ok((display, DISPLAY_WIDTH, DISPLAY_HEIGHT)) +} + +/// Builds the SPI interface for the ST7789 display +/// +/// Returns (spi master, chip-select) tuple +/// +/// # Errors +/// Returns SPI config error when SPI setup failed +fn setup_st7789_spi( + scl: IoScl, + sda: IoSda, + cs: IoCs, + spi: Spi, +) -> Result< + ( + esp_hal::spi::master::Spi<'static, esp_hal::Blocking>, + esp_hal::gpio::Output<'static>, + ), + esp_hal::spi::master::ConfigError, +> +where + IoScl: esp_hal::gpio::interconnect::PeripheralOutput<'static>, + IoSda: esp_hal::gpio::interconnect::PeripheralOutput<'static>, + IoCs: + esp_hal::gpio::interconnect::PeripheralOutput<'static> + esp_hal::gpio::OutputPin + 'static, + Spi: esp_hal::spi::master::Instance + 'static, +{ + let spi_config = esp_hal::spi::master::Config::default() + .with_frequency(esp_hal::time::Rate::from_mhz(40)) + .with_mode(esp_hal::spi::Mode::_0); + let spi: esp_hal::spi::master::Spi<'_, esp_hal::Blocking> = + esp_hal::spi::master::Spi::new(spi, spi_config)? + .with_sck(scl) + .with_mosi(sda); + + let cs_output = esp_hal::gpio::Output::new( + cs, + esp_hal::gpio::Level::High, + esp_hal::gpio::OutputConfig::default(), + ); + + Ok((spi, cs_output)) +} From 055b03850b1006c96b2910e0f9e0ca403de68647 Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Mon, 20 Jul 2026 11:48:15 +0200 Subject: [PATCH 30/49] wifi: Move setup to radio module --- src/main.rs | 11 ++++------- src/radio.rs | 9 +++------ 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/src/main.rs b/src/main.rs index 76557b1..77dfeaf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -112,7 +112,8 @@ async fn main(spawner: Spawner) -> ! { }; // Setup WiFi - let (mut wifi_controller, interfaces) = esp_radio::wifi::new( + // sadly, it doesn't work any more when we move `esp_radio::wifi::new` to `setup_wifi_sniffer` + let (mut wifi_controller, mut interfaces) = esp_radio::wifi::new( peripherals.WIFI, esp_radio::wifi::ControllerConfig::default(), ) @@ -124,9 +125,7 @@ async fn main(spawner: Spawner) -> ! { critical_section::with(|cs| { WIFI_RX_QUEUE.borrow(cs).replace(Some(ArrayQueue::new(2))); }); - - let mut sniffer = interfaces.sniffer; - radio::setup_wifi_sniffer(WIFI_CHANNEL, &mut sniffer, handle_frame) + radio::setup_wifi_sniffer(WIFI_CHANNEL, &mut interfaces.sniffer, handle_frame) .expect("Fatal error initializing 802.11p sniffer"); info!("WiFi promiscuous mode on channel {WIFI_CHANNEL} running"); @@ -276,9 +275,7 @@ fn handle_frame(frame: wifi::sniffer::PromiscuousPkt<'_>) { warn!("Received frame has RX error: {}", frame.rx_cntl.rx_state); return; } - if !radio::PromiscuousPktType::Data.matches(frame.frame_type) { - debug!("Received frame is not a DATA frame: {}", frame.frame_type); return; } @@ -296,7 +293,7 @@ fn handle_frame(frame: wifi::sniffer::PromiscuousPkt<'_>) { return; } - debug!("Received frame with {} bytes", frame.len); + // info!("Received frame with {} bytes", frame.len); // Send data to main thread critical_section::with(|cs| { diff --git a/src/radio.rs b/src/radio.rs index 325cbf0..46cd72a 100644 --- a/src/radio.rs +++ b/src/radio.rs @@ -1,9 +1,6 @@ //! IEEE 802.11p C-ITS Capture with ESP32-C5 #![allow(unused)] -#[cfg(feature = "esp")] -use esp_radio::wifi::sniffer; - // ----------------------------- // missing interface definitions // ----------------------------- @@ -77,8 +74,8 @@ impl PromiscuousPktType { /// Configure WiFi Sniffer to a certain 802.11p channel pub fn setup_wifi_sniffer( channel: Channel, - sniffer: &mut sniffer::Sniffer, - callback: fn(sniffer::PromiscuousPkt<'_>), + sniffer: &mut esp_radio::wifi::sniffer::Sniffer, + callback: fn(esp_radio::wifi::sniffer::PromiscuousPkt<'_>), ) -> Result<(), alloc::string::String> { sniffer .set_promiscuous_mode(true) @@ -106,7 +103,7 @@ pub fn setup_wifi_sniffer( /// # Errors /// Fails when MAC is not the right size or returns frame send error pub fn send_80211_bcast_frame( - sniffer: &mut sniffer::Sniffer, + sniffer: &mut esp_radio::wifi::sniffer::Sniffer, mac_addr: &[u8], data: &[u8], ) -> Result<(), alloc::string::String> { From f32887df619fb311b546192b3e876c23867fd35c Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Mon, 20 Jul 2026 22:05:01 +0200 Subject: [PATCH 31/49] applogic: Move to generic position state interface --- src/applogic/mod.rs | 84 +++++++++++++++++++++++++++------------------ src/main.rs | 13 +++++-- 2 files changed, 61 insertions(+), 36 deletions(-) diff --git a/src/applogic/mod.rs b/src/applogic/mod.rs index ed8b546..bf3252a 100644 --- a/src/applogic/mod.rs +++ b/src/applogic/mod.rs @@ -47,11 +47,7 @@ pub struct State { time: chrono::NaiveDateTime, #[allow(unused)] - position: geo_types::Point, - #[allow(unused)] - heading_deg: Option, - #[allow(unused)] - speed_mps: Option, + pos: PositionState, #[cfg(feature = "spat")] last_prune: chrono::NaiveDateTime, @@ -122,6 +118,41 @@ pub enum SignalPhase { Yellow, } +#[allow(unused)] +#[derive(Debug, Default, Clone, PartialEq)] +pub struct GnssFix { + pub time: Option, + pub latitude_deg: f64, + pub longitude_deg: f64, + pub heading_deg: Option, + pub speed_mps: Option, +} + +#[allow(unused)] +#[derive(Debug, Default, Clone, PartialEq)] +pub struct PositionState { + pub position: geo_types::Point, + pub heading_deg: Option, + pub speed_mps: Option, +} + +impl From<&GnssFix> for PositionState { + fn from(value: &GnssFix) -> Self { + let position = geo_types::Point::new(value.longitude_deg, value.latitude_deg); + + Self { + position, + heading_deg: value.heading_deg, + speed_mps: value.speed_mps, + } + } +} +impl From for PositionState { + fn from(value: GnssFix) -> Self { + (&value).into() + } +} + #[cfg(feature = "spat")] impl core::fmt::Display for SignalPhase { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { @@ -179,9 +210,7 @@ impl State { Self { initialized: false, time: chrono::NaiveDateTime::default(), - position: geo_types::Point::default(), - heading_deg: None, - speed_mps: None, + pos: PositionState::default(), #[cfg(feature = "spat")] last_prune: chrono::NaiveDateTime::default(), #[cfg(feature = "spat")] @@ -191,23 +220,11 @@ impl State { } } - #[cfg(all(feature = "esp", feature = "gnss"))] - pub fn update_with_gpsfix(&mut self, fix: &embassy_gps::types::GpsFix) { - self.position = geo_types::Point::new(fix.longitude_deg, fix.latitude_deg); - - if let Some(heading) = fix.true_course_deg { - self.heading_deg = Some(heading); - } else { - warn!("State: Course missing from GpsFix"); - } - - if let Some(speed) = fix.speed_over_ground { - self.speed_mps = Some(speed); - } else { - warn!("State: Speed missing from GpsFix"); - } - - if let Some(time) = fix.get_timestamp() { + #[allow(unused)] + #[cfg(feature = "esp")] + pub fn update_with_gpsfix(&mut self, fix: &GnssFix) { + self.pos = fix.into(); + if let Some(time) = fix.time { self.time = time; if !self.initialized { @@ -217,18 +234,19 @@ impl State { } } - #[cfg(feature = "gnss")] + #[allow(unused)] pub fn print_fix(&self) -> alloc::string::String { alloc::format!( "{:?}, {:.4} N, {:.4} E, {:.0}°, {:.2} m/s", self.time, - self.position.y(), - self.position.x(), - self.heading_deg.unwrap_or_default(), - self.speed_mps.unwrap_or_default(), + self.pos.position.y(), + self.pos.position.x(), + self.pos.heading_deg.unwrap_or_default(), + self.pos.speed_mps.unwrap_or_default(), ) } + #[allow(clippy::unused_self)] pub fn prune(&mut self) { #[cfg(feature = "spat")] if self.last_prune + PRUNE_INTERVAL < self.time { @@ -252,7 +270,7 @@ impl State { #[cfg(feature = "spat")] pub fn run_glosa(&mut self) -> GlosaOutput { - let Some(own_heading) = self.heading_deg else { + let Some(own_heading) = self.pos.heading_deg else { use alloc::string::ToString; return GlosaOutput::Error("Heading unknown".to_string()); @@ -268,7 +286,7 @@ impl State { ); if let Some(search_result) = - Self::find_intersection(&self.position, own_heading, &self.tlc_cache) + Self::find_intersection(&self.pos.position, own_heading, &self.tlc_cache) { info!( "GLOSA: Approach has signal groups: {:?}", @@ -315,7 +333,7 @@ impl State { &intersection, info.lane_id, info.approach_id, - &self.position, + &self.pos.position, ) { info!("GLOSA: Intersection was left -> back to idle"); ( diff --git a/src/main.rs b/src/main.rs index 77dfeaf..35f6602 100644 --- a/src/main.rs +++ b/src/main.rs @@ -40,8 +40,7 @@ const WIFI_CHANNEL: radio::Channel = 180; esp_bootloader_esp_idf::esp_app_desc!(); #[cfg(feature = "gnss")] -static GNSS_UPDATE: Mutex>> = - Mutex::new(RefCell::new(None)); +static GNSS_UPDATE: Mutex>> = Mutex::new(RefCell::new(None)); static WIFI_RX_QUEUE: Mutex>>>> = Mutex::new(RefCell::new(None)); #[allow( @@ -341,9 +340,17 @@ async fn gnss_task( fix_drop_message = !fix_drop_message; if fix_drop_message { // send to main thread + let pos_state = applogic::GnssFix { + time: fix.get_timestamp(), + latitude_deg: fix.latitude_deg, + longitude_deg: fix.longitude_deg, + heading_deg: fix.true_course_deg, + speed_mps: fix.speed_over_ground, + }; + critical_section::with(|cs| { let gnss_update_ref = GNSS_UPDATE.borrow(cs); - gnss_update_ref.replace(Some(fix)); + gnss_update_ref.replace(Some(pos_state)); }); } } else { From e178c740dbc0e4829b06077767e67a9919437bb9 Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Sat, 20 Jun 2026 20:07:48 +0200 Subject: [PATCH 32/49] v2x: drop hopped messages and unsupported message types --- Cargo.lock | 12 +++---- Cargo.toml | 2 +- src/main.rs | 93 +++++++++++++++++++++++++++++++++++++++++++++-------- 3 files changed, 86 insertions(+), 21 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0b7e161..ab58dc3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -131,9 +131,9 @@ checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" [[package]] name = "bitvec" -version = "1.0.1" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" dependencies = [ "funty", "radium", @@ -186,9 +186,9 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "c-its-parser" -version = "2.2.5" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "993244dbda1234597ca13fe2e2565fc6f2ece585040404faa011482b55ed544e" +checksum = "f9bd739e1f76c017fc9fc6c21b9ff2aee4536bef357075d454e387e94a4d6287" dependencies = [ "chrono", "etherparse", @@ -1347,9 +1347,9 @@ dependencies = [ [[package]] name = "geonetworking" -version = "0.2.1" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea4f48e7b1cdf91ad77ec1e4c5617aee3666a929288f4a3f11e473b67d3a6314" +checksum = "6f6d36111f5057d3be7eb4e312a07edac4737fc791e4b0a73d812c6f710161e7" dependencies = [ "bitvec", "bitvec-nom2", diff --git a/Cargo.toml b/Cargo.toml index 3eddcc6..546cb89 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -85,7 +85,7 @@ esp-radio = { version = "0.18.0", optional = true, features = [ embassy_gps = { version = "0.1.0", optional = true, default-features = false, features = ["esp", "log-04"], git = "https://github.com/jbeyerstedt/embassy_gps.git", branch = "feat/rework-gpsfix" } embedded-hal-bus = { version = "0.3.0", optional = true } embedded-graphics = { version = "0.8.2", optional = true } -c-its-parser = { version = "2.2.4", default-features = false, features = [ +c-its-parser = { version = "2.3.0", default-features = false, features = [ "time", "geo", "libm", diff --git a/src/main.rs b/src/main.rs index 35f6602..7f0fb48 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,9 +7,11 @@ )] // #![deny(clippy::large_stack_frames)] +use alloc::string::ToString as _; use alloc::vec::Vec; use core::cell::RefCell; +use c_its_parser::gn as geonetworking; use critical_section::Mutex; use crossbeam_queue::ArrayQueue; use embassy_executor::Spawner; @@ -21,6 +23,7 @@ use esp_hal::timer::timg::TimerGroup; #[cfg(all(target_arch = "riscv32", feature = "spat"))] use esp_println::println; use esp_radio::wifi; +use geonetworking::Decode as _; use log::{debug, error, info, warn}; extern crate alloc; @@ -204,12 +207,12 @@ async fn main(spawner: Spawner) -> ! { } }); if let Some(data) = new_data { - match c_its_parser::de::decode(&data, c_its_parser::Headers::IEEE802LlcGnBtp) { + match c_its_parser::de::decode(&data, c_its_parser::Headers::None) { Ok(msg) => { use c_its_parser::ItsMessage; - use c_its_parser::standards::extensions::ItsMessageId; + // use c_its_parser::standards::extensions::ItsMessageId; - debug!("Got new {:?} message", ItsMessageId::from(&msg)); + // debug!("Got new {:?} message", ItsMessageId::from(&msg)); match msg { #[cfg(feature = "spat")] @@ -253,8 +256,9 @@ async fn main(spawner: Spawner) -> ! { } } Err(err) => { - // will give false-positives when a message is received which wasn't enabled - debug!("Failed to parse V2X message: {err}"); + // we already filtered out all unsupported message types in the wifi rx callback + // so these are actual errors + warn!("Failed to parse V2X message: {err}"); } } } @@ -294,16 +298,77 @@ fn handle_frame(frame: wifi::sniffer::PromiscuousPkt<'_>) { // info!("Received frame with {} bytes", frame.len); - // Send data to main thread - critical_section::with(|cs| { - let queue_rc = WIFI_RX_QUEUE.borrow(cs).borrow(); - // unwrap is fine b/c we stored something in it before - let queue = queue_rc.as_ref().unwrap(); - - if queue.push(frame.data.to_vec()).is_err() { - error!("V2X RX queue is full"); + // parse GN headers + match c_its_parser::pcap::remove_wlan_headers(frame.data) + .map_err(|err| alloc::format!("Failed to parse WLAN headers: {err}")) + .and_then(|data| { + geonetworking::Packet::decode(data) + .map_err(|err| alloc::format!("Failed to parse GN headers: {err:?}")) + }) { + Err(err) => { + warn!("{err}"); } - }); + Ok(packet) => { + // drop hopped (for now) + if packet.decoded.is_hopped() { + return; + } + + // "parse" ITS PDU Header + if let Ok((payload, message_id, _)) = get_its_header(&packet) { + // drop all unsupported message IDs + if match message_id { + #[cfg(feature = "denm")] + c_its_parser::standards::extensions::ItsMessageId::Denm => false, + #[cfg(feature = "cam")] + c_its_parser::standards::extensions::ItsMessageId::Cam => false, + #[cfg(feature = "spat")] + c_its_parser::standards::extensions::ItsMessageId::Spatem + | c_its_parser::standards::extensions::ItsMessageId::Mapem => false, + _ => true, + } { + return; + } + + // Send data to main thread + critical_section::with(|cs| { + let queue_rc = WIFI_RX_QUEUE.borrow(cs).borrow(); + // unwrap is fine b/c we stored something in it before + let queue = queue_rc.as_ref().unwrap(); + + if queue.push(payload.to_vec()).is_err() { + error!("V2X RX queue is full"); + } + }); + } + } + } +} + +fn get_its_header<'p>( + packet: &geonetworking::Decoded>, +) -> Result< + ( + &'p [u8], + c_its_parser::standards::extensions::ItsMessageId, + u32, + ), + alloc::string::String, +> { + let payload = packet.decoded.btp_payload()?; + + if payload.len() < 6 { + return Err("ITS payload too small for ItsPduHeader".to_string()); + } + + if let Ok(message_id) = payload[1].try_into() { + let station_id_buf: [u8; 4] = payload[2..6].try_into().unwrap(); + let station_id = u32::from_be_bytes(station_id_buf); + + Ok((payload, message_id, station_id)) + } else { + Err("Unknown message ID in ItsPduHeader".to_string()) + } } #[cfg(feature = "gnss")] From 58e5d5ec751564d1514c0c1baaa7b33e616dc876 Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Sat, 20 Jun 2026 20:15:11 +0200 Subject: [PATCH 33/49] v2x: Reduce SPAT rate --- src/main.rs | 89 ++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 84 insertions(+), 5 deletions(-) diff --git a/src/main.rs b/src/main.rs index 7f0fb48..79cf7d9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,6 +10,8 @@ use alloc::string::ToString as _; use alloc::vec::Vec; use core::cell::RefCell; +#[cfg(feature = "spat")] +use core::cell::UnsafeCell; use c_its_parser::gn as geonetworking; use critical_section::Mutex; @@ -37,6 +39,8 @@ mod radio; mod screen; const WIFI_CHANNEL: radio::Channel = 180; +#[cfg(feature = "spat")] +const SPAT_RATE_LIMIT: u8 = 5; // keep every n-th message // This creates a default app-descriptor required by the esp-idf bootloader. // For more information see: @@ -212,7 +216,7 @@ async fn main(spawner: Spawner) -> ! { use c_its_parser::ItsMessage; // use c_its_parser::standards::extensions::ItsMessageId; - // debug!("Got new {:?} message", ItsMessageId::from(&msg)); + // info!("Got new {:?} message", ItsMessageId::from(&msg)); match msg { #[cfg(feature = "spat")] @@ -272,7 +276,28 @@ async fn main(spawner: Spawner) -> ! { clippy::needless_pass_by_value, reason = "adhering to callback interface" )] +#[allow(clippy::too_many_lines)] fn handle_frame(frame: wifi::sniffer::PromiscuousPkt<'_>) { + #[cfg(feature = "spat")] + const PRUNE_INTERVAL: chrono::Duration = chrono::Duration::seconds(60); + #[cfg(feature = "spat")] + static mut RATELIMIT_CACHE_LAST_PRUNE: UnsafeCell = + UnsafeCell::new(chrono::NaiveDateTime::MIN); + + #[cfg(feature = "spat")] + static mut SPAT_RATELIMIT_CACHE: UnsafeCell> = UnsafeCell::new( + cache::Cache::::new(chrono::Duration::seconds(20)), + ); + + #[cfg(feature = "spat")] + let now = { + let time = esp_hal::time::Instant::now().duration_since_epoch(); + // unwrap is fine since value range is u64 microseconds + chrono::DateTime::from_timestamp(time.as_secs().cast_signed(), 0) + .unwrap() + .naive_utc() + }; + // Ignore frames with errors and non-data frames if frame.rx_cntl.rx_state != 0 { warn!("Received frame has RX error: {}", frame.rx_cntl.rx_state); @@ -315,16 +340,37 @@ fn handle_frame(frame: wifi::sniffer::PromiscuousPkt<'_>) { } // "parse" ITS PDU Header - if let Ok((payload, message_id, _)) = get_its_header(&packet) { - // drop all unsupported message IDs + if let Ok((payload, message_id, station_id)) = get_its_header(&packet) { + // drop all unsupported message IDs and rate-limit SPATEMs if match message_id { #[cfg(feature = "denm")] c_its_parser::standards::extensions::ItsMessageId::Denm => false, #[cfg(feature = "cam")] c_its_parser::standards::extensions::ItsMessageId::Cam => false, #[cfg(feature = "spat")] - c_its_parser::standards::extensions::ItsMessageId::Spatem - | c_its_parser::standards::extensions::ItsMessageId::Mapem => false, + c_its_parser::standards::extensions::ItsMessageId::Spatem => { + // reduce SPAT rate per station ID + let mut drop_msg = false; + unsafe { + #[allow( + static_mut_refs, + reason = "this function will only run consecutive" + )] + let spat_cache = SPAT_RATELIMIT_CACHE.get(); + + (*spat_cache).update_or_init(now, station_id, |i| { + i.msg_count = i.msg_count.wrapping_add(1); + + if (i.msg_count % SPAT_RATE_LIMIT) > 0 { + drop_msg = true; + } + }); + } + + drop_msg + } + #[cfg(feature = "spat")] + c_its_parser::standards::extensions::ItsMessageId::Mapem => false, _ => true, } { return; @@ -343,6 +389,39 @@ fn handle_frame(frame: wifi::sniffer::PromiscuousPkt<'_>) { } } } + + #[cfg(feature = "spat")] + unsafe { + #[allow(static_mut_refs, reason = "this function will only run consecutive")] + let last_prune_time = RATELIMIT_CACHE_LAST_PRUNE.get(); + if *last_prune_time + PRUNE_INTERVAL < now { + #[allow(static_mut_refs, reason = "this function will only run consecutive")] + let spat_cache = SPAT_RATELIMIT_CACHE.get(); + (*spat_cache).prune(now); + + *last_prune_time = now; + } + } +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +struct RatelimitData { + station_id: u32, + msg_count: u8, +} + +impl cache::Cachable for RatelimitData { + fn key(&self) -> u32 { + self.station_id + } +} +impl cache::Initable for RatelimitData { + fn init(key: u32) -> Self { + Self { + station_id: key, + ..Default::default() + } + } } fn get_its_header<'p>( From 29a5d86e438a9afbcbe76759b7bba8ebec883309 Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Fri, 15 May 2026 15:17:04 +0200 Subject: [PATCH 34/49] docs: Add BLE and UART protocol specification --- Readme.md | 2 +- docs/ble-protocol.md | 53 +++++++++++++++++ docs/c_its-messages.proto | 122 ++++++++++++++++++++++++++++++++++++++ docs/uart-protocol.md | 20 +++++++ 4 files changed, 196 insertions(+), 1 deletion(-) create mode 100644 docs/ble-protocol.md create mode 100644 docs/c_its-messages.proto create mode 100644 docs/uart-protocol.md diff --git a/Readme.md b/Readme.md index f80ac99..9cbf097 100644 --- a/Readme.md +++ b/Readme.md @@ -1,4 +1,4 @@ -# ESP32-C5 C-ITS Visualizer +# ESP32-C5 C-ITS Companion/ Visualizer Displays different C-ITS receive-only use cases for cheap. diff --git a/docs/ble-protocol.md b/docs/ble-protocol.md new file mode 100644 index 0000000..65f68ff --- /dev/null +++ b/docs/ble-protocol.md @@ -0,0 +1,53 @@ +# Bluetooth LE Protocol + +Some data is made available via BLE so that a smartphone app can be used to display it in other use cases. + +## General + +The BLE protocol contains some standardized services, but can also transport custom services. +Each service is compromised of a set of characteristics which can be read or clients can even subscribe for changes. + +Sadly we can't use any of the standardized service/ profiles to transmit V2X data. +Even notifications about DENMs can't be transmitted since the Alert Notification Profile is only meant for the companion device to query notifications from the phone, but not the other way around. + +So we will only use the Device Information Service to announce ourselves and use a custom service for everything else. + +## V2X BLE Service + +V2X data is exposed via the service UUID `c0b70000-d4f4-4000-ada8-f99a02ee315c` (randomly generated UUID, with byte 3 and 4 set to zero) using the following characteristics: + +UUID | Type | Description +------------------------------------- | --------------- | ----------- +c0b70001-d4f4-4001-ada8-f99a02ee315c | `PositionState` | Set position from external source +c0b70001-d4f4-4001-ada8-f99a02ee315c | `RawMsgRx` | Raw CAM message (de-duplicated/ rate-limited) +c0b70001-d4f4-4002-ada8-f99a02ee315c | `CamEvent` | Latest received CAM +c0b70001-d4f4-4003-ada8-f99a02ee315c | `RawMsgRx` | Raw DENM message (de-duplicated/ rate-limited) +c0b70001-d4f4-4004-ada8-f99a02ee315c | `DenmEvent` | Latest received DENM +c0b70001-d4f4-4005-ada8-f99a02ee315c | `RawMsgRx` | Raw MAPEM message (de-duplicated/ rate-limited) +c0b70001-d4f4-4006-ada8-f99a02ee315c | `RawMsgRx` | Raw SPATEM message (de-duplicated/ rate-limited) + +## Message Format + +Messages are encoded according to the [Protocol Buffers](https://protobuf.dev/) specification since it provides decent binary size and has parser libraries for many programming languages. +All data types are defined in one `.proto` file: [c_its-messages.proto](./c_its-messages.proto). + +### Position State Input + +When there's no GNSS receiver connected to the ESP, position, heading and speed information can be set using a `PositionState` message (see [c_its-messages.proto](./c_its-messages.proto)). + +### DENM Event + +Each received DENM will be "published" as a `DenmEvent` message (see [c_its-messages.proto](./c_its-messages.proto)). +Repetitions of the same message (e.g. no changes to the validity timeframe and location) may be dropped by the BLE server. + +### CAM Event + +Each received (low-frequency) CAM will be "published" as a `CamEvent` message (see [c_its-messages.proto](./c_its-messages.proto)). + +### Raw Received Message + +Received V2X messages will be "published" as a `RawMsgRx` message (see [c_its-messages.proto](./c_its-messages.proto)). +Repetitions of the same message should be dropped by the BLE server. +Consecutive messages with no relevant changes may be dropped by the BLE server while keeping some minimal publishing rate, e.g.: +- only publish MAPEMs every 10 seconds (since content doesn't change, but client may be restarted after first MAPEM from an intersection was received) +- drop high-frequency CAMs (only publish "full" CAMs which include the low-frequency container) diff --git a/docs/c_its-messages.proto b/docs/c_its-messages.proto new file mode 100644 index 0000000..a45083c --- /dev/null +++ b/docs/c_its-messages.proto @@ -0,0 +1,122 @@ +syntax = "proto2"; + +package c_its_io; + +// ------------------------------ +// Data Types +// ------------------------------ + +message Position { + required float latitude_deg = 1; + required float longitude_deg = 2; +} + +enum TrafficDir { + // for all directions of traffic + ALL_DIRECTIONS = 0; + // for the direction of traffic according to the reference direction, and the portion of traffic upstream of the reference position + UPSTREAM = 1; + // for the direction of traffic according to the reference direction, and the portion of traffic downstream of the reference position + DOWNSTREAM = 2; + // for the direction of traffic opposite to the reference direction + OPPOSITE = 3; +} + +message VehicleContainer { + optional uint32 heading_deg = 1; // vehicle heading in degrees, if not "unavailable" + optional uint32 speed = 2; // vehicle speed in 1/100 m/s, if not "unavailable" + optional uint32 vehicle_length_dm = 3; // vehicle length in 1/10 m, if not "unavailable" + optional uint32 vehicle_width_dm = 4; // vehicle width in 1/10 m, if not "unavailable" +} + + +// GeoNetworking Destination Area Shape +enum GNAreaShape { + ETSI_AREASHAPE_CIRCLE = 0; + ETSI_AREASHAPE_RECTANGLE = 1; + ETSI_AREASHAPE_ELLIPSIS = 2; + ETSI_AREASHAPE_UNKNOWN = 15; +} + +// GeoNetworking Destination Area/ Source Position +message ITSPosition { + optional int32 latitude = 1; // Latitude in 1/10th of micro degrees + optional int32 longitude = 2; // Longitude in 1/10th of micro degrees +} + +// GeoNetworking Destination Area +message GNArea { + optional GNAreaShape area = 1 [default = ETSI_AREASHAPE_UNKNOWN]; + optional ITSPosition position = 2; + optional int32 distA = 3; // Distance A of the geometric shape in meters + optional int32 distB = 4; // Distance B of the geometric shape in meters, mandatory for rectangle and ellipsis + optional int32 angle = 5; // Angle of the geometric shape in degrees, mandatory for rectangle and ellipsis +} + +// GeoNetworking Transport +enum GNTransport { + GN_TRANSPORT_DEFAULT = 0; + GN_TRANSPORT_GUC = 2; // GeoUnicast + GN_TRANSPORT_GAC = 3; // Geographically-Scoped Anycast + GN_TRANSPORT_GBC = 4; // Geographically-Scoped broadcast + GN_TRANSPORT_TSB = 5; // Topologically-scoped broadcast + GN_TRANSPORT_SHB = 7; // Single hop broadcast +} + +message RawMsgTx { + required bytes payload = 1; // as UPER + required uint32 message_type = 2; // ITS `MessageId` integer value + + required GNTransport gnTransport = 3; + required GNArea gnArea = 4; + required uint32 hopLimit = 5; +} + +// ------------------------------ +// Messages +// ------------------------------ + +message PositionState { + required Position position = 1; + optional uint32 heading_deg = 2; + optional uint32 speed = 3; // speed in 1/100 m/s +} + +message DenmEvent { + required fixed64 timestamp = 1; // DENM reference time as UNIX time + required fixed64 validity_end_ts = 2; // UNIX time when event validity ends (from denm.management.validityDuration) + required fixed32 station_id = 3; // unique ID of the event when combined with `seq_num` + required uint32 seq_num = 4; // unique ID of the event when combined with `station_id` + optional Position event_position = 5; // event location (from denm.management.eventPosition) + optional uint32 event_heading_deg = 6; // event heading (from denm.location.eventPositionHeading) + optional TrafficDir direction = 7; // relevance traffic direction (from denm.management.trafficDirection) + optional uint32 cause_code = 8; + optional uint32 sub_cause_code = 9; +} + +message CamEvent { + required fixed32 station_id = 1; // Station ID of the vehicle + required uint32 station_type = 2; // ITS `StationType` + required Position position = 3; // vehicle position + optional uint32 vehicle_role = 4; // ITS `VehicleRole` + optional VehicleContainer vehicle_data = 5; // speed, etc unless CAM contains an RSU container +} + +message RawMsgRx { + required uint32 msg_type = 1; // ITS message type (`MessageId` from PDU header) + required bytes payload = 2; // UPER message with Geonetworking and BTP headers +} + +message SerialOutputMsg { + oneof payload { + DenmEvent denm = 1; + RawMsgRx rx_msg = 2; + } +} + +message SerialInputMsg { + oneof payload { + PositionState position = 1; + RawMsgTx tx_msg = 2; + } +} diff --git a/docs/uart-protocol.md b/docs/uart-protocol.md new file mode 100644 index 0000000..6a765e3 --- /dev/null +++ b/docs/uart-protocol.md @@ -0,0 +1,20 @@ +# UART Protocol + +If enabled using the `TODO` feature flag, all received messages are transmitted on UART using the protocol described in this document. +The serial communication can also be used to send V2X data. + +## Message Format + +The UART communication is using a custom framing protocol to encapsulate binary messages. +The payload is: + +- a `SerialOutputMsg` for transmissions from the ESP to the host or +- a `SerialInputMsg` for transmissions from the host to the ESP + +in binary [Proto](https://protobuf.dev/) serialization according to the [c_its-messages.proto](./c_its-messages.proto) definition. + +Each transmission: +- starts with a Start of Frame (SOF) sequence: `0x56, 0x32, 0x58, 0x2B` ("V2X+") +- followed by a 16-bit big-endian length field +- followed by the payload +- followed by an End of Frame (EOF) sequence `0x0d, 0x0a` From 21db09ad7f3ad140755eb3ac1548bc35989692e3 Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Wed, 22 Jul 2026 15:30:47 +0200 Subject: [PATCH 35/49] build: Add "io" module for BLE/ UART messages --- Cargo.lock | 123 ++++++++++++++++++++++++++++++++++++++ Cargo.toml | 4 ++ build.rs | 12 +++- docs/c_its-messages.proto | 4 +- src/io.rs | 51 ++++++++++++++++ src/main.rs | 3 +- 6 files changed, 193 insertions(+), 4 deletions(-) create mode 100644 src/io.rs diff --git a/Cargo.lock b/Cargo.lock index ab58dc3..359840b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -763,6 +763,16 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + [[package]] name = "esp-alloc" version = "0.10.0" @@ -1160,6 +1170,8 @@ dependencies = [ "mipidsi", "num-traits", "profont", + "prost", + "prost-build", ] [[package]] @@ -1234,12 +1246,24 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "float-cmp" version = "0.9.0" @@ -1549,6 +1573,12 @@ version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b23ac50abb8261cb38c6e2a7192d3302e0836dac1628f6a93b82b4fad185897" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litrs" version = "1.0.0" @@ -1612,6 +1642,12 @@ dependencies = [ "heapless 0.8.0", ] +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + [[package]] name = "nb" version = "0.1.3" @@ -1770,6 +1806,17 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1849,6 +1896,57 @@ dependencies = [ "embedded-graphics", ] +[[package]] +name = "prost" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" +dependencies = [ + "heck", + "itertools", + "log", + "multimap", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "regex", + "syn 2.0.117", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "prost-types" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" +dependencies = [ + "prost", +] + [[package]] name = "quote" version = "1.0.45" @@ -2037,6 +2135,19 @@ dependencies = [ "svgbobdoc", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -2248,6 +2359,18 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "termcolor" version = "1.4.1" diff --git a/Cargo.toml b/Cargo.toml index 546cb89..ac69615 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -97,11 +97,15 @@ geo-types = { version = "0.7.19", default-features = false } mipidsi = { version = "0.10.0", optional = true } num-traits = { version = "0.2.19", default-features = false, features = ["libm"] } profont = { version = "0.7.0", optional = true } +prost = { version = "0.14", default-features = false, features = ["derive"] } [dev-dependencies] assert_float_eq = "1.2.0" env_logger = "0.11.10" +[build-dependencies] +prost-build = "0.14" + # For fine tuning these settings, please refer to https://doc.rust-lang.org/cargo/reference/profiles.html [profile.dev] diff --git a/build.rs b/build.rs index 7ad15d3..f376e0e 100644 --- a/build.rs +++ b/build.rs @@ -1,6 +1,6 @@ use std::env; -fn main() { +fn main() -> std::io::Result<()> { let target_arch = env::var("CARGO_CFG_TARGET_ARCH").unwrap(); if target_arch == "riscv32" { @@ -8,6 +8,16 @@ fn main() { // make sure linkall.x is the last linker script (otherwise might cause problems with flip-link) println!("cargo:rustc-link-arg=-Tlinkall.x"); } + + let descriptor_path = + std::path::PathBuf::from(env::var("OUT_DIR").unwrap()).join("proto_descriptor.bin"); + + prost_build::Config::new() + .file_descriptor_set_path(&descriptor_path) + .compile_well_known_types() + .compile_protos(&["docs/c_its-messages.proto"], &["docs/"])?; + + Ok(()) } fn linker_be_nice() { diff --git a/docs/c_its-messages.proto b/docs/c_its-messages.proto index a45083c..c2ca54e 100644 --- a/docs/c_its-messages.proto +++ b/docs/c_its-messages.proto @@ -78,8 +78,8 @@ message RawMsgTx { message PositionState { required Position position = 1; - optional uint32 heading_deg = 2; - optional uint32 speed = 3; // speed in 1/100 m/s + optional uint32 heading = 3; // heading in 1/10 deg + optional uint32 speed = 4; // speed in 1/100 m/s } message DenmEvent { diff --git a/src/io.rs b/src/io.rs new file mode 100644 index 0000000..cb23055 --- /dev/null +++ b/src/io.rs @@ -0,0 +1,51 @@ +//! UART/ BLE I/O Protocol + +pub mod msg { + #![allow(clippy::all, clippy::pedantic, clippy::nursery, dead_code)] + include!(concat!(env!("OUT_DIR"), "/c_its_io.rs")); +} + +impl From for msg::SerialOutputMsg { + fn from(value: msg::RawMsgRx) -> Self { + use msg::serial_output_msg::Payload; + + Self { + payload: Some(Payload::RxMsg(value)), + } + } +} + +impl From for msg::SerialOutputMsg { + fn from(value: msg::DenmEvent) -> Self { + use msg::serial_output_msg::Payload; + + Self { + payload: Some(Payload::Denm(value)), + } + } +} + +impl From for crate::applogic::PositionState { + fn from(value: msg::PositionState) -> Self { + let position = geo_types::Point::new( + value.position.longitude_deg.into(), + value.position.latitude_deg.into(), + ); + let heading_deg = value.heading.map(|v| { + #[allow(clippy::cast_precision_loss)] + let val = (v as f32) / 10.; + val + }); + let speed_mps = value.speed.map(|v| { + #[allow(clippy::cast_precision_loss)] + let val = (v as f32) / 100.; + val + }); + + Self { + position, + heading_deg, + speed_mps, + } + } +} diff --git a/src/main.rs b/src/main.rs index 79cf7d9..c0d9e3a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,7 +26,7 @@ use esp_hal::timer::timg::TimerGroup; use esp_println::println; use esp_radio::wifi; use geonetworking::Decode as _; -use log::{debug, error, info, warn}; +use log::{error, info, warn}; extern crate alloc; @@ -34,6 +34,7 @@ mod applogic; #[cfg(feature = "spat")] mod cache; mod geo_alg; +mod io; mod radio; #[cfg(feature = "screen")] mod screen; From fc3581d0d1d8595457c956b37f9634a6435634d6 Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Wed, 22 Jul 2026 16:15:58 +0200 Subject: [PATCH 36/49] main: Fix feature flags --- src/main.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index c0d9e3a..bddb837 100644 --- a/src/main.rs +++ b/src/main.rs @@ -31,7 +31,6 @@ use log::{error, info, warn}; extern crate alloc; mod applogic; -#[cfg(feature = "spat")] mod cache; mod geo_alg; mod io; @@ -405,17 +404,20 @@ fn handle_frame(frame: wifi::sniffer::PromiscuousPkt<'_>) { } } +#[cfg(feature = "spat")] #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] struct RatelimitData { station_id: u32, msg_count: u8, } +#[cfg(feature = "spat")] impl cache::Cachable for RatelimitData { fn key(&self) -> u32 { self.station_id } } +#[cfg(feature = "spat")] impl cache::Initable for RatelimitData { fn init(key: u32) -> Self { Self { From baaee5f6ffd1d51cb80b06eca6acbc830dabebec Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Wed, 22 Jul 2026 21:20:48 +0200 Subject: [PATCH 37/49] logging: Only print to jtag-serial Otherwise esp-println will use UART0 when no USB-JTAG/Serial is connected. --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index ac69615..f322c5b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -72,7 +72,7 @@ esp-backtrace = { version = "0.19.0", optional = true, features = [ "panic-handler", "println", ] } -esp-println = { version = "0.17.0", optional = true, features = ["esp32c5", "log-04"] } +esp-println = { version = "0.17.0", optional = true, default-features = false, features = ["colors", "critical-section", "jtag-serial", "esp32c5", "log-04"] } esp-radio = { version = "0.18.0", optional = true, features = [ "esp-alloc", "esp32c5", From 7668c9bb7bd5151879f5e3ff181eebd076f076c3 Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Fri, 24 Jul 2026 11:51:00 +0200 Subject: [PATCH 38/49] proto/RawMsgTx: Add station type for source position vector --- docs/c_its-messages.proto | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/c_its-messages.proto b/docs/c_its-messages.proto index c2ca54e..9848910 100644 --- a/docs/c_its-messages.proto +++ b/docs/c_its-messages.proto @@ -70,6 +70,8 @@ message RawMsgTx { required GNTransport gnTransport = 3; required GNArea gnArea = 4; required uint32 hopLimit = 5; + + optional uint32 stationType = 6; // for source position vector } // ------------------------------ From 1d4e82335d02e6b513933c6defd783a76898314a Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Fri, 24 Jul 2026 11:39:35 +0200 Subject: [PATCH 39/49] feat(docs): Explain supported feature combinations, add test script --- Readme.md | 9 ++++-- feat-permut.sh | 69 +++++++++++++++++++++++++++++++++++++++++++++ src/applogic/mod.rs | 12 ++++---- 3 files changed, 82 insertions(+), 8 deletions(-) create mode 100755 feat-permut.sh diff --git a/Readme.md b/Readme.md index 9cbf097..8659877 100644 --- a/Readme.md +++ b/Readme.md @@ -52,14 +52,19 @@ rustup component add --toolchain nightly rustfmt ### Build and Flash In default configuration only signal phases (SPAT) data is displayed for the upcoming intersection. -Additional features are available though feature flags: +Additional features are available through feature flags: - `esp`: Run on ESP (required as feature flag to enable unit tests on host) - `std`: Run on system with `std` library -- `spat`: Enable signal phase (and intersection map) data +- `spat`: Enable signal phase (and intersection map) data +- `spat_debug`: Enable additional debug information for the SPAT/ GLOSA use case - `denm`: Enable DENMs - `cam`: Enable CAMs +Officially supported feature combinations are: +- `esp,spat,gnss,screen` (default features) +- To be implemented: No GNSS and screen when BLE/ UART interface is implemented + Connect your ESP32-C5 and run: ``` diff --git a/feat-permut.sh b/feat-permut.sh new file mode 100755 index 0000000..cd20431 --- /dev/null +++ b/feat-permut.sh @@ -0,0 +1,69 @@ +#!/bin/bash +## Build/ Test/ Clippy all supported feature permutations +set -eu + +mode=${1:-help} +wasm_target="--chrome --headless" +extended=${2:-false} + + +features_base=( + #cam,denm,uart + spat,gnss,screen +) +features_more=( + spat,gnss,screen,cam,denm +) +features_test=( + spat + spat,cam,denm +) + + +runBuild () { + for feat in "${features[@]}"; do + echo "cargo build for $feat ..." + cargo build --release --no-default-features -F esp,$feat + done +} + +runTest () { + for feat in "${features_test[@]}"; do + echo "cargo test for $feat ..." + cargo test --target host-tuple --bin test --no-default-features -F std,$feat + done +} + +runClippy () { + for feat in "${features[@]}"; do + echo "clippy for $feat ..." + cargo clippy --no-default-features -F esp,$feat -- -Wclippy::pedantic + done +} + +if [ $extended == "--more" ]; then + echo "running with extended feature list" + features=( "${features_base[@]}" "${features_more[@]}" ) +else + echo "running standard feature list" + features=( "${features_base[@]}" ) +fi + +case $mode in +build) + runBuild + ;; +clippy) + runClippy + ;; +test) + runTest + ;; +all) + runBuild + runClippy + runTest + ;; +*) + echo "Usage: $0 [build | clippy | test | all] [--more]" +esac diff --git a/src/applogic/mod.rs b/src/applogic/mod.rs index bf3252a..98d100f 100644 --- a/src/applogic/mod.rs +++ b/src/applogic/mod.rs @@ -6,7 +6,7 @@ use alloc::rc::Rc; use alloc::vec::Vec; use log::info; -#[cfg(any(feature = "gnss", feature = "spat"))] +#[cfg(feature = "spat")] use log::warn; #[cfg(feature = "spat")] use log::{debug, error}; @@ -688,13 +688,13 @@ mod tests { let own_pos_passed = geo_types::Point::new(9.977217, 53.5560602); let own_heading = 90.; - state.heading_deg = Some(own_heading); + state.pos.heading_deg = Some(own_heading); - state.position = own_pos_infront; + state.pos.position = own_pos_infront; state.run_glosa(); assert!(matches!(state.glosa_state, GlosaState::Idle)); - state.position = own_pos_inside; + state.pos.position = own_pos_inside; state.run_glosa(); assert!(matches!(state.glosa_state, GlosaState::Locked(_))); if let GlosaState::Locked(info) = &state.glosa_state { @@ -702,7 +702,7 @@ mod tests { assert!(info.lane_id == 1 || info.lane_id == 2); } - state.position = own_pos_inside; + state.pos.position = own_pos_inside; state.run_glosa(); assert!(matches!(state.glosa_state, GlosaState::Locked(_))); if let GlosaState::Locked(info) = &state.glosa_state { @@ -710,7 +710,7 @@ mod tests { assert!(info.lane_id == 1 || info.lane_id == 2); } - state.position = own_pos_passed; + state.pos.position = own_pos_passed; state.run_glosa(); assert!(matches!(state.glosa_state, GlosaState::Idle)); } From 2379a7eefc69d2342b5041b7d35ee371864d4a71 Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Fri, 3 Jul 2026 13:38:54 +0200 Subject: [PATCH 40/49] Enable V2X transmission, add optional stand-alone CAM transmission --- Cargo.lock | 22 +++-- Cargo.toml | 5 +- Readme.md | 7 ++ feat-permut.sh | 2 + src/applogic/cam_tx.rs | 197 +++++++++++++++++++++++++++++++++++++++++ src/applogic/mod.rs | 2 + src/main.rs | 68 +++++++++++++- src/v2x_tx/gn.rs | 94 ++++++++++++++++++++ src/v2x_tx/mod.rs | 160 +++++++++++++++++++++++++++++++++ 9 files changed, 543 insertions(+), 14 deletions(-) create mode 100644 src/applogic/cam_tx.rs create mode 100644 src/v2x_tx/gn.rs create mode 100644 src/v2x_tx/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 359840b..3fba778 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -73,6 +73,12 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arbitrary-int" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993a810118f8f37e9c4411c86f1c4c940a09a7ab34b7bf2d88d06f50c553fab7" + [[package]] name = "arrayvec" version = "0.7.6" @@ -186,9 +192,9 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "c-its-parser" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9bd739e1f76c017fc9fc6c21b9ff2aee4536bef357075d454e387e94a4d6287" +checksum = "cd4db66f495280ec88eef8712dd9ab7d1aacc590f62485e3e03a89b3ce4f8b14" dependencies = [ "chrono", "etherparse", @@ -1239,9 +1245,9 @@ dependencies = [ [[package]] name = "etherparse" -version = "0.20.1" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ac016aaf11dfe643edcd088a166234bfcb72e7f06691abfcf21e9af524037f4" +checksum = "17304d06addb3283cdc4bd528e42dd95e73c8ee2d6492ffce415e93660885449" dependencies = [ "arrayvec", ] @@ -1371,13 +1377,11 @@ dependencies = [ [[package]] name = "geonetworking" -version = "0.3.0" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f6d36111f5057d3be7eb4e312a07edac4737fc791e4b0a73d812c6f710161e7" +checksum = "6da6fde770112587d5f5f7af439546e35bd35aa77a9e3e47e9968056783fe50d" dependencies = [ - "bitvec", - "bitvec-nom2", - "bytes", + "arbitrary-int", "nom", "num", "num-traits", diff --git a/Cargo.toml b/Cargo.toml index f322c5b..834f164 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,8 @@ std = ["c-its-parser/std"] gnss = ["dep:embassy_gps"] # echo CAM data on serial cam = ["c-its-parser/cam"] +# send CAM autonomously +cam_tx = ["c-its-parser/cam", "gnss"] # echo DENM events on serial and BLE denm = ["c-its-parser/denm"] # evaluate SPAT information @@ -85,7 +87,8 @@ esp-radio = { version = "0.18.0", optional = true, features = [ embassy_gps = { version = "0.1.0", optional = true, default-features = false, features = ["esp", "log-04"], git = "https://github.com/jbeyerstedt/embassy_gps.git", branch = "feat/rework-gpsfix" } embedded-hal-bus = { version = "0.3.0", optional = true } embedded-graphics = { version = "0.8.2", optional = true } -c-its-parser = { version = "2.3.0", default-features = false, features = [ +# needs newer GN with removed bitvec dependency +c-its-parser = { version = "2.4.0", default-features = false, features = [ "time", "geo", "libm", diff --git a/Readme.md b/Readme.md index 8659877..281f995 100644 --- a/Readme.md +++ b/Readme.md @@ -28,6 +28,10 @@ Shall "publish" the most relevant information of a DENMs via BLE ### CAM Just prints a line for each received CAM. +### CAM-TX +Generates and transmits a CAM when a GNSS update was received. +Depends on the `gnss` feature. + ## Usage @@ -60,9 +64,12 @@ Additional features are available through feature flags: - `spat_debug`: Enable additional debug information for the SPAT/ GLOSA use case - `denm`: Enable DENMs - `cam`: Enable CAMs +- `cam_tx`: Enable CAM generation and transmission Officially supported feature combinations are: - `esp,spat,gnss,screen` (default features) +- `esp,spat,gnss,screen,cam_tx` +- `esp,spat,gnss,screen,cam_tx,cam,denm` - To be implemented: No GNSS and screen when BLE/ UART interface is implemented Connect your ESP32-C5 and run: diff --git a/feat-permut.sh b/feat-permut.sh index cd20431..a40a8ca 100755 --- a/feat-permut.sh +++ b/feat-permut.sh @@ -10,9 +10,11 @@ extended=${2:-false} features_base=( #cam,denm,uart spat,gnss,screen + spat,gnss,screen,cam_tx ) features_more=( spat,gnss,screen,cam,denm + spat,gnss,screen,cam_tx,cam,denm ) features_test=( spat diff --git a/src/applogic/cam_tx.rs b/src/applogic/cam_tx.rs new file mode 100644 index 0000000..7de554e --- /dev/null +++ b/src/applogic/cam_tx.rs @@ -0,0 +1,197 @@ +//! C-ITS CAM Transmission + +use alloc::vec::Vec; + +use c_its_parser::gn as geonetworking; +use c_its_parser::standards::cam_1_4_1::cam_pdu_descriptions; +use c_its_parser::standards::cdd_1_3_1_1::its_container; +use c_its_parser::standards::extensions; + +#[derive(Debug)] +pub struct CamState { + station_id: u32, + station_type: its_container::StationType, + + vehicle_length_dm: u16, // vehicle length is in 10cm steps + vehicle_width_dm: u8, // vehicle width is in 10cm steps + + time: chrono::DateTime, + pos_state: super::PositionState, +} + +impl CamState { + pub fn new( + station_id: u32, + station_type: c_its_parser::standards::extensions::ItsStationType, + vehicle_length_dm: u16, + vehicle_width_dm: u8, + ) -> Self { + Self { + station_id, + station_type: station_type.into(), + vehicle_length_dm, + vehicle_width_dm, + time: chrono::DateTime::::default(), + pos_state: super::PositionState::default(), + } + } + + pub fn update(&mut self, time: chrono::DateTime, pos: super::PositionState) { + self.time = time; + self.pos_state = pos; + + // TODO: build CAM trace + } + + #[allow(clippy::too_many_lines)] + fn make_cam(&self) -> Result { + let header = its_container::ItsPduHeader::new( + 2, + extensions::ItsMessageId::Cam.as_u8(), + its_container::StationID(self.station_id), + ); + + let generation_delta_time = { + let its_timestamp = c_its_parser::time_utils::TimestampIts::from(self.time); + + #[allow(clippy::cast_possible_truncation)] + let ts_mod = (its_timestamp.0 % 65_536) as u16; + + cam_pdu_descriptions::GenerationDeltaTime(ts_mod) + }; + + let heading = match self.pos_state.heading_deg { + Some(value) => its_container::Heading { + heading_value: its_container::HeadingValue::from_deg(value) + .map_err(|err| alloc::format!("Failed to make HeadingValue: {err}"))?, + heading_confidence: its_container::HeadingConfidence(127), // unavailable for now + }, + None => its_container::Heading { + heading_value: its_container::HeadingValue(0), + heading_confidence: its_container::HeadingConfidence(127), // unavailable for now + }, + }; + let speed = match self.pos_state.speed_mps { + Some(value) => its_container::Speed { + speed_value: its_container::SpeedValue::from_mps(value) + .map_err(|err| alloc::format!("Failed to make SpeedValue: {err}"))?, + speed_confidence: its_container::SpeedConfidence(127), // unavailable for now + }, + None => its_container::Speed { + speed_value: its_container::SpeedValue(0), + speed_confidence: its_container::SpeedConfidence(127), // unavailable for now + }, + }; + let reference_position = its_container::ReferencePosition { + latitude: its_container::Latitude::from_deg(self.pos_state.position.y()), + longitude: its_container::Longitude::from_deg(self.pos_state.position.x()), + position_confidence_ellipse: its_container::PosConfidenceEllipse { + semi_major_confidence: its_container::SemiAxisLength(4095), // unavailable for now + semi_minor_confidence: its_container::SemiAxisLength(4095), // unavailable for now + semi_major_orientation: its_container::HeadingValue::unavailable(), + }, + altitude: its_container::Altitude { + altitude_value: its_container::AltitudeValue(800_001), // unavailable for now + altitude_confidence: its_container::AltitudeConfidence::unavailable, + }, + }; + + let basic_container = cam_pdu_descriptions::BasicContainer::new( + self.station_type.clone(), + reference_position, + ); + let high_frequency_container = + cam_pdu_descriptions::HighFrequencyContainer::basicVehicleContainerHighFrequency( + cam_pdu_descriptions::BasicVehicleContainerHighFrequency { + heading, + speed, + drive_direction: its_container::DriveDirection::unavailable, + vehicle_length: its_container::VehicleLength { + vehicle_length_value: its_container::VehicleLengthValue( + self.vehicle_length_dm, + ), + vehicle_length_confidence_indication: + its_container::VehicleLengthConfidenceIndication::unavailable, + }, + vehicle_width: its_container::VehicleWidth(self.vehicle_width_dm), + longitudinal_acceleration: its_container::LongitudinalAcceleration { + longitudinal_acceleration_value: + its_container::LongitudinalAccelerationValue::unavailable(), + longitudinal_acceleration_confidence: its_container::AccelerationConfidence( + 102, // unavailable + ), + }, + curvature: its_container::Curvature { + curvature_value: its_container::CurvatureValue::unavailable(), + curvature_confidence: its_container::CurvatureConfidence::unavailable, + }, + curvature_calculation_mode: + its_container::CurvatureCalculationMode::unavailable, + yaw_rate: its_container::YawRate { + yaw_rate_value: its_container::YawRateValue::unavailable(), + yaw_rate_confidence: its_container::YawRateConfidence::unavailable, + }, + acceleration_control: None, + lane_position: None, + steering_wheel_angle: None, + lateral_acceleration: None, + vertical_acceleration: None, + performance_class: None, + cen_dsrc_tolling_zone: None, + }, + ); + let low_frequency_container = + cam_pdu_descriptions::LowFrequencyContainer::basicVehicleContainerLowFrequency( + cam_pdu_descriptions::BasicVehicleContainerLowFrequency { + vehicle_role: its_container::VehicleRole::default, + exterior_lights: its_container::ExteriorLights::default(), + path_history: its_container::PathHistory(alloc::vec![]), // TODO: fill trace + }, + ); + + let cam_parameters = cam_pdu_descriptions::CamParameters::new( + basic_container, + high_frequency_container, + Some(low_frequency_container), + None, + ); + + Ok(cam_pdu_descriptions::CAM::new( + header, + cam_pdu_descriptions::CoopAwareness::new(generation_delta_time, cam_parameters), + )) + } +} + +impl crate::v2x_tx::GnItsPayload for CamState { + fn make_eh( + &self, + address: [u8; 6], + own_position: &crate::applogic::PositionState, + time: chrono::DateTime, + _seq_no: &mut u16, + ) -> ( + geonetworking::en302636_4_1::ExtendedHeader, + Option, + u8, + ) { + let station_type = geonetworking::en302636_4_1::StationType::try_from(self.station_type.0) + .unwrap_or_default(); + + // CAM always uses SHB, hop-limit 1 + ( + crate::v2x_tx::gn::make_shb_eh(address, station_type, own_position, time), + None, + 1, + ) + } + + fn make_payload( + &self, + ) -> Result<(Vec, c_its_parser::standards::extensions::ItsMessageId), alloc::string::String> + { + let msg = self.make_cam()?; + let uper = msg.encode_to_uper()?; + Ok((uper, c_its_parser::standards::extensions::ItsMessageId::Cam)) + } +} diff --git a/src/applogic/mod.rs b/src/applogic/mod.rs index 98d100f..d1d082e 100644 --- a/src/applogic/mod.rs +++ b/src/applogic/mod.rs @@ -18,6 +18,8 @@ pub mod v2x; #[cfg(feature = "cam")] pub mod cam; +#[cfg(feature = "cam_tx")] +pub mod cam_tx; #[cfg(feature = "denm")] pub mod denm; #[cfg(feature = "spat")] diff --git a/src/main.rs b/src/main.rs index bddb837..daac5b8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,6 +14,8 @@ use core::cell::RefCell; use core::cell::UnsafeCell; use c_its_parser::gn as geonetworking; +#[cfg(feature = "cam_tx")] +use c_its_parser::standards::extensions::ItsStationType; use critical_section::Mutex; use crossbeam_queue::ArrayQueue; use embassy_executor::Spawner; @@ -37,10 +39,20 @@ mod io; mod radio; #[cfg(feature = "screen")] mod screen; +#[cfg(feature = "cam_tx")] +mod v2x_tx; const WIFI_CHANNEL: radio::Channel = 180; #[cfg(feature = "spat")] const SPAT_RATE_LIMIT: u8 = 5; // keep every n-th message +#[cfg(feature = "cam_tx")] +const GN_IS_MOBILE: bool = true; +#[cfg(feature = "cam_tx")] +const OWN_STATION_TYPE: ItsStationType = ItsStationType::Cyclist; +#[cfg(feature = "cam_tx")] +const OWN_VEHICLE_WIDTH: u8 = 5; // in 10cm steps! +#[cfg(feature = "cam_tx")] +const OWN_VEHICLE_LENGTH: u16 = 20; // in 10cm steps! // This creates a default app-descriptor required by the esp-idf bootloader. // For more information see: @@ -119,7 +131,7 @@ async fn main(spawner: Spawner) -> ! { // Setup WiFi // sadly, it doesn't work any more when we move `esp_radio::wifi::new` to `setup_wifi_sniffer` - let (mut wifi_controller, mut interfaces) = esp_radio::wifi::new( + let (mut wifi_controller, interfaces) = esp_radio::wifi::new( peripherals.WIFI, esp_radio::wifi::ControllerConfig::default(), ) @@ -131,7 +143,8 @@ async fn main(spawner: Spawner) -> ! { critical_section::with(|cs| { WIFI_RX_QUEUE.borrow(cs).replace(Some(ArrayQueue::new(2))); }); - radio::setup_wifi_sniffer(WIFI_CHANNEL, &mut interfaces.sniffer, handle_frame) + let mut wlan_iface = interfaces.sniffer; + radio::setup_wifi_sniffer(WIFI_CHANNEL, &mut wlan_iface, handle_frame) .expect("Fatal error initializing 802.11p sniffer"); info!("WiFi promiscuous mode on channel {WIFI_CHANNEL} running"); @@ -159,6 +172,23 @@ async fn main(spawner: Spawner) -> ! { info!("GNSS task started, waiting for GNSS fix..."); let mut state = applogic::State::new(); + #[cfg(feature = "cam_tx")] + let (mut seq_no, mut cam, mac_bytes) = { + let station_id = v2x_tx::make_station_id(mac); + info!("V2X Station ID: {station_id}"); + + // unwrap is fine since [`esp_hal::efuse::MacAddress`] is just an `[u8; 6]` + let mac_bytes = mac.as_bytes().try_into().unwrap(); + + let cam = applogic::cam_tx::CamState::new( + station_id, + OWN_STATION_TYPE, + OWN_VEHICLE_LENGTH, + OWN_VEHICLE_WIDTH, + ); + + (0, cam, mac_bytes) + }; loop { embassy_time::Timer::after(embassy_time::Duration::from_millis(1)).await; @@ -171,6 +201,33 @@ async fn main(spawner: Spawner) -> ! { if let Some(fix) = gnss_update_ref.replace(None) { state.update_with_gpsfix(&fix); new_position = true; + + #[cfg(feature = "cam_tx")] + { + use v2x_tx::GnItsPayload; + let time = state.time().and_utc(); + + cam.update(time, fix.clone().into()); + + match cam.encode_packet( + mac_bytes, + &fix.into(), + time, + GN_IS_MOBILE, + &mut seq_no, + ) { + Ok(data) => { + if let Err(err) = radio::send_80211_bcast_frame( + &mut wlan_iface, + mac.as_bytes(), + &data, + ) { + warn!("Failed to send 802.11 frame: {err}"); + } + } + Err(err) => warn!("Failed to create CAM: {err}"), + } + } } }); @@ -236,12 +293,15 @@ async fn main(spawner: Spawner) -> ! { } } - #[cfg(feature = "cam")] + #[cfg(any(feature = "cam", feature = "cam_tx"))] ItsMessage::Cam { geonetworking: _, transport: _, etsi, - } => applogic::cam::handle_cam(&etsi), + } => { + #[cfg(feature = "cam")] + applogic::cam::handle_cam(&etsi); + } #[cfg(feature = "denm")] ItsMessage::DenmV2 { diff --git a/src/v2x_tx/gn.rs b/src/v2x_tx/gn.rs new file mode 100644 index 0000000..828c74d --- /dev/null +++ b/src/v2x_tx/gn.rs @@ -0,0 +1,94 @@ +//! Geonetworking utilities + +use c_its_parser::gn as geonetworking; + +#[allow(unused)] +pub fn make_shb_eh( + address: [u8; 6], + station_type: geonetworking::en302636_4_1::StationType, + own_position: &crate::applogic::PositionState, + time: chrono::DateTime, +) -> geonetworking::en302636_4_1::ExtendedHeader { + let source_position_vector = make_lpv(address, station_type, own_position, time); + + geonetworking::en302636_4_1::ExtendedHeader::SHB( + geonetworking::en302636_4_1::SingleHopBroadcast { + source_position_vector, + media_dependent_data: [0; 4], + }, + ) +} + +#[allow(unused)] +pub fn make_tsb_eh( + address: [u8; 6], + station_type: geonetworking::en302636_4_1::StationType, + own_position: &crate::applogic::PositionState, + time: chrono::DateTime, + sequence_number: u16, +) -> geonetworking::en302636_4_1::ExtendedHeader { + let source_position_vector = make_lpv(address, station_type, own_position, time); + + geonetworking::en302636_4_1::ExtendedHeader::TSB( + geonetworking::en302636_4_1::TopologicallyScopedBroadcast::new( + sequence_number, + source_position_vector, + ), + ) +} + +#[allow(unused)] +pub fn make_gbc_eh( + address: [u8; 6], + station_type: geonetworking::en302636_4_1::StationType, + own_position: &crate::applogic::PositionState, + time: chrono::DateTime, + target_pos: geo_types::Point, + radius_m: u16, + sequence_number: u16, +) -> geonetworking::en302636_4_1::ExtendedHeader { + let source_position_vector = make_lpv(address, station_type, own_position, time); + #[allow(clippy::cast_possible_truncation)] + let latitude_deg = target_pos.x() as f32; + #[allow(clippy::cast_possible_truncation)] + let longitude_deg = target_pos.y() as f32; + + geonetworking::en302636_4_1::ExtendedHeader::GBC( + geonetworking::en302636_4_1::GeoBroadcast::try_from_values( + sequence_number, + source_position_vector, + latitude_deg, + longitude_deg, + radius_m, + 0, + 0, + ) + .expect("Failed to create circular GBC"), + ) +} + +fn make_lpv( + address: [u8; 6], + station_type: geonetworking::en302636_4_1::StationType, + own_position: &crate::applogic::PositionState, + time: chrono::DateTime, +) -> geonetworking::en302636_4_1::LongPositionVector { + let timestamp_its = c_its_parser::time_utils::TimestampIts::from(time); + #[allow(clippy::cast_possible_truncation)] + let latitude_deg = own_position.position.y() as f32; + #[allow(clippy::cast_possible_truncation)] + let longitude_deg = own_position.position.x() as f32; + let speed_mps = own_position.speed_mps.unwrap_or_default(); + let heading_deg = own_position.heading_deg.unwrap_or_default(); + + geonetworking::en302636_4_1::LongPositionVector::try_from_values( + geonetworking::en302636_4_1::Address::new(false, station_type, address), + timestamp_its.0, + latitude_deg, + longitude_deg, + true, + speed_mps, + heading_deg, + ) + .expect("Failed to create LongPositionVector") +} diff --git a/src/v2x_tx/mod.rs b/src/v2x_tx/mod.rs new file mode 100644 index 0000000..e9b1a28 --- /dev/null +++ b/src/v2x_tx/mod.rs @@ -0,0 +1,160 @@ +//! V2X Transmission + +use alloc::vec::Vec; + +use c_its_parser::gn::{self as geonetworking, Encode}; + +pub mod gn; + +#[cfg(feature = "esp")] +/// Creates a station ID from the WLAN MAC address +/// +/// Will use `0xC173` as static prefix and uses last 16 bit from MAC address for the remainder. +pub fn make_station_id(mac: esp_hal::efuse::MacAddress) -> u32 { + // unwrap is fine here since we can be sure that the MacAddress is 6 byte + // and a slice of 2 bytes can be converted to [u8; 2] + let mac_last_16bit = u16::from_be_bytes(mac.as_bytes().get(4..6).unwrap().try_into().unwrap()); + + 0xC173_0000 | u32::from(mac_last_16bit) +} + +pub trait GnItsPayload { + // common GN parameters + const LIFETIME_MILLIS: u32 = 60_000; // itsGnDefaultPacketLifetime is 60 seconds according to ETSI EN 302 636-4-1 V1.4.1 + + // --------------------------------------------- + // methods to build a GN packet with ITS payload + // --------------------------------------------- + + /// Builds the [`geonetworking::en302636_4_1::ExtendedHeader`] and additional information for the ITS message + /// + /// Output: + /// - Extended header + /// - Area type (when using GAC or GBC) + /// - hop limit + /// + /// Note: Make sure to increase the sequence number on multi-hop packets. + fn make_eh( + &self, + address: [u8; 6], + own_position: &crate::applogic::PositionState, + time: chrono::DateTime, + seq_no: &mut u16, + ) -> ( + geonetworking::en302636_4_1::ExtendedHeader, + Option, + u8, + ); + + /// Creates the ITS payload (without BTP header) + fn make_payload( + &self, + ) -> Result<(Vec, c_its_parser::standards::extensions::ItsMessageId), alloc::string::String>; + + // --------------------------------------------- + // common methods (with implementation) + // --------------------------------------------- + + /// Encodes the GN packet + fn encode_packet( + &self, + address: [u8; 6], + own_position: &crate::applogic::PositionState, + time: chrono::DateTime, + is_mobile: bool, + seq_no: &mut u16, + ) -> Result, alloc::string::String> { + // Build payload (including BTP header) + let (mut its_payload, msg_type) = self.make_payload()?; + let mut payload = make_btp_b(msg_type).encode()?; + + payload.append(&mut its_payload); + + #[allow(clippy::cast_possible_truncation)] + let payload_length = (payload.len()) as u16; + + // get extended header (depending on message type and sometimes content as well) + let (eh, area_type, maximum_hop_limit) = self.make_eh(address, own_position, time, seq_no); + + // Build GN headers + let traffic_class = geonetworking::en302636_4_1::TrafficClass::try_new( + false, + false, + make_traffic_class(msg_type), + ) + .map_err(|err| alloc::format!("Failed to create TrafficClass: {err:?}"))?; + let common = geonetworking::en302636_4_1::CommonHeader::from_values( + geonetworking::en302636_4_1::NextAfterCommon::BTPB, + geonetworking::en302636_4_1::HeaderType::new_from(&eh, area_type)?, + traffic_class, + is_mobile, + payload_length, + maximum_hop_limit, + ); + + let remaining_hop_limit = maximum_hop_limit; + let basic = geonetworking::en302636_4_1::BasicHeader::try_new( + 1, + geonetworking::en302636_4_1::NextAfterBasic::CommonHeader, + geonetworking::en302636_4_1::Lifetime::from_milliseconds(Self::LIFETIME_MILLIS), + remaining_hop_limit, + ) + .map_err(|err| alloc::format!("Failed to create BasicHeader: {err}"))?; + + // Assemble and encode packet + let packet = geonetworking::Packet::Unsecured { + basic, + common, + extended: Some(eh), + payload: &payload, + }; + + packet + .encode_to_vec() + .map_err(|err| alloc::format!("Failed to encode GN packet: {err:?}")) + } +} + +/// Creates a BTP-B header based on the ITS message ID (message type) +/// +/// Port numbers and destination port info according to ETSI TS 103 248 +fn make_btp_b( + msg_type: c_its_parser::standards::extensions::ItsMessageId, +) -> c_its_parser::transport::TransportHeader { + let (destination_port, destination_port_info) = match msg_type { + c_its_parser::standards::extensions::ItsMessageId::Denm => (2002, 0), + c_its_parser::standards::extensions::ItsMessageId::Cam => (2001, 0), + c_its_parser::standards::extensions::ItsMessageId::Mapem => (2003, 0), + c_its_parser::standards::extensions::ItsMessageId::Spatem => (2004, 0), + c_its_parser::standards::extensions::ItsMessageId::Saem => (2005, 0), + c_its_parser::standards::extensions::ItsMessageId::Ivim => (2006, 0), + c_its_parser::standards::extensions::ItsMessageId::Srem => (2007, 0), + c_its_parser::standards::extensions::ItsMessageId::Ssem => (2008, 0), + c_its_parser::standards::extensions::ItsMessageId::Cpm => (2009, 0), + c_its_parser::standards::extensions::ItsMessageId::Poi => (2010, 0), + c_its_parser::standards::extensions::ItsMessageId::EvRsr => (2012, 0), + c_its_parser::standards::extensions::ItsMessageId::Rtcmem => (2013, 0), + _ => (0, 0), // no information found for the subsequent types + }; + + c_its_parser::transport::TransportHeader::BtpB(c_its_parser::transport::BasicTransportBHeader { + destination_port, + destination_port_info, + }) +} + +/// Creates a traffic class ID based on the ITS message ID (message type) +fn make_traffic_class(msg_type: c_its_parser::standards::extensions::ItsMessageId) -> u8 { + #[allow(clippy::match_same_arms)] + match msg_type { + c_its_parser::standards::extensions::ItsMessageId::Denm => 1, + c_its_parser::standards::extensions::ItsMessageId::Cam => 2, + c_its_parser::standards::extensions::ItsMessageId::Mapem => 3, // from C-Roads RS_RSP_063(1) + c_its_parser::standards::extensions::ItsMessageId::Spatem => 3, // from C-Roads RS_RSP_063(1) + c_its_parser::standards::extensions::ItsMessageId::Ivim => 3, // from C-Roads RS_RSP_063(1) + c_its_parser::standards::extensions::ItsMessageId::Srem => 2, // from C-Roads RS_RSP_063(1) + c_its_parser::standards::extensions::ItsMessageId::Ssem => 2, // from C-Roads RS_RSP_063(1) + c_its_parser::standards::extensions::ItsMessageId::Rtcmem => 3, // CSP_MaxPrio=252 -> 3 + _ => 3, // fall-back to lowest priority + } +} From 7bbb47a844d96195abf2075eb388bc2faa9ef088 Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Fri, 24 Jul 2026 17:25:52 +0200 Subject: [PATCH 41/49] cam_tx: Fill path history --- Cargo.toml | 2 +- feat-permut.sh | 1 - src/applogic/cam_tx.rs | 230 ++++++++++++++++++++++++++++++++++++++++- src/applogic/mod.rs | 2 +- src/test.rs | 1 + 5 files changed, 231 insertions(+), 5 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 834f164..fb0d588 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ path = "./src/main.rs" [[bin]] name = "test" path = "./src/test.rs" -required-features = ["std"] +required-features = ["std", "cam"] [features] default = ["esp", "spat", "gnss", "screen"] diff --git a/feat-permut.sh b/feat-permut.sh index a40a8ca..6ddd438 100755 --- a/feat-permut.sh +++ b/feat-permut.sh @@ -17,7 +17,6 @@ features_more=( spat,gnss,screen,cam_tx,cam,denm ) features_test=( - spat spat,cam,denm ) diff --git a/src/applogic/cam_tx.rs b/src/applogic/cam_tx.rs index 7de554e..0cd0a16 100644 --- a/src/applogic/cam_tx.rs +++ b/src/applogic/cam_tx.rs @@ -7,6 +7,10 @@ use c_its_parser::standards::cam_1_4_1::cam_pdu_descriptions; use c_its_parser::standards::cdd_1_3_1_1::its_container; use c_its_parser::standards::extensions; +const PH_MAX_LENGHT_M: f32 = 500.; +const PH_MAX_ITEMS: usize = 23; +const PH_MIN_DIST_M: f32 = 20.; + #[derive(Debug)] pub struct CamState { station_id: u32, @@ -17,6 +21,39 @@ pub struct CamState { time: chrono::DateTime, pos_state: super::PositionState, + + /// database for path history, oldest item at the back of the vector + path_history: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +struct PathPoint { + pos: geo_types::Point, + time: chrono::DateTime, + /// distance to previous point. Ignore on last point! + dist_m: f32, +} + +impl PathPoint { + fn new_from_pos(pos: &super::PositionState, time: chrono::DateTime) -> Self { + Self { + pos: pos.position, + time, + dist_m: 0., + } + } + + fn new_from_pos_with_dist( + pos: &super::PositionState, + time: chrono::DateTime, + dist_m: f32, + ) -> Self { + Self { + pos: pos.position, + time, + dist_m, + } + } } impl CamState { @@ -33,6 +70,7 @@ impl CamState { vehicle_width_dm, time: chrono::DateTime::::default(), pos_state: super::PositionState::default(), + path_history: Vec::with_capacity(PH_MAX_ITEMS + 1), } } @@ -40,7 +78,99 @@ impl CamState { self.time = time; self.pos_state = pos; - // TODO: build CAM trace + // Update path history + Self::update_ph(&mut self.path_history, time, &self.pos_state); + } + + // this is a function, not a method for easier testing + fn update_ph( + path_history: &mut Vec, + time: chrono::DateTime, + pos: &super::PositionState, + ) { + // Update path history + if path_history.is_empty() { + // prime with current position if empty + path_history.push(PathPoint::new_from_pos(pos, time)); + } else { + // unwrap is fine since we checked for non-empty before + let prev = path_history.first().unwrap(); + + // only add a point, if we moved more than PH_MIN_DIST_M + #[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)] + let dist = crate::geo_alg::haversine_dist(&prev.pos, &pos.position) as f32; + if dist > PH_MIN_DIST_M { + // new items shall be at the front of the vector + path_history.insert(0, PathPoint::new_from_pos_with_dist(pos, time, dist)); + } + } + + // check length (PH_MAX_LENGHT_M and PH_MAX_ITEMS points max) + if path_history.len() > PH_MAX_ITEMS { + let _ = path_history.pop(); + } + + let (_, cutoff_idx) = + path_history + .iter() + .enumerate() + .fold((0., None), |(len, cutoff_idx), (idx, item)| { + if cutoff_idx.is_none() { + let new_len = len + item.dist_m; + let new_cutoff_idx = if len > PH_MAX_LENGHT_M { + Some(idx) + } else { + None + }; + + (new_len, new_cutoff_idx) + } else { + (len, cutoff_idx) + } + }); + if let Some(idx) = cutoff_idx { + path_history.drain(idx..); + } + } + + fn make_ph(&self) -> Vec { + let mut ph = alloc::vec![]; + + self.path_history.iter().fold( + PathPoint::new_from_pos(&self.pos_state, self.time), + |prev_point, pt| { + // calculate delta position and append to `ph` + + let delta_time = pt.time - prev_point.time; + let delta_lat = pt.pos.y() - prev_point.pos.y(); + let delta_lon = pt.pos.x() - prev_point.pos.x(); + + // delta time is in 1/100 second / 10 ms steps + let path_delta_time = its_container::PathDeltaTime( + (delta_time.num_milliseconds().abs() / 10) + .min(65_535) // clamp to max value + .into(), + ); + let delta_latitude = its_container::DeltaLatitude::from_deg(delta_lat); + let delta_longitude = its_container::DeltaLongitude::from_deg(delta_lon); + let delta_altitude = its_container::DeltaAltitude::unavailable(); + + let ppoint = its_container::PathPoint::new( + its_container::DeltaReferencePosition::new( + delta_latitude, + delta_longitude, + delta_altitude, + ), + Some(path_delta_time), + ); + + ph.push(ppoint); + + *pt + }, + ); + + ph } #[allow(clippy::too_many_lines)] @@ -145,7 +275,7 @@ impl CamState { cam_pdu_descriptions::BasicVehicleContainerLowFrequency { vehicle_role: its_container::VehicleRole::default, exterior_lights: its_container::ExteriorLights::default(), - path_history: its_container::PathHistory(alloc::vec![]), // TODO: fill trace + path_history: its_container::PathHistory(self.make_ph()), }, ); @@ -195,3 +325,99 @@ impl crate::v2x_tx::GnItsPayload for CamState { Ok((uper, c_its_parser::standards::extensions::ItsMessageId::Cam)) } } + +#[cfg(test)] +mod tests { + use alloc::vec; + + use super::*; + use crate::init_test_env_logger; + + fn posstate_from_pos(position: geo_types::Point) -> crate::applogic::PositionState { + crate::applogic::PositionState { + position, + heading_deg: None, + speed_mps: None, + } + } + + #[test] + fn cam_path_history_update() { + init_test_env_logger(); + + let time = chrono::DateTime::from_timestamp(0, 0).unwrap(); + let p1 = geo_types::Point::new(10., 53.5); + let p2 = geo_types::Point::new(10., 53.5 + 0.000045); // ~5m further (< PH_MIN_DIST_M) + let p3 = geo_types::Point::new(10., 53.5 + 0.00027); // ~30m further (> PH_MIN_DIST_M) + + let ps1 = posstate_from_pos(p1); + let pp1 = PathPoint::new_from_pos(&ps1, time); + let ps2 = posstate_from_pos(p2); + let pp2 = PathPoint::new_from_pos(&ps2, time); + let ps3 = posstate_from_pos(p3); + let pp3 = PathPoint::new_from_pos_with_dist(&ps3, time, 30.02267); + + let mut path_history: Vec = vec![]; + + // add first point, expect one point in PH + CamState::update_ph(&mut path_history, time, &posstate_from_pos(p1)); + assert_eq!(1, path_history.len()); + assert_eq!(&pp1, path_history.first().unwrap()); + + // add insignificant point, expect still one point in PH + CamState::update_ph(&mut path_history, time, &posstate_from_pos(p2)); + assert_eq!(1, path_history.len()); + assert_eq!(&pp1, path_history.first().unwrap()); + + // add significantly moved point, expect [pp3, pp1] vector + CamState::update_ph(&mut path_history, time, &posstate_from_pos(p3)); + assert_eq!(2, path_history.len()); + assert_eq!(&pp3, path_history.first().unwrap()); + assert_eq!(&pp1, path_history.last().unwrap()); + } + + #[test] + fn cam_path_history_max_len() { + init_test_env_logger(); + + let time = chrono::DateTime::from_timestamp(0, 0).unwrap(); + let p1 = geo_types::Point::new(10., 53.5); + let p2 = geo_types::Point::new(10., 53.5 + 0.00023); // at ~25m from p1 (> PH_MIN_DIST_M) + let p3 = geo_types::Point::new(10., 53.5 + 0.00445); // at ~495m from p1 (slightly less than PH_MAX_LENGHT_M) + let p4 = geo_types::Point::new(10., 53.5 + 0.00477); // at ~530m from p1 (slightly more than PH_MAX_LENGHT_M) + + let ps1 = posstate_from_pos(p1); + let pp1 = PathPoint::new_from_pos(&ps1, time); + let ps2 = posstate_from_pos(p2); + let pp2 = PathPoint::new_from_pos(&ps2, time); + let ps3 = posstate_from_pos(p3); + let pp3 = PathPoint::new_from_pos(&ps3, time); + let ps4 = posstate_from_pos(p4); + let pp4 = PathPoint::new_from_pos(&ps4, time); + + let mut path_history: Vec = vec![]; + + // prime with p3, p4 (should give a length of ~470m) + CamState::update_ph(&mut path_history, time, &posstate_from_pos(p4)); + CamState::update_ph(&mut path_history, time, &posstate_from_pos(p3)); + assert_eq!(2, path_history.len()); + + // Add p2, should drop p4 from vector because of > PH_MAX_LENGHT_M + CamState::update_ph(&mut path_history, time, &posstate_from_pos(p2)); + assert_eq!(2, path_history.len()); + assert_eq!(p2, path_history.get(0).unwrap().pos); + assert_eq!(469.24323, path_history.get(0).unwrap().dist_m); + assert_eq!(p3, path_history.get(1).unwrap().pos); + assert_eq!(35.582424, path_history.get(1).unwrap().dist_m); + + // Add p1, should not drop anything from vector because still < PH_MAX_LENGHT_M + CamState::update_ph(&mut path_history, time, &posstate_from_pos(p1)); + assert_eq!(3, path_history.len()); + assert_eq!(p1, path_history.get(0).unwrap().pos); + assert_eq!(25.57487, path_history.get(0).unwrap().dist_m); + assert_eq!(p2, path_history.get(1).unwrap().pos); + assert_eq!(469.24323, path_history.get(1).unwrap().dist_m); + assert_eq!(p3, path_history.get(2).unwrap().pos); + assert_eq!(35.582424, path_history.get(2).unwrap().dist_m); + } +} diff --git a/src/applogic/mod.rs b/src/applogic/mod.rs index d1d082e..32ee4f6 100644 --- a/src/applogic/mod.rs +++ b/src/applogic/mod.rs @@ -18,7 +18,7 @@ pub mod v2x; #[cfg(feature = "cam")] pub mod cam; -#[cfg(feature = "cam_tx")] +#[cfg(any(feature = "cam_tx", test))] pub mod cam_tx; #[cfg(feature = "denm")] pub mod denm; diff --git a/src/test.rs b/src/test.rs index 5a04150..7de1bf8 100644 --- a/src/test.rs +++ b/src/test.rs @@ -8,6 +8,7 @@ mod cache; mod geo_alg; mod radio; mod testdata; +mod v2x_tx; fn main() { // env_logger::init(); From 6604670490cbb36874fd7e36d718b445da88a5bc Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Sun, 26 Jul 2026 21:40:55 +0200 Subject: [PATCH 42/49] main: Add UART I/O helpers --- Cargo.toml | 2 + src/main.rs | 118 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index fb0d588..bb70ea2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,6 +48,8 @@ spat_debug = [] # enable ST7789 based display screen = ["dep:mipidsi", "dep:embedded-graphics", "dep:embedded-hal-bus", "dep:profont"] +# internal feature, that any UART I/O is enabled +_uart = [] [dependencies] esp-hal = { version = "~1.1.0", optional = true, features = ["esp32c5", "log-04", "unstable"] } diff --git a/src/main.rs b/src/main.rs index daac5b8..03ef304 100644 --- a/src/main.rs +++ b/src/main.rs @@ -53,6 +53,8 @@ const OWN_STATION_TYPE: ItsStationType = ItsStationType::Cyclist; const OWN_VEHICLE_WIDTH: u8 = 5; // in 10cm steps! #[cfg(feature = "cam_tx")] const OWN_VEHICLE_LENGTH: u16 = 20; // in 10cm steps! +#[cfg(feature = "_uart")] +const SERIAL_FIFO_SIZE_THLD: u8 = 100; // hardware has max. 128 byte // This creates a default app-descriptor required by the esp-idf bootloader. // For more information see: @@ -61,6 +63,11 @@ esp_bootloader_esp_idf::esp_app_desc!(); #[cfg(feature = "gnss")] static GNSS_UPDATE: Mutex>> = Mutex::new(RefCell::new(None)); static WIFI_RX_QUEUE: Mutex>>>> = Mutex::new(RefCell::new(None)); +#[cfg(feature = "_uart")] +static UART_RX_BUF: Mutex>>> = Mutex::new(RefCell::new(None)); +#[cfg(feature = "_uart")] +static SERIAL: Mutex>>> = + Mutex::new(RefCell::new(None)); #[allow( clippy::large_stack_frames, @@ -192,6 +199,25 @@ async fn main(spawner: Spawner) -> ! { loop { embassy_time::Timer::after(embassy_time::Duration::from_millis(1)).await; + #[cfg(feature = "_uart")] + { + let mut new_data = None; + critical_section::with(|cs| { + let mut buf_rc = UART_RX_BUF.borrow(cs).borrow_mut(); + // unwrap is fine b/c we stored something in it before + let buf = buf_rc.as_mut().unwrap(); + + // we move the buffer to new_data to do the processing outside of the critical section lock + if !buf.is_empty() { + new_data = Some(core::mem::take(buf)); + } + }); + + if let Some(data) = new_data { + warn!("TODO: New UART data: {data:x?}"); + } + } + #[cfg(feature = "gnss")] { let mut new_position = false; @@ -573,3 +599,95 @@ where { error!("graphics failed: {error:?}"); } + +#[cfg(feature = "_uart")] +/// Configures UART0 with an interrupt handler and saves it to `SERIAL` +fn setup_uart0( + baud: u32, + cmd_char: Option, + gpio12: esp_hal::peripherals::GPIO12<'static>, + gpio11: esp_hal::peripherals::GPIO11<'static>, + uart0: esp_hal::peripherals::UART0<'static>, + handler: esp_hal::interrupt::InterruptHandler, +) -> Result<(), alloc::string::String> { + critical_section::with(|cs| { + UART_RX_BUF + .borrow(cs) + .replace(Some(alloc::vec::Vec::with_capacity(255))); + }); + + let mut uart = esp_hal::uart::Uart::new( + uart0, + esp_hal::uart::Config::default() + .with_baudrate(baud) + .with_rx( + esp_hal::uart::RxConfig::default() + .with_fifo_full_threshold(u16::from(SERIAL_FIFO_SIZE_THLD)) + .with_timeout(50), + ), + ) + .map_err(|err| alloc::format!("UART0 config error: {err}"))? + .with_rx(gpio12) + .with_tx(gpio11); + + if let Some(char) = cmd_char { + uart.set_at_cmd( + esp_hal::uart::AtCmdConfig::default() + .with_cmd_char(char) + .with_char_num(1) + .with_pre_idle_count(0) // command start can be right after other data + .with_post_idle_count(0) // we don't expect a gap between the command start and subsequent text + .with_gap_timeout(5), // needs to be >0 to work + ); + } + uart.set_interrupt_handler(handler); + uart.listen( + esp_hal::uart::UartInterrupt::AtCmd + | esp_hal::uart::UartInterrupt::RxTimeout + | esp_hal::uart::UartInterrupt::RxFifoFull, // just to be save + ); + + critical_section::with(|cs| SERIAL.borrow_ref_mut(cs).replace(uart)); + + Ok(()) +} + +#[cfg(feature = "_uart")] +#[esp_hal::handler] +fn serial_handler() { + critical_section::with(|cs| { + let mut serial = SERIAL.borrow_ref_mut(cs); + let mut uart_buf = UART_RX_BUF.borrow_ref_mut(cs); + + if let (Some(serial), Some(uart_buf)) = (serial.as_mut(), uart_buf.as_mut()) { + let mut buf = [0u8; SERIAL_FIFO_SIZE_THLD as usize]; + + match serial.read_buffered(&mut buf) { + Ok(0) => {} + Ok(len) => { + uart_buf.extend_from_slice(&buf[..len]); + } + Err(err) => error!("Failed to read from UART: {err}"), + } + + serial.clear_interrupts( + esp_hal::uart::UartInterrupt::RxFifoFull + | esp_hal::uart::UartInterrupt::RxTimeout + | esp_hal::uart::UartInterrupt::AtCmd, + ); + } + }); +} + +#[cfg(feature = "_uart")] +fn send_uart0(data: &[u8]) { + critical_section::with(|cs| { + let mut serial = SERIAL.borrow_ref_mut(cs); + + if let Some(serial) = serial.as_mut() + && let Err(err) = serial.write(data) + { + error!("Failed to write to UART: {err:?}"); + } + }); +} From ba71e718dfcd99e96ad441bf2347b8344f8f8432 Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Mon, 27 Jul 2026 11:14:14 +0200 Subject: [PATCH 43/49] proto/PositionState: Add time for geonetworking --- docs/c_its-messages.proto | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/docs/c_its-messages.proto b/docs/c_its-messages.proto index 9848910..af8f802 100644 --- a/docs/c_its-messages.proto +++ b/docs/c_its-messages.proto @@ -35,18 +35,17 @@ enum GNAreaShape { ETSI_AREASHAPE_CIRCLE = 0; ETSI_AREASHAPE_RECTANGLE = 1; ETSI_AREASHAPE_ELLIPSIS = 2; - ETSI_AREASHAPE_UNKNOWN = 15; } // GeoNetworking Destination Area/ Source Position message ITSPosition { - optional int32 latitude = 1; // Latitude in 1/10th of micro degrees - optional int32 longitude = 2; // Longitude in 1/10th of micro degrees + required int32 latitude = 1; // Latitude in 1/10th of micro degrees + required int32 longitude = 2; // Longitude in 1/10th of micro degrees } // GeoNetworking Destination Area message GNArea { - optional GNAreaShape area = 1 [default = ETSI_AREASHAPE_UNKNOWN]; + optional GNAreaShape area = 1; optional ITSPosition position = 2; optional int32 distA = 3; // Distance A of the geometric shape in meters optional int32 distB = 4; // Distance B of the geometric shape in meters, mandatory for rectangle and ellipsis @@ -55,8 +54,6 @@ message GNArea { // GeoNetworking Transport enum GNTransport { - GN_TRANSPORT_DEFAULT = 0; - GN_TRANSPORT_GUC = 2; // GeoUnicast GN_TRANSPORT_GAC = 3; // Geographically-Scoped Anycast GN_TRANSPORT_GBC = 4; // Geographically-Scoped broadcast GN_TRANSPORT_TSB = 5; // Topologically-scoped broadcast @@ -79,7 +76,8 @@ message RawMsgTx { // ------------------------------ message PositionState { - required Position position = 1; + required sfixed64 timestamp_ms = 1; // current UNIX time in milliseconds + required Position position = 2; optional uint32 heading = 3; // heading in 1/10 deg optional uint32 speed = 4; // speed in 1/100 m/s } From 4d7bef58a165eb2f3a80a4d702034279ff3d50a4 Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Mon, 20 Jul 2026 11:58:44 +0200 Subject: [PATCH 44/49] Implement UART I/O protocol --- Cargo.toml | 10 +- Readme.md | 7 +- feat-permut.sh | 4 +- src/applogic/cam_tx.rs | 17 +- src/applogic/mod.rs | 5 + src/io.rs | 464 +++++++++++++++++++++++++++++++++++++++++ src/main.rs | 114 ++++++++-- src/test.rs | 1 + src/testdata.rs | 8 + src/v2x_tx/gn.rs | 2 +- src/v2x_tx/mod.rs | 18 +- 11 files changed, 612 insertions(+), 38 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index bb70ea2..eef57d9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,11 +34,11 @@ esp = [ std = ["c-its-parser/std"] # enable L67K GNSS module -gnss = ["dep:embassy_gps"] +gnss = ["_gnss", "dep:embassy_gps"] # echo CAM data on serial cam = ["c-its-parser/cam"] # send CAM autonomously -cam_tx = ["c-its-parser/cam", "gnss"] +cam_tx = ["c-its-parser/cam"] # echo DENM events on serial and BLE denm = ["c-its-parser/denm"] # evaluate SPAT information @@ -47,9 +47,13 @@ spat = ["c-its-parser/spatem", "c-its-parser/mapem"] spat_debug = [] # enable ST7789 based display screen = ["dep:mipidsi", "dep:embedded-graphics", "dep:embedded-hal-bus", "dep:profont"] +# enable I/O via UART +uart = ["_uart", "_gnss", "dep:prost"] # internal feature, that any UART I/O is enabled _uart = [] +# internal feature, that any GNSS source is enabled +_gnss = [] [dependencies] esp-hal = { version = "~1.1.0", optional = true, features = ["esp32c5", "log-04", "unstable"] } @@ -102,7 +106,7 @@ geo-types = { version = "0.7.19", default-features = false } mipidsi = { version = "0.10.0", optional = true } num-traits = { version = "0.2.19", default-features = false, features = ["libm"] } profont = { version = "0.7.0", optional = true } -prost = { version = "0.14", default-features = false, features = ["derive"] } +prost = { version = "0.14", optional = true, default-features = false, features = ["derive"] } [dev-dependencies] assert_float_eq = "1.2.0" diff --git a/Readme.md b/Readme.md index 281f995..c8dcf5c 100644 --- a/Readme.md +++ b/Readme.md @@ -32,6 +32,9 @@ Just prints a line for each received CAM. Generates and transmits a CAM when a GNSS update was received. Depends on the `gnss` feature. +### UART +Enable UART I/O protocol according to [docs/uart-protocol.md](./docs/uart-protocol.md). + ## Usage @@ -64,13 +67,13 @@ Additional features are available through feature flags: - `spat_debug`: Enable additional debug information for the SPAT/ GLOSA use case - `denm`: Enable DENMs - `cam`: Enable CAMs -- `cam_tx`: Enable CAM generation and transmission +- `cam_tx`: Enable CAM generation and transmission (needs time and position source) Officially supported feature combinations are: - `esp,spat,gnss,screen` (default features) - `esp,spat,gnss,screen,cam_tx` - `esp,spat,gnss,screen,cam_tx,cam,denm` -- To be implemented: No GNSS and screen when BLE/ UART interface is implemented +- `esp,uart,cam,denm` Connect your ESP32-C5 and run: diff --git a/feat-permut.sh b/feat-permut.sh index 6ddd438..2019bef 100755 --- a/feat-permut.sh +++ b/feat-permut.sh @@ -8,7 +8,7 @@ extended=${2:-false} features_base=( - #cam,denm,uart + uart,cam,denm spat,gnss,screen spat,gnss,screen,cam_tx ) @@ -17,7 +17,7 @@ features_more=( spat,gnss,screen,cam_tx,cam,denm ) features_test=( - spat,cam,denm + spat,cam,denm,uart ) diff --git a/src/applogic/cam_tx.rs b/src/applogic/cam_tx.rs index 0cd0a16..e15de27 100644 --- a/src/applogic/cam_tx.rs +++ b/src/applogic/cam_tx.rs @@ -300,20 +300,23 @@ impl crate::v2x_tx::GnItsPayload for CamState { own_position: &crate::applogic::PositionState, time: chrono::DateTime, _seq_no: &mut u16, - ) -> ( - geonetworking::en302636_4_1::ExtendedHeader, - Option, - u8, - ) { + ) -> Result< + ( + geonetworking::en302636_4_1::ExtendedHeader, + Option, + u8, + ), + alloc::string::String, + > { let station_type = geonetworking::en302636_4_1::StationType::try_from(self.station_type.0) .unwrap_or_default(); // CAM always uses SHB, hop-limit 1 - ( + Ok(( crate::v2x_tx::gn::make_shb_eh(address, station_type, own_position, time), None, 1, - ) + )) } fn make_payload( diff --git a/src/applogic/mod.rs b/src/applogic/mod.rs index 32ee4f6..7847010 100644 --- a/src/applogic/mod.rs +++ b/src/applogic/mod.rs @@ -639,6 +639,11 @@ impl State { pub fn time(&self) -> chrono::prelude::NaiveDateTime { self.time } + + #[allow(unused)] + pub fn pos(&self) -> &PositionState { + &self.pos + } } #[cfg(test)] diff --git a/src/io.rs b/src/io.rs index cb23055..4642253 100644 --- a/src/io.rs +++ b/src/io.rs @@ -5,6 +5,12 @@ pub mod msg { include!(concat!(env!("OUT_DIR"), "/c_its_io.rs")); } +use alloc::string::ToString; +use alloc::vec::Vec; + +use c_its_parser::gn as geonetworking; +use log::error; + impl From for msg::SerialOutputMsg { fn from(value: msg::RawMsgRx) -> Self { use msg::serial_output_msg::Payload; @@ -25,6 +31,144 @@ impl From for msg::SerialOutputMsg { } } +impl msg::RawMsgRx { + pub fn serialize_uart_proto(&self) -> Vec { + serialize_serial_msg(&Into::::into(self.clone())) + } +} +impl msg::DenmEvent { + #[allow(unused)] + pub fn serialize_uart_proto(&self) -> Vec { + serialize_serial_msg(&Into::::into(*self)) + } +} + +#[derive(Debug, Default)] +pub struct Parser { + buffer: alloc::vec::Vec, +} + +impl Parser { + const SOF_LEN: u16 = 4; + const SOF1: u8 = 0x56; + const SOF2: u8 = 0x32; + const SOF3: u8 = 0x58; + const SOF4: u8 = 0x2B; + const EOF_LEN: u16 = 2; + #[allow(unused)] + const EOF1: u8 = 0x0d; + #[allow(unused)] + const EOF2: u8 = 0x0a; + const LENGTH_LEN: u16 = 2; + + /// Parses incoming data and return list of messages + pub fn parse(&mut self, input: &[u8]) -> alloc::vec::Vec { + use prost::Message as _; + + // combine with existing data + self.buffer.append(&mut input.to_vec()); + + // find start of frame and parse + let mut output = alloc::vec![]; + while let Some(pos) = Self::find_sof(&self.buffer) { + // drop everything before the SOF + self.buffer.drain(..pos); + + // get length field and check buffer size + if let Some(len_bytes) = self + .buffer + .get(usize::from(Self::SOF_LEN)..usize::from(Self::SOF_LEN + Self::LENGTH_LEN)) + { + // unwrap is fine since we queried 2 bytes before, so we can also convert to 2-byte slice + let msg_size = u16::from_be_bytes(len_bytes.try_into().unwrap()); + + let required_buf_size = Self::SOF_LEN + Self::LENGTH_LEN + msg_size + Self::EOF_LEN; + if let Some(payload) = self.buffer.get( + usize::from(Self::SOF_LEN + Self::LENGTH_LEN) + ..usize::from(required_buf_size - 2), + ) { + match msg::SerialInputMsg::decode(payload) { + Ok(data) => { + output.push(data); + } + Err(err) => error!("Failed to parse input: {err:?}"), + } + + // consume buffer + self.buffer.drain(..usize::from(required_buf_size)); + } else { + break; + } + } + } + + output + } + + /// Returns the start index of the SOF sequence, if present + fn find_sof(input: &[u8]) -> Option { + // search for SOF + if let Some(sof1_pos) = input.iter().position(|i| *i == Self::SOF1) { + // drop everything before sof1_pos and check if remainder continues with SOF sequence + let (_, msg) = input.split_at(sof1_pos); + if msg.starts_with(&[Self::SOF1, Self::SOF2, Self::SOF3, Self::SOF4]) { + // found the SOF sequence + return Some(sof1_pos); + } + } + + // if we reach this position, SOF sequence wasn't found + None + } +} + +fn serialize_serial_msg(msg: &T) -> Vec +where + T: prost::Message, +{ + let mut msg_buf = msg.encode_to_vec(); + + let sof = [0x56u8, 0x32, 0x58, 0x2B]; + let eof = [0x0du8, 0x0a]; + #[allow(clippy::cast_possible_truncation)] + let len: [u8; 2] = (msg_buf.len() as u16).to_be_bytes(); + + let mut out = sof.to_vec(); + out.extend_from_slice(&len); + out.append(&mut msg_buf); + out.extend_from_slice(&eof); + + out +} + +impl From for crate::applogic::GnssFix { + fn from(value: msg::PositionState) -> Self { + let time = + chrono::DateTime::from_timestamp_millis(value.timestamp_ms).map(|v| v.naive_utc()); + + let latitude_deg = value.position.latitude_deg.into(); + let longitude_deg = value.position.longitude_deg.into(); + let heading_deg = value.heading.map(|v| { + #[allow(clippy::cast_precision_loss)] + let val = (v as f32) / 10.; + val + }); + let speed_mps = value.speed.map(|v| { + #[allow(clippy::cast_precision_loss)] + let val = (v as f32) / 100.; + val + }); + + Self { + time, + latitude_deg, + longitude_deg, + heading_deg, + speed_mps, + } + } +} + impl From for crate::applogic::PositionState { fn from(value: msg::PositionState) -> Self { let position = geo_types::Point::new( @@ -49,3 +193,323 @@ impl From for crate::applogic::PositionState { } } } + +impl From for crate::applogic::PositionState { + fn from(value: msg::ItsPosition) -> Self { + let latitude_deg = f64::from(value.latitude) / 10_000_000.; + let longitude_deg = f64::from(value.longitude) / 10_000_000.; + let position = geo_types::Point::new(longitude_deg, latitude_deg); + + Self { + position, + heading_deg: None, + speed_mps: None, + } + } +} + +impl crate::v2x_tx::GnItsPayload for msg::RawMsgTx { + fn make_eh( + &self, + address: [u8; 6], + own_position: &crate::applogic::PositionState, + time: chrono::DateTime, + seq_no: &mut u16, + ) -> Result< + ( + c_its_parser::gn::en302636_4_1::ExtendedHeader, + Option, + u8, + ), + alloc::string::String, + > { + let station_type = self + .station_type + .and_then(|v| { + #[allow(clippy::cast_possible_truncation)] + let raw_val = v as u8; + geonetworking::en302636_4_1::StationType::try_from(raw_val).ok() + }) + .unwrap_or_default(); + #[allow(clippy::cast_possible_truncation)] + let hop_limit = self.hop_limit as u8; + + Ok(match self.gn_transport() { + msg::GnTransport::Gac => { + if self.gn_area.position.is_none() { + return Err("Failed to create GAC: gnArea.position missing".to_string()); + } + + *seq_no += 1; + + let (atype, geoanycast) = Self::make_geobcast( + address, + station_type, + own_position, + time, + self.gn_area, + *seq_no, + )?; + let eh = geonetworking::en302636_4_1::ExtendedHeader::GAC(geoanycast); + (eh, Some(atype), hop_limit) + } + msg::GnTransport::Gbc => { + if self.gn_area.position.is_none() { + return Err("Failed to create GAC: gnArea.position missing".to_string()); + } + + *seq_no += 1; + + let (atype, geoanycast) = Self::make_geobcast( + address, + station_type, + own_position, + time, + self.gn_area, + *seq_no, + )?; + let eh = geonetworking::en302636_4_1::ExtendedHeader::GBC(geoanycast); + (eh, Some(atype), hop_limit) + } + msg::GnTransport::Tsb => { + *seq_no += 1; + + ( + crate::v2x_tx::gn::make_tsb_eh( + address, + station_type, + own_position, + time, + *seq_no, + ), + None, + hop_limit, + ) + } + msg::GnTransport::Shb => ( + crate::v2x_tx::gn::make_shb_eh(address, station_type, own_position, time), + None, + 1, + ), + }) + } + + fn make_payload( + &self, + ) -> Result<(Vec, c_its_parser::standards::extensions::ItsMessageId), alloc::string::String> + { + #[allow(clippy::cast_possible_truncation)] + let message_id = (self.message_type as u8).try_into()?; + Ok((self.payload.clone(), message_id)) + } +} + +impl msg::RawMsgTx { + fn make_geobcast( + address: [u8; 6], + station_type: geonetworking::en302636_4_1::StationType, + own_position: &crate::applogic::PositionState, + time: chrono::DateTime, + gn_area: msg::GnArea, + sequence_number: u16, + ) -> Result< + ( + c_its_parser::gn::en302636_4_1::AreaType, + geonetworking::en302636_4_1::GeoBroadcast, + ), + alloc::string::String, + > { + let source_position_vector = + crate::v2x_tx::gn::make_lpv(address, station_type, own_position, time); + + let (atype, dist_a, dist_b, angle) = match gn_area.area() { + msg::GnAreaShape::EtsiAreashapeCircle => ( + c_its_parser::gn::en302636_4_1::AreaType::Circular, + gn_area.dist_a, + None, + None, + ), + msg::GnAreaShape::EtsiAreashapeRectangle => ( + c_its_parser::gn::en302636_4_1::AreaType::Rectangular, + gn_area.dist_a, + gn_area.dist_b, + gn_area.angle, + ), + msg::GnAreaShape::EtsiAreashapeEllipsis => ( + c_its_parser::gn::en302636_4_1::AreaType::Ellipsoidal, + gn_area.dist_a, + gn_area.dist_b, + gn_area.angle, + ), + }; + + #[allow(clippy::cast_possible_truncation)] + let latitude_deg = own_position.position.y() as f32; + #[allow(clippy::cast_possible_truncation)] + let longitude_deg = own_position.position.x() as f32; + let distance_a = dist_a + .map(|v| { + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + let val = v as u16; + val + }) + .unwrap_or_default(); + let distance_b = dist_b + .map(|v| { + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + let val = v as u16; + val + }) + .unwrap_or_default(); + let angle = angle + .map(|v| { + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + let val = v as u16; + val + }) + .unwrap_or_default(); + + let gac = geonetworking::en302636_4_1::GeoAnycast::try_from_values( + sequence_number, + source_position_vector, + latitude_deg, + longitude_deg, + distance_a, + distance_b, + angle, + ) + .map_err(|err| alloc::format!("{err}"))?; + + Ok((atype, gac)) + } +} + +#[cfg(test)] +mod tests { + + use super::*; + use crate::init_test_env_logger; + + impl From for msg::SerialInputMsg { + fn from(value: msg::RawMsgTx) -> Self { + use msg::serial_input_msg::Payload; + + Self { + payload: Some(Payload::TxMsg(value)), + } + } + } + + impl From for msg::SerialInputMsg { + fn from(value: msg::PositionState) -> Self { + use msg::serial_input_msg::Payload; + + Self { + payload: Some(Payload::Position(value)), + } + } + } + + // manual example data: + // SerialInputMsg(RawMsgTx): 5632582b0040123e0a340202c173256817284029d5b3400f1feaf01ffffffc23b7743e00000fc0007e8138250737feebfff600000dffff7ffff1ce40400010011807220028020d0a + // SerialInputMsg(PositionState): 5632582b00170a1509a0bbe2a29f010000120a0d00002842150000b8410d0a + + #[test] + fn uart_parser() { + // generate some test UART input messages + let raw_tx = msg::RawMsgTx { + payload: crate::testdata::CAM_SMALL.to_vec(), + message_type: 1, + gn_transport: msg::GnTransport::Shb.into(), + gn_area: msg::GnArea { + area: None, + position: None, + dist_a: None, + dist_b: None, + angle: None, + }, + hop_limit: 2, + station_type: None, + }; + let raw_tx_buf = serialize_serial_msg::(&raw_tx.clone().into()); + + let pos_state = msg::PositionState { + timestamp_ms: 1785144196000, + position: msg::Position { + latitude_deg: 42., + longitude_deg: 23., + }, + heading: None, + speed: None, + }; + let pos_state_buf = serialize_serial_msg::(&pos_state.clone().into()); + + let mut parser = Parser::default(); + + // feed individual complete messages to parser + let output = parser.parse(&raw_tx_buf); + assert_eq!(1, output.len()); + assert_eq!( + &msg::SerialInputMsg::from(raw_tx.clone()), + output.get(0).unwrap() + ); + + let output = parser.parse(&pos_state_buf); + assert_eq!(1, output.len()); + assert_eq!( + &msg::SerialInputMsg::from(pos_state.clone()), + output.get(0).unwrap() + ); + + // feed multiple complete messages to parser + let mut combined_buf = pos_state_buf.clone(); + combined_buf.extend_from_slice(&raw_tx_buf); + + let output = parser.parse(&combined_buf); + assert_eq!(2, output.len()); + assert_eq!( + &msg::SerialInputMsg::from(pos_state.clone()), + output.get(0).unwrap() + ); + assert_eq!( + &msg::SerialInputMsg::from(raw_tx.clone()), + output.get(1).unwrap() + ); + + // feed partial messages to parser + let buf1 = &raw_tx_buf[0..3]; + let buf2 = &raw_tx_buf[3..]; + + let output = parser.parse(buf1); + assert_eq!(0, output.len()); + + let output = parser.parse(buf2); + assert_eq!(1, output.len()); + assert_eq!( + &msg::SerialInputMsg::from(raw_tx.clone()), + output.get(0).unwrap() + ); + + // feed messages with garbage in between + let mut buf1 = vec![0xabu8, 0xcd]; // prepend some garbage + buf1.extend_from_slice(&raw_tx_buf[0..3]); + let mut buf2 = (&raw_tx_buf[3..]).to_vec(); + buf2.extend_from_slice(&[0xfeu8, 0x42]); // append some garbage + buf2.extend_from_slice(&pos_state_buf); // append second message + buf2.extend_from_slice(&[0x11u8, 0x22]); // append some more garbage + + let output = parser.parse(&buf1); + assert_eq!(0, output.len()); + + let output = parser.parse(&buf2); + assert_eq!(2, output.len()); + assert_eq!( + &msg::SerialInputMsg::from(raw_tx.clone()), + output.get(0).unwrap() + ); + assert_eq!( + &msg::SerialInputMsg::from(pos_state.clone()), + output.get(1).unwrap() + ); + } +} diff --git a/src/main.rs b/src/main.rs index 03ef304..772adb6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,6 +7,13 @@ )] // #![deny(clippy::large_stack_frames)] +// we can't use UART I/O and GNSS at the same time +#[cfg(all(feature = "uart", feature = "gnss"))] +compile_error!("feature \"uart\" and feature \"gnss\" cannot be enabled at the same time"); +// autonomous CAM generation need a time and position source (aka _gnss) +#[cfg(all(feature = "cam_tx", not(feature = "_gnss")))] +compile_error!("feature \"cam_tx\" needs a time and position source (either GNSS or UART)"); + use alloc::string::ToString as _; use alloc::vec::Vec; use core::cell::RefCell; @@ -35,17 +42,18 @@ extern crate alloc; mod applogic; mod cache; mod geo_alg; +#[cfg(feature = "uart")] mod io; mod radio; #[cfg(feature = "screen")] mod screen; -#[cfg(feature = "cam_tx")] +#[cfg(any(feature = "cam_tx", feature = "uart"))] mod v2x_tx; const WIFI_CHANNEL: radio::Channel = 180; #[cfg(feature = "spat")] const SPAT_RATE_LIMIT: u8 = 5; // keep every n-th message -#[cfg(feature = "cam_tx")] +#[cfg(any(feature = "cam_tx", feature = "uart"))] const GN_IS_MOBILE: bool = true; #[cfg(feature = "cam_tx")] const OWN_STATION_TYPE: ItsStationType = ItsStationType::Cyclist; @@ -60,7 +68,7 @@ const SERIAL_FIFO_SIZE_THLD: u8 = 100; // hardware has max. 128 byte // For more information see: esp_bootloader_esp_idf::esp_app_desc!(); -#[cfg(feature = "gnss")] +#[cfg(feature = "_gnss")] static GNSS_UPDATE: Mutex>> = Mutex::new(RefCell::new(None)); static WIFI_RX_QUEUE: Mutex>>>> = Mutex::new(RefCell::new(None)); #[cfg(feature = "_uart")] @@ -136,6 +144,18 @@ async fn main(spawner: Spawner) -> ! { screen }; + // Setup UART I/O channel + #[cfg(feature = "uart")] + setup_uart0( + 115_200, + Some(b'\n'), + peripherals.GPIO12, + peripherals.GPIO11, + peripherals.UART0, + serial_handler, + ) + .expect("Failed to initialize UART"); + // Setup WiFi // sadly, it doesn't work any more when we move `esp_radio::wifi::new` to `setup_wifi_sniffer` let (mut wifi_controller, interfaces) = esp_radio::wifi::new( @@ -178,24 +198,29 @@ async fn main(spawner: Spawner) -> ! { #[cfg(feature = "gnss")] info!("GNSS task started, waiting for GNSS fix..."); - let mut state = applogic::State::new(); - #[cfg(feature = "cam_tx")] - let (mut seq_no, mut cam, mac_bytes) = { - let station_id = v2x_tx::make_station_id(mac); - info!("V2X Station ID: {station_id}"); - + #[cfg(any(feature = "cam_tx", feature = "uart"))] + let (mut seq_no, mac_bytes) = { // unwrap is fine since [`esp_hal::efuse::MacAddress`] is just an `[u8; 6]` let mac_bytes = mac.as_bytes().try_into().unwrap(); - let cam = applogic::cam_tx::CamState::new( + (0, mac_bytes) + }; + + let mut state = applogic::State::new(); + #[cfg(feature = "cam_tx")] + let mut cam = { + let station_id = v2x_tx::make_station_id(mac); + info!("V2X Station ID: {station_id}"); + + applogic::cam_tx::CamState::new( station_id, OWN_STATION_TYPE, OWN_VEHICLE_LENGTH, OWN_VEHICLE_WIDTH, - ); - - (0, cam, mac_bytes) + ) }; + #[cfg(feature = "uart")] + let mut uart_input = io::Parser::default(); loop { embassy_time::Timer::after(embassy_time::Duration::from_millis(1)).await; @@ -214,11 +239,56 @@ async fn main(spawner: Spawner) -> ! { }); if let Some(data) = new_data { - warn!("TODO: New UART data: {data:x?}"); + #[cfg(feature = "uart")] + { + for msg in uart_input.parse(&data) { + match msg.payload { + Some(io::msg::serial_input_msg::Payload::Position(fix)) => { + let pos_state = fix.into(); + + critical_section::with(|cs| { + let gnss_update_ref = GNSS_UPDATE.borrow(cs); + gnss_update_ref.replace(Some(pos_state)); + }); + } + Some(io::msg::serial_input_msg::Payload::TxMsg(tx_request)) => { + use v2x_tx::GnItsPayload as _; + + info!( + "UART: Got new TX message: Type {} with {} bytes", + tx_request.message_type, + tx_request.payload.len() + ); + let own_position = state.pos(); + let time = state.time().and_utc(); + + match tx_request.encode_packet( + mac_bytes, + own_position, + time, + GN_IS_MOBILE, + &mut seq_no, + ) { + Ok(data) => { + if let Err(err) = radio::send_80211_bcast_frame( + &mut wlan_iface, + &mac_bytes.clone(), + &data, + ) { + warn!("Failed to send 802.11 frame: {err}"); + } + } + Err(err) => warn!("Failed to create GN message: {err}"), + } + } + None => {} + } + } + } } } - #[cfg(feature = "gnss")] + #[cfg(feature = "_gnss")] { let mut new_position = false; critical_section::with(|cs| { @@ -245,7 +315,7 @@ async fn main(spawner: Spawner) -> ! { Ok(data) => { if let Err(err) = radio::send_80211_bcast_frame( &mut wlan_iface, - mac.as_bytes(), + &mac_bytes.clone(), &data, ) { warn!("Failed to send 802.11 frame: {err}"); @@ -472,6 +542,18 @@ fn handle_frame(frame: wifi::sniffer::PromiscuousPkt<'_>) { error!("V2X RX queue is full"); } }); + + // Send data to UART + #[cfg(feature = "uart")] + { + let message = io::msg::RawMsgRx { + msg_type: message_id.as_u8().into(), + payload: frame.data.to_vec(), + } + .serialize_uart_proto(); + + send_uart0(&message); + } } } } diff --git a/src/test.rs b/src/test.rs index 7de1bf8..6bb20d2 100644 --- a/src/test.rs +++ b/src/test.rs @@ -6,6 +6,7 @@ extern crate alloc; mod applogic; mod cache; mod geo_alg; +mod io; mod radio; mod testdata; mod v2x_tx; diff --git a/src/testdata.rs b/src/testdata.rs index 567410d..fa884d6 100644 --- a/src/testdata.rs +++ b/src/testdata.rs @@ -455,3 +455,11 @@ pub const CAM: &[u8] = &[ 0x00, 0x06, 0xc6, 0x70, 0x00, 0xdf, 0x80, 0x8d, 0x5f, 0xde, 0x66, 0x27, 0x00, 0x07, 0x3c, 0x04, 0x76, 0xfd, 0x67, 0x31, 0x9c, 0x00, 0x58, 0x60, 0x41, 0x37, 0xd3, 0x15, 0x89, 0xc0, 0x06, 0xdc, ]; + +#[cfg(any(feature = "cam_tx", feature = "uart"))] +pub const CAM_SMALL: &[u8] = &[ + 0x2, 0x2, 0xc1, 0x73, 0x25, 0x68, 0x17, 0x28, 0x40, 0x29, 0xd5, 0xb3, 0x40, 0xf, 0x1f, 0xea, + 0xf0, 0x1f, 0xff, 0xff, 0xfc, 0x23, 0xb7, 0x74, 0x3e, 0x0, 0x0, 0xf, 0xc0, 0x0, 0x7e, 0x81, + 0x38, 0x25, 0x7, 0x37, 0xfe, 0xeb, 0xff, 0xf6, 0x0, 0x0, 0xd, 0xff, 0xff, 0x7f, 0xff, 0xf1, + 0xce, 0x40, 0x40, 0x0, +]; diff --git a/src/v2x_tx/gn.rs b/src/v2x_tx/gn.rs index 828c74d..4b2b0b9 100644 --- a/src/v2x_tx/gn.rs +++ b/src/v2x_tx/gn.rs @@ -67,7 +67,7 @@ pub fn make_gbc_eh( ) } -fn make_lpv( +pub fn make_lpv( address: [u8; 6], station_type: geonetworking::en302636_4_1::StationType, own_position: &crate::applogic::PositionState, diff --git a/src/v2x_tx/mod.rs b/src/v2x_tx/mod.rs index e9b1a28..ee48578 100644 --- a/src/v2x_tx/mod.rs +++ b/src/v2x_tx/mod.rs @@ -6,7 +6,7 @@ use c_its_parser::gn::{self as geonetworking, Encode}; pub mod gn; -#[cfg(feature = "esp")] +#[cfg(all(feature = "esp", feature = "cam_tx"))] /// Creates a station ID from the WLAN MAC address /// /// Will use `0xC173` as static prefix and uses last 16 bit from MAC address for the remainder. @@ -40,11 +40,14 @@ pub trait GnItsPayload { own_position: &crate::applogic::PositionState, time: chrono::DateTime, seq_no: &mut u16, - ) -> ( - geonetworking::en302636_4_1::ExtendedHeader, - Option, - u8, - ); + ) -> Result< + ( + geonetworking::en302636_4_1::ExtendedHeader, + Option, + u8, + ), + alloc::string::String, + >; /// Creates the ITS payload (without BTP header) fn make_payload( @@ -74,7 +77,8 @@ pub trait GnItsPayload { let payload_length = (payload.len()) as u16; // get extended header (depending on message type and sometimes content as well) - let (eh, area_type, maximum_hop_limit) = self.make_eh(address, own_position, time, seq_no); + let (eh, area_type, maximum_hop_limit) = + self.make_eh(address, own_position, time, seq_no)?; // Build GN headers let traffic_class = geonetworking::en302636_4_1::TrafficClass::try_new( From 2c50668e7e74ed67d182440022407466963b7d25 Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Fri, 31 Jul 2026 12:04:46 +0200 Subject: [PATCH 45/49] 802.11-TX: Reduce transmit power, add notes about legal requirements --- Readme.md | 19 +++++++++++++++++++ src/main.rs | 5 +++++ 2 files changed, 24 insertions(+) diff --git a/Readme.md b/Readme.md index c8dcf5c..1dda26f 100644 --- a/Readme.md +++ b/Readme.md @@ -32,6 +32,25 @@ Just prints a line for each received CAM. Generates and transmits a CAM when a GNSS update was received. Depends on the `gnss` feature. +Note: In germany, using the 5875 to 5915 MHz frequency band is only permitted for C-ITS use cases which implement transmit power reduction measures with at least 30 dB reduction range (or equivalent measures) and have a maximum EIRP of 33 dBm. +This is based on EU regulation 2014/53/EU, so it applies to other EU countries in similar ways. +The relevant harmonized standard is ETSI EN 302 571 which mandates (excerpt of most relevant points): + +- a maximum power spectral density of 23 dBm/Mhz EIRP +- transmit power control (TCP) in a range from 3 dBm up to the maximum output power of the equipment + * used for CEN DSRC and HDR DSRC interference mitigation by implementing measures from ETSI TS 102 792 + + Mode A or B: Output power less than 10 dBm EIRP and less than -65 or -45 dBm/MHz EIRP in CEN DSRC band -> **needs to be measured** + + Mode C: Maximum transmission time of less than 1 ms and at least a few 100 ms between transmission (realistically) -> **can't be ensured with big messages** + + Mode D: "any" realistically possible transmission time, but at least 500 ms between transmissions for ~5 ITS stations/ 1000ms for 10 ITS stations -> **safe to assume when we only send CAMs at 1 Hz** + + But modes C and D also need to: + - use protected zone information from CAM messages + - use a protected zone database, e.g. from www.asecap-pzdb.com + * used for dynamic congestion control (DCC) + + mandatory mechanism described in ETSI EN 302 571, chapter 4.2.10 + +This is **not** implemented in this application! +**So legally, you're not allowed to send with this.** + ### UART Enable UART I/O protocol according to [docs/uart-protocol.md](./docs/uart-protocol.md). diff --git a/src/main.rs b/src/main.rs index 772adb6..64ed528 100644 --- a/src/main.rs +++ b/src/main.rs @@ -51,6 +51,7 @@ mod screen; mod v2x_tx; const WIFI_CHANNEL: radio::Channel = 180; +const WIFI_TXPOWER: i8 = 10; // dBm, 2..20 #[cfg(feature = "spat")] const SPAT_RATE_LIMIT: u8 = 5; // keep every n-th message #[cfg(any(feature = "cam_tx", feature = "uart"))] @@ -163,9 +164,13 @@ async fn main(spawner: Spawner) -> ! { esp_radio::wifi::ControllerConfig::default(), ) .expect("Failed to initialize Wi-Fi controller"); + if let Err(err) = wifi_controller.set_band_mode(esp_radio::wifi::BandMode::_5G) { error!("Failed to set WiFi to 5GHz only: {err}"); } + if let Err(err) = wifi_controller.set_max_tx_power(WIFI_TXPOWER * 4) { + error!("Failed to set WiFi TX power: {err}"); + } critical_section::with(|cs| { WIFI_RX_QUEUE.borrow(cs).replace(Some(ArrayQueue::new(2))); From 71161d1d991a49c81ecff9b0d0cfeb5034aaac0d Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Fri, 31 Jul 2026 12:13:54 +0200 Subject: [PATCH 46/49] Add scripts to run/ flash the supported feature-sets --- Readme.md | 6 +++--- run-smartphone.sh | 6 ++++++ run-standalone-cam.sh | 6 ++++++ run-standalone-no_cam.sh | 6 ++++++ 4 files changed, 21 insertions(+), 3 deletions(-) create mode 100755 run-smartphone.sh create mode 100755 run-standalone-cam.sh create mode 100755 run-standalone-no_cam.sh diff --git a/Readme.md b/Readme.md index 1dda26f..ace338e 100644 --- a/Readme.md +++ b/Readme.md @@ -89,10 +89,10 @@ Additional features are available through feature flags: - `cam_tx`: Enable CAM generation and transmission (needs time and position source) Officially supported feature combinations are: -- `esp,spat,gnss,screen` (default features) -- `esp,spat,gnss,screen,cam_tx` +- `esp,spat,gnss,screen` (default features, "standalone") +- `esp,spat,gnss,screen,cam_tx` ("standalone with CAM") - `esp,spat,gnss,screen,cam_tx,cam,denm` -- `esp,uart,cam,denm` +- `esp,uart,cam,denm` ("smartphone companion") Connect your ESP32-C5 and run: diff --git a/run-smartphone.sh b/run-smartphone.sh new file mode 100755 index 0000000..ee5170d --- /dev/null +++ b/run-smartphone.sh @@ -0,0 +1,6 @@ +#!/bin/sh + +## Builds common config for smartphone connected device +## -> only receive CAM and DENMs via UART (TODO: and BLE) + +cargo run --release --no-default-features -F esp,cam,denm,uart diff --git a/run-standalone-cam.sh b/run-standalone-cam.sh new file mode 100755 index 0000000..9bfafbe --- /dev/null +++ b/run-standalone-cam.sh @@ -0,0 +1,6 @@ +#!/bin/sh + +## Builds common config for standalone device with automatic CAM transmission +## -> enable SPAT/ GLOSA with GNSS and a screen + CAM transmission + +cargo run --release -F cam_tx diff --git a/run-standalone-no_cam.sh b/run-standalone-no_cam.sh new file mode 100755 index 0000000..e61d25c --- /dev/null +++ b/run-standalone-no_cam.sh @@ -0,0 +1,6 @@ +#!/bin/sh + +## Builds common config for standalone device +## -> enable SPAT/ GLOSA with GNSS and a screen + +cargo run --release From ac6a3e398ecc091e603324887cdc89d4264113ff Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Sun, 12 Jul 2026 00:14:29 +0200 Subject: [PATCH 47/49] Hardware: Add PCB layout --- .gitignore | 4 + hardware/c_its-companion-v0.1-back.jpg | Bin 0 -> 148719 bytes hardware/c_its-companion-v0.1-front.jpg | Bin 0 -> 145854 bytes .../c_its-companion/c_its-companion.kicad_pcb | 8034 +++++++++++ .../c_its-companion/c_its-companion.kicad_prl | 138 + .../c_its-companion/c_its-companion.kicad_pro | 744 + .../c_its-companion/c_its-companion.kicad_sch | 4985 +++++++ .../fabrication-toolkit-options.json | 1 + hardware/c_its-companion/fp-lib-table | 4 + .../Seeed_Studio_XIAO_Series.kicad_sym | 11661 ++++++++++++++++ .../library/XIAO-ESP32-C5-DIP.kicad_mod | 542 + .../library/XIAO-ESP32-C5-SMD-DIP.kicad_mod | 558 + .../library/XIAO-ESP32-C5-SMD.kicad_mod | 445 + .../production/C-ITS_Companion_v0.1.zip | Bin 0 -> 100901 bytes hardware/c_its-companion/sym-lib-table | 4 + 15 files changed, 27120 insertions(+) create mode 100644 hardware/c_its-companion-v0.1-back.jpg create mode 100644 hardware/c_its-companion-v0.1-front.jpg create mode 100644 hardware/c_its-companion/c_its-companion.kicad_pcb create mode 100644 hardware/c_its-companion/c_its-companion.kicad_prl create mode 100644 hardware/c_its-companion/c_its-companion.kicad_pro create mode 100644 hardware/c_its-companion/c_its-companion.kicad_sch create mode 100644 hardware/c_its-companion/fabrication-toolkit-options.json create mode 100644 hardware/c_its-companion/fp-lib-table create mode 100644 hardware/c_its-companion/library/Seeed_Studio_XIAO_Series.kicad_sym create mode 100644 hardware/c_its-companion/library/XIAO-ESP32-C5-DIP.kicad_mod create mode 100644 hardware/c_its-companion/library/XIAO-ESP32-C5-SMD-DIP.kicad_mod create mode 100644 hardware/c_its-companion/library/XIAO-ESP32-C5-SMD.kicad_mod create mode 100644 hardware/c_its-companion/production/C-ITS_Companion_v0.1.zip create mode 100644 hardware/c_its-companion/sym-lib-table diff --git a/.gitignore b/.gitignore index deb0257..1acf1bd 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,7 @@ target/ # Ignore .DS_Store file in mac **/.DS_Store + +# Ignore KiCad backups and lock files +hardware/c_its-companion/c_its-companion-backups/ +*.kicad_pro.lck diff --git a/hardware/c_its-companion-v0.1-back.jpg b/hardware/c_its-companion-v0.1-back.jpg new file mode 100644 index 0000000000000000000000000000000000000000..969862400169b2debe51bf728ca9ccf0a0f6abcc GIT binary patch literal 148719 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≦W^vFvsT%isj-`H-(H*|n$gt5H{)l;pPzqT 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 literal 0 HcmV?d00001 diff --git a/hardware/c_its-companion-v0.1-front.jpg b/hardware/c_its-companion-v0.1-front.jpg new file mode 100644 index 0000000000000000000000000000000000000000..2386379ce7aa76af99918f3f004c5e94111d7310 GIT binary patch literal 145854 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≷oBCMy-*bi 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 z%0D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 Date: Sun, 26 Jul 2026 21:34:18 +0200 Subject: [PATCH 48/49] gnss: Add support for ublox GNSS --- Cargo.lock | 24 ++++++++++ Cargo.toml | 7 ++- Readme.md | 13 ++--- feat-permut.sh | 8 ++-- src/main.rs | 125 +++++++++++++++++++++++++++++++++++++++++++++---- src/ubx.rs | 106 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 262 insertions(+), 21 deletions(-) create mode 100644 src/ubx.rs diff --git a/Cargo.lock b/Cargo.lock index 3fba778..b3d5c4c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1178,6 +1178,7 @@ dependencies = [ "profont", "prost", "prost-build", + "ublox", ] [[package]] @@ -2490,6 +2491,29 @@ version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +[[package]] +name = "ublox" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bcc96bd937066775fb87034e1dbf6d7165a8975c74ddec1105e31b4a050538b" +dependencies = [ + "bitflags", + "chrono", + "num-traits", + "ublox_derive", +] + +[[package]] +name = "ublox_derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5eda7d1c1151a40ae85c7c065a19959909573535034e18e1015b738993cd2c0" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "ufmt-write" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index eef57d9..55f42c5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ path = "./src/test.rs" required-features = ["std", "cam"] [features] -default = ["esp", "spat", "gnss", "screen"] +default = ["esp", "spat", "gnss_ubx", "screen"] # build for ESP (needed to build test for `host-tuple`) esp = [ @@ -33,8 +33,10 @@ esp = [ ] std = ["c-its-parser/std"] +# enable u-blox GNSS module +gnss_ubx = ["_gnss", "_uart", "dep:ublox"] # enable L67K GNSS module -gnss = ["_gnss", "dep:embassy_gps"] +gnss_l67k = ["_gnss", "dep:embassy_gps"] # echo CAM data on serial cam = ["c-its-parser/cam"] # send CAM autonomously @@ -107,6 +109,7 @@ mipidsi = { version = "0.10.0", optional = true } num-traits = { version = "0.2.19", default-features = false, features = ["libm"] } profont = { version = "0.7.0", optional = true } prost = { version = "0.14", optional = true, default-features = false, features = ["derive"] } +ublox = { version = "0.10", optional = true, default-features = false, features = ["alloc", "ubx_proto27"] } [dev-dependencies] assert_float_eq = "1.2.0" diff --git a/Readme.md b/Readme.md index ace338e..2ae3aab 100644 --- a/Readme.md +++ b/Readme.md @@ -5,15 +5,16 @@ Displays different C-ITS receive-only use cases for cheap. ## Hardware Requirements The code is currently tailored to: - Seeed XIAO ESP32-C5 (8MB PSRAM) -- Seeed XIAO L67K (connected on GPIO12 (RX), GPIO11 (TX), GPIO1 (wakeup) and GPIO25 (reset)) -only available with `gnss` feature (which is included in `spat` feature) +- U-Blox M10Q GNSS (connected on GPIO12 (RX), GPIO11 (TX), and GPIO25 (reset)) +or Seeed XIAO L67K (connected on GPIO12 (RX), GPIO11 (TX), GPIO1 (wakeup) and GPIO25 (reset)) +only available with `gnss_ubx` or `gnss_l67k` feature (which is included in `spat` feature) - Some ST7789 172*320px screen (connected on GPIO8 (SCL/ SCK), GPIO10 (SDA/ MOSI), GPIO7 (reset), GPIO23 (DC), GPIO24 (CS), TODO GPIO0 (BL)) only available with `screen` feature (which is included in `spat` feature) ## Features ### GNSS -Enable L67K GNSS module and in main application logic/ state. +Enable u-blox or L67K GNSS module and in main application logic/ state. ### SPAT Takes the current position and heading from the GNSS receiver and displays the signal phases of the upcoming intersection. @@ -89,9 +90,9 @@ Additional features are available through feature flags: - `cam_tx`: Enable CAM generation and transmission (needs time and position source) Officially supported feature combinations are: -- `esp,spat,gnss,screen` (default features, "standalone") -- `esp,spat,gnss,screen,cam_tx` ("standalone with CAM") -- `esp,spat,gnss,screen,cam_tx,cam,denm` +- `esp,spat,gnss_ubx,screen` (default features, "standalone") +- `esp,spat,gnss_ubx,screen,cam_tx` ("standalone with CAM") +- `esp,spat,gnss_ubx,screen,cam_tx,cam,denm` - `esp,uart,cam,denm` ("smartphone companion") Connect your ESP32-C5 and run: diff --git a/feat-permut.sh b/feat-permut.sh index 2019bef..9c08d65 100755 --- a/feat-permut.sh +++ b/feat-permut.sh @@ -9,12 +9,12 @@ extended=${2:-false} features_base=( uart,cam,denm - spat,gnss,screen - spat,gnss,screen,cam_tx + spat,gnss_ubx,screen + spat,gnss_ubx,screen,cam_tx ) features_more=( - spat,gnss,screen,cam,denm - spat,gnss,screen,cam_tx,cam,denm + spat,gnss_ubx,screen,cam,denm + spat,gnss_ubx,screen,cam_tx,cam,denm ) features_test=( spat,cam,denm,uart diff --git a/src/main.rs b/src/main.rs index 64ed528..5320951 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,11 +8,16 @@ // #![deny(clippy::large_stack_frames)] // we can't use UART I/O and GNSS at the same time -#[cfg(all(feature = "uart", feature = "gnss"))] -compile_error!("feature \"uart\" and feature \"gnss\" cannot be enabled at the same time"); +#[cfg(all(feature = "uart", feature = "gnss_ubx", feature = "gnss_l67k"))] +compile_error!( + "feature \"uart\" and features \"gnss_ubx\" or \"gnss_l67k\" cannot be enabled at the same time" +); // autonomous CAM generation need a time and position source (aka _gnss) #[cfg(all(feature = "cam_tx", not(feature = "_gnss")))] compile_error!("feature \"cam_tx\" needs a time and position source (either GNSS or UART)"); +// we can't have both GNSS drivers enabled at the same time +#[cfg(all(feature = "gnss_ubx", feature = "gnss_l67k"))] +compile_error!("feature \"gnss_ubx\" and feature \"gnss_l67k\" cannot be enabled at the same time"); use alloc::string::ToString as _; use alloc::vec::Vec; @@ -26,7 +31,7 @@ use c_its_parser::standards::extensions::ItsStationType; use critical_section::Mutex; use crossbeam_queue::ArrayQueue; use embassy_executor::Spawner; -#[cfg(feature = "gnss")] +#[cfg(feature = "gnss_l67k")] use embassy_gps::gps::l76k; use esp_backtrace as _; use esp_hal::clock::CpuClock; @@ -47,6 +52,8 @@ mod io; mod radio; #[cfg(feature = "screen")] mod screen; +#[cfg(feature = "gnss_ubx")] +mod ubx; #[cfg(any(feature = "cam_tx", feature = "uart"))] mod v2x_tx; @@ -184,14 +191,15 @@ async fn main(spawner: Spawner) -> ! { let mac = esp_hal::efuse::base_mac_address(); info!("WiFi MAC: {mac}"); - // Setup GNSS: Seeed XIAO L67K: + // Setup GNSS: + // Seeed XIAO L67K: // RX: D7/ GPIO12 // TX: D6/ GPIO11 // WAKEUP: D0/ GPIO1 -> HIGH for active, LOW for Sleep // RESET: D2/ GPIO25 -> HIGH for normal, LOW for Reset - #[cfg(feature = "gnss")] + #[cfg(feature = "gnss_l67k")] spawner.spawn( - gnss_task( + gnss_l67k_task( peripherals.GPIO1, peripherals.GPIO25, peripherals.UART0, @@ -200,7 +208,29 @@ async fn main(spawner: Spawner) -> ! { ) .expect("Failed to spawn GNSS task"), ); - #[cfg(feature = "gnss")] + + // U-Blox module: + // RX: D7/ GPIO12 + // TX: D6/ GPIO11 + // RESET: D2/ GPIO25 -> HIGH for normal, LOW for Reset + #[cfg(feature = "gnss_ubx")] + { + setup_uart0( + 9600, + Some(b'\n'), + peripherals.GPIO12, + peripherals.GPIO11, + peripherals.UART0, + serial_handler, + ) + .expect("Failed to initialize UART for GNSS"); + + // do some settings (UART receive and parsing runs in main loop) + spawner + .spawn(gnss_ubx_config(peripherals.GPIO25).expect("Failed to spawn GNSS config task")); + } + + #[cfg(feature = "_gnss")] info!("GNSS task started, waiting for GNSS fix..."); #[cfg(any(feature = "cam_tx", feature = "uart"))] @@ -226,6 +256,8 @@ async fn main(spawner: Spawner) -> ! { }; #[cfg(feature = "uart")] let mut uart_input = io::Parser::default(); + #[cfg(feature = "gnss_ubx")] + let mut ubx_parser = ublox::Parser::default(); loop { embassy_time::Timer::after(embassy_time::Duration::from_millis(1)).await; @@ -290,6 +322,49 @@ async fn main(spawner: Spawner) -> ! { } } } + #[cfg(feature = "gnss_ubx")] + { + let mut it = ubx_parser.consume_ubx(&data); + loop { + match it.next() { + Some(Ok(ublox::UbxPacket::Proto27(packet_ref))) => match &packet_ref { + ublox::proto27::PacketRef::MonVer(packet) => { + println!("{}", ubx::pretty_print_mon_ver(packet)); + } + ublox::proto27::PacketRef::MonComms(packet) => { + println!("{}", ubx::pretty_print_mon_comms(packet)); + } + ublox::proto27::PacketRef::MonRf(packet) => { + println!("{}", ubx::pretty_print_mon_rf(packet)); + } + ublox::proto27::PacketRef::NavSat(packet) => { + println!("{}", ubx::pretty_print_nav_sat(packet)); + } + ublox::proto27::PacketRef::NavPvt(pvt) => { + match applogic::GnssFix::try_from(pvt) { + Ok(pos_state) => { + critical_section::with(|cs| { + let gnss_update_ref = GNSS_UPDATE.borrow(cs); + gnss_update_ref.replace(Some(pos_state)); + }); + } + Err(err) => warn!("{err}"), + } + } + _ => { + println!("UBX: {packet_ref:?}"); + } + }, + Some(Err(_)) => { + // Received a malformed packet + } + None => { + // The internal buffer is now empty + break; + } + } + } + } } } @@ -626,10 +701,10 @@ fn get_its_header<'p>( } } -#[cfg(feature = "gnss")] +#[cfg(feature = "gnss_l67k")] #[allow(clippy::large_stack_frames, reason = "GNSS driver needs some space")] #[embassy_executor::task] -async fn gnss_task( +async fn gnss_l67k_task( gps_wakeup: esp_hal::peripherals::GPIO1<'static>, gps_reset: esp_hal::peripherals::GPIO25<'static>, uart: esp_hal::peripherals::UART0<'static>, @@ -679,6 +754,38 @@ async fn gnss_task( } } +#[cfg(feature = "gnss_ubx")] +#[embassy_executor::task] +async fn gnss_ubx_config(reset_pin: esp_hal::peripherals::GPIO25<'static>) { + // reset the module + let mut gnss_reset = esp_hal::gpio::Output::new( + reset_pin, + esp_hal::gpio::Level::Low, + esp_hal::gpio::OutputConfig::default(), + ); + embassy_time::Timer::after(embassy_time::Duration::from_millis(2)).await; + gnss_reset.set_high(); + + embassy_time::Timer::after(embassy_time::Duration::from_secs(1)).await; + + info!("UBX: Enabling ubx protocol with NAV-PVT and NAV-SAT"); + let buffer = ublox::packets::cfg_val::CfgValSetBuilder { + version: 1, + layers: ublox::packets::cfg_val::CfgLayerSet::RAM, + reserved1: 0, + cfg_data: &[ + ublox::cfg_val::CfgVal::Uart1OutProtNmea(false), + ublox::cfg_val::CfgVal::Uart1OutProtUbx(true), + // CFG-MSGOUT-UBX_NAV_PVT_UART1 + ublox::cfg_val::CfgVal::MsgOutUbxNavPvtUart1(1), + // CFG-MSGOUT-UBX_NAV_SAT_UART1 + // ublox::cfg_val::CfgVal::MsgOutUbxNavSatUart1(10), + ], + } + .into_packet_vec(); + send_uart0(&buffer); +} + #[cfg(feature = "screen")] fn log_screen_error(error: &E) where diff --git a/src/ubx.rs b/src/ubx.rs new file mode 100644 index 0000000..934af7b --- /dev/null +++ b/src/ubx.rs @@ -0,0 +1,106 @@ +//! u-blox helpers +#![allow(unused)] + +use alloc::vec::Vec; + +pub fn pretty_print_mon_ver(packet: &ublox::mon_ver::MonVerRef) -> alloc::string::String { + alloc::format!( + "UBX-MON-VER: SW version: {} HW version: {}; Extensions: {:?}", + packet.software_version(), + packet.hardware_version(), + packet.extension().collect::>() + ) +} + +pub fn pretty_print_mon_comms(packet: &ublox::mon_comms::MonCommsRef) -> alloc::string::String { + alloc::format!( + "UBX-MON-COMMS: {} ports ({:?}), {} tx err, info {:?}", + packet.n_ports(), + packet.prot_ids(), + packet.tx_errors(), + packet + .ports() + .map(|v| alloc::format!("{v:?}")) + .collect::>() + ) +} + +pub fn pretty_print_mon_rf(packet: &ublox::mon_rf::MonRfRef) -> alloc::string::String { + alloc::format!( + "UBX-MON-RF: {:?}", + packet + .blocks() + .map(|v| alloc::format!("{v:?}")) + .collect::>() + ) +} + +pub fn pretty_print_nav_sat(packet: &ublox::nav_sat::NavSatRef) -> alloc::string::String { + // sort by quality flags + let mut sat_infos = packet.svs().collect::>(); + sat_infos.sort_unstable_by_key(|v| v.flags_raw() & 0x7); + sat_infos.reverse(); + + let sat_info_strings = sat_infos + .iter() + .map(|v| { + alloc::format!( + "- {}/{:3}: E {:3.0}, A {:3.0}, CNO {:2.1} dBHz, {:?}, {:?}", + v.gnss_id(), + v.sv_id(), + v.elev(), + v.azim(), + v.cno(), + v.flags().quality_ind(), + v.flags().health(), + ) + }) + .collect::>(); + + alloc::format!( + "UBX-NAV-SAT: num {:?}\n{}", + packet.num_svs(), + sat_info_strings.join("\n") + ) +} + +impl<'a> TryFrom<&ublox::nav_pvt::proto27::NavPvtRef<'a>> for crate::applogic::GnssFix { + type Error = alloc::string::String; + + fn try_from(value: &ublox::nav_pvt::proto27::NavPvtRef<'a>) -> Result { + let has_time = value.fix_type() == ublox::GnssFixType::Fix3D + || value.fix_type() == ublox::GnssFixType::GPSPlusDeadReckoning + || value.fix_type() == ublox::GnssFixType::TimeOnlyFix; + let has_posvel = value.fix_type() == ublox::GnssFixType::Fix3D + || value.fix_type() == ublox::GnssFixType::GPSPlusDeadReckoning; + + if !has_posvel || !has_time { + return Err(alloc::format!( + "NAV-PVT without valid fix: {:?}", + value.fix_type() + )); + } + + let pos: ublox::PositionLLA = value.into(); + let vel: ublox::Velocity = value.into(); + + let time: chrono::DateTime = value + .try_into() + .map_err(|err| alloc::format!("Failed to convert time: {err}"))?; + + let latitude_deg = pos.lat; + let longitude_deg = pos.lon; + #[allow(clippy::cast_precision_loss)] + let heading_deg = vel.heading as f32; + #[allow(clippy::cast_precision_loss)] + let speed_mps = vel.speed as f32; + + Ok(Self { + time: Some(time.naive_utc()), + latitude_deg, + longitude_deg, + heading_deg: Some(heading_deg), + speed_mps: Some(speed_mps), + }) + } +} From 666499c240bb22f118c6cf6d2ad92b1c9e254b0b Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Fri, 31 Jul 2026 16:01:43 +0200 Subject: [PATCH 49/49] Move core ESP32 V2X to own repo --- Cargo.lock | 17 ++- Cargo.toml | 5 +- src/applogic/cam_tx.rs | 61 +++++++-- src/applogic/mod.rs | 18 +-- src/io.rs | 18 +-- src/main.rs | 62 ++++----- src/radio.rs | 288 ----------------------------------------- src/test.rs | 2 - src/ubx.rs | 4 +- src/v2x_tx/gn.rs | 94 -------------- src/v2x_tx/mod.rs | 164 ----------------------- 11 files changed, 118 insertions(+), 615 deletions(-) delete mode 100644 src/radio.rs delete mode 100644 src/v2x_tx/gn.rs delete mode 100644 src/v2x_tx/mod.rs diff --git a/Cargo.lock b/Cargo.lock index b3d5c4c..98a4c6a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -192,9 +192,9 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "c-its-parser" -version = "2.4.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd4db66f495280ec88eef8712dd9ab7d1aacc590f62485e3e03a89b3ce4f8b14" +checksum = "7e94d9990b20498f621f164f23ae198c4365115eb4191ae106352ec74d4a4110" dependencies = [ "chrono", "etherparse", @@ -1171,6 +1171,7 @@ dependencies = [ "esp-println", "esp-radio", "esp-rtos", + "esp32-cits-core", "geo-types", "log", "mipidsi", @@ -1181,6 +1182,18 @@ dependencies = [ "ublox", ] +[[package]] +name = "esp32-cits-core" +version = "0.1.0" +source = "git+https://git.hamburg.ccc.de/jtbx/esp32-cits-core?branch=main#a6d0ea6ca2da130fde2b84060a34ef45e772a459" +dependencies = [ + "c-its-parser", + "chrono", + "esp-hal", + "esp-radio", + "geo-types", +] + [[package]] name = "esp32c2" version = "0.29.2" diff --git a/Cargo.toml b/Cargo.toml index 55f42c5..9b3cbd9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,8 +30,10 @@ esp = [ "dep:esp-backtrace", "dep:esp-println", "dep:esp-radio", + "esp32-cits-core/esp32c5", + "esp32-cits-core/log-04", ] -std = ["c-its-parser/std"] +std = ["c-its-parser/std", "esp32-cits-core/std"] # enable u-blox GNSS module gnss_ubx = ["_gnss", "_uart", "dep:ublox"] @@ -93,6 +95,7 @@ esp-radio = { version = "0.18.0", optional = true, features = [ ] } embassy_gps = { version = "0.1.0", optional = true, default-features = false, features = ["esp", "log-04"], git = "https://github.com/jbeyerstedt/embassy_gps.git", branch = "feat/rework-gpsfix" } +esp32-cits-core = { version = "0.1.0", default-features = false, git = "https://git.hamburg.ccc.de/jtbx/esp32-cits-core", branch = "main" } embedded-hal-bus = { version = "0.3.0", optional = true } embedded-graphics = { version = "0.8.2", optional = true } # needs newer GN with removed bitvec dependency diff --git a/src/applogic/cam_tx.rs b/src/applogic/cam_tx.rs index e15de27..850ae40 100644 --- a/src/applogic/cam_tx.rs +++ b/src/applogic/cam_tx.rs @@ -11,6 +11,45 @@ const PH_MAX_LENGHT_M: f32 = 500.; const PH_MAX_ITEMS: usize = 23; const PH_MIN_DIST_M: f32 = 20.; +/// Stand-alone CAM generator +/// +/// Generates CAM messages from time and [`PosVel`] updates +/// +/// # Send a message +/// ``` +/// use esp32_cits_core::tx::GnItsPayload as _; +/// +/// let (mut seq_no, mac_bytes) = { +/// // unwrap is fine since [`esp_hal::efuse::MacAddress`] is just an `[u8; 6]` +/// let mac_bytes = mac.as_bytes().try_into().unwrap(); +/// +/// (0, mac_bytes) +/// }; +/// +/// let mut cam = { +/// let station_id = make_station_id(mac); +/// info!("V2X Station ID: {station_id}"); +/// +/// applogic::cam_tx::CamState::new( +/// station_id, +/// OWN_STATION_TYPE, +/// OWN_VEHICLE_LENGTH, +/// OWN_VEHICLE_WIDTH, +/// ) +/// }; +/// +/// cam.update(time, fix.clone().into()); +/// match cam.encode_packet(mac_bytes, pos_vel, time, GN_IS_MOBILE, &mut seq_no) { +/// Ok(data) => { +/// if let Err(err) = +/// radio::send_80211_bcast_frame(&mut wlan_iface, &mac_bytes.clone(), &data) +/// { +/// warn!("Failed to send 802.11 frame: {err}"); +/// } +/// } +/// Err(err) => warn!("Failed to create CAM: {err}"), +/// } +/// ``` #[derive(Debug)] pub struct CamState { station_id: u32, @@ -20,7 +59,7 @@ pub struct CamState { vehicle_width_dm: u8, // vehicle width is in 10cm steps time: chrono::DateTime, - pos_state: super::PositionState, + pos_state: esp32_cits_core::PosVel, /// database for path history, oldest item at the back of the vector path_history: Vec, @@ -35,7 +74,7 @@ struct PathPoint { } impl PathPoint { - fn new_from_pos(pos: &super::PositionState, time: chrono::DateTime) -> Self { + fn new_from_pos(pos: &esp32_cits_core::PosVel, time: chrono::DateTime) -> Self { Self { pos: pos.position, time, @@ -44,7 +83,7 @@ impl PathPoint { } fn new_from_pos_with_dist( - pos: &super::PositionState, + pos: &esp32_cits_core::PosVel, time: chrono::DateTime, dist_m: f32, ) -> Self { @@ -69,12 +108,12 @@ impl CamState { vehicle_length_dm, vehicle_width_dm, time: chrono::DateTime::::default(), - pos_state: super::PositionState::default(), + pos_state: esp32_cits_core::PosVel::default(), path_history: Vec::with_capacity(PH_MAX_ITEMS + 1), } } - pub fn update(&mut self, time: chrono::DateTime, pos: super::PositionState) { + pub fn update(&mut self, time: chrono::DateTime, pos: esp32_cits_core::PosVel) { self.time = time; self.pos_state = pos; @@ -86,7 +125,7 @@ impl CamState { fn update_ph( path_history: &mut Vec, time: chrono::DateTime, - pos: &super::PositionState, + pos: &esp32_cits_core::PosVel, ) { // Update path history if path_history.is_empty() { @@ -293,11 +332,11 @@ impl CamState { } } -impl crate::v2x_tx::GnItsPayload for CamState { +impl esp32_cits_core::tx::GnItsPayload for CamState { fn make_eh( &self, address: [u8; 6], - own_position: &crate::applogic::PositionState, + own_position: &esp32_cits_core::PosVel, time: chrono::DateTime, _seq_no: &mut u16, ) -> Result< @@ -313,7 +352,7 @@ impl crate::v2x_tx::GnItsPayload for CamState { // CAM always uses SHB, hop-limit 1 Ok(( - crate::v2x_tx::gn::make_shb_eh(address, station_type, own_position, time), + esp32_cits_core::tx::gn::make_shb_eh(address, station_type, own_position, time)?, None, 1, )) @@ -336,8 +375,8 @@ mod tests { use super::*; use crate::init_test_env_logger; - fn posstate_from_pos(position: geo_types::Point) -> crate::applogic::PositionState { - crate::applogic::PositionState { + fn posstate_from_pos(position: geo_types::Point) -> esp32_cits_core::PosVel { + esp32_cits_core::PosVel { position, heading_deg: None, speed_mps: None, diff --git a/src/applogic/mod.rs b/src/applogic/mod.rs index 7847010..70cc086 100644 --- a/src/applogic/mod.rs +++ b/src/applogic/mod.rs @@ -49,7 +49,7 @@ pub struct State { time: chrono::NaiveDateTime, #[allow(unused)] - pos: PositionState, + pos: esp32_cits_core::PosVel, #[cfg(feature = "spat")] last_prune: chrono::NaiveDateTime, @@ -130,15 +130,7 @@ pub struct GnssFix { pub speed_mps: Option, } -#[allow(unused)] -#[derive(Debug, Default, Clone, PartialEq)] -pub struct PositionState { - pub position: geo_types::Point, - pub heading_deg: Option, - pub speed_mps: Option, -} - -impl From<&GnssFix> for PositionState { +impl From<&GnssFix> for esp32_cits_core::PosVel { fn from(value: &GnssFix) -> Self { let position = geo_types::Point::new(value.longitude_deg, value.latitude_deg); @@ -149,7 +141,7 @@ impl From<&GnssFix> for PositionState { } } } -impl From for PositionState { +impl From for esp32_cits_core::PosVel { fn from(value: GnssFix) -> Self { (&value).into() } @@ -212,7 +204,7 @@ impl State { Self { initialized: false, time: chrono::NaiveDateTime::default(), - pos: PositionState::default(), + pos: esp32_cits_core::PosVel::default(), #[cfg(feature = "spat")] last_prune: chrono::NaiveDateTime::default(), #[cfg(feature = "spat")] @@ -641,7 +633,7 @@ impl State { } #[allow(unused)] - pub fn pos(&self) -> &PositionState { + pub fn pos(&self) -> &esp32_cits_core::PosVel { &self.pos } } diff --git a/src/io.rs b/src/io.rs index 4642253..44997ee 100644 --- a/src/io.rs +++ b/src/io.rs @@ -169,7 +169,7 @@ impl From for crate::applogic::GnssFix { } } -impl From for crate::applogic::PositionState { +impl From for esp32_cits_core::PosVel { fn from(value: msg::PositionState) -> Self { let position = geo_types::Point::new( value.position.longitude_deg.into(), @@ -194,7 +194,7 @@ impl From for crate::applogic::PositionState { } } -impl From for crate::applogic::PositionState { +impl From for esp32_cits_core::PosVel { fn from(value: msg::ItsPosition) -> Self { let latitude_deg = f64::from(value.latitude) / 10_000_000.; let longitude_deg = f64::from(value.longitude) / 10_000_000.; @@ -208,11 +208,11 @@ impl From for crate::applogic::PositionState { } } -impl crate::v2x_tx::GnItsPayload for msg::RawMsgTx { +impl esp32_cits_core::tx::GnItsPayload for msg::RawMsgTx { fn make_eh( &self, address: [u8; 6], - own_position: &crate::applogic::PositionState, + own_position: &esp32_cits_core::PosVel, time: chrono::DateTime, seq_no: &mut u16, ) -> Result< @@ -275,19 +275,19 @@ impl crate::v2x_tx::GnItsPayload for msg::RawMsgTx { *seq_no += 1; ( - crate::v2x_tx::gn::make_tsb_eh( + esp32_cits_core::tx::gn::make_tsb_eh( address, station_type, own_position, time, *seq_no, - ), + )?, None, hop_limit, ) } msg::GnTransport::Shb => ( - crate::v2x_tx::gn::make_shb_eh(address, station_type, own_position, time), + esp32_cits_core::tx::gn::make_shb_eh(address, station_type, own_position, time)?, None, 1, ), @@ -308,7 +308,7 @@ impl msg::RawMsgTx { fn make_geobcast( address: [u8; 6], station_type: geonetworking::en302636_4_1::StationType, - own_position: &crate::applogic::PositionState, + own_position: &esp32_cits_core::PosVel, time: chrono::DateTime, gn_area: msg::GnArea, sequence_number: u16, @@ -320,7 +320,7 @@ impl msg::RawMsgTx { alloc::string::String, > { let source_position_vector = - crate::v2x_tx::gn::make_lpv(address, station_type, own_position, time); + esp32_cits_core::tx::gn::make_lpv(address, station_type, own_position, time)?; let (atype, dist_a, dist_b, angle) = match gn_area.area() { msg::GnAreaShape::EtsiAreashapeCircle => ( diff --git a/src/main.rs b/src/main.rs index 5320951..67e181c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -38,7 +38,7 @@ use esp_hal::clock::CpuClock; use esp_hal::timer::timg::TimerGroup; #[cfg(all(target_arch = "riscv32", feature = "spat"))] use esp_println::println; -use esp_radio::wifi; +use esp32_cits_core::radio; use geonetworking::Decode as _; use log::{error, info, warn}; @@ -49,13 +49,10 @@ mod cache; mod geo_alg; #[cfg(feature = "uart")] mod io; -mod radio; #[cfg(feature = "screen")] mod screen; #[cfg(feature = "gnss_ubx")] mod ubx; -#[cfg(any(feature = "cam_tx", feature = "uart"))] -mod v2x_tx; const WIFI_CHANNEL: radio::Channel = 180; const WIFI_TXPOWER: i8 = 10; // dBm, 2..20 @@ -244,7 +241,7 @@ async fn main(spawner: Spawner) -> ! { let mut state = applogic::State::new(); #[cfg(feature = "cam_tx")] let mut cam = { - let station_id = v2x_tx::make_station_id(mac); + let station_id = make_station_id(mac); info!("V2X Station ID: {station_id}"); applogic::cam_tx::CamState::new( @@ -289,7 +286,7 @@ async fn main(spawner: Spawner) -> ! { }); } Some(io::msg::serial_input_msg::Payload::TxMsg(tx_request)) => { - use v2x_tx::GnItsPayload as _; + use esp32_cits_core::tx::GnItsPayload as _; info!( "UART: Got new TX message: Type {} with {} bytes", @@ -380,7 +377,7 @@ async fn main(spawner: Spawner) -> ! { #[cfg(feature = "cam_tx")] { - use v2x_tx::GnItsPayload; + use esp32_cits_core::tx::GnItsPayload as _; let time = state.time().and_utc(); cam.update(time, fix.clone().into()); @@ -513,7 +510,7 @@ async fn main(spawner: Spawner) -> ! { reason = "adhering to callback interface" )] #[allow(clippy::too_many_lines)] -fn handle_frame(frame: wifi::sniffer::PromiscuousPkt<'_>) { +fn handle_frame(frame: esp_radio::wifi::sniffer::PromiscuousPkt<'_>) { #[cfg(feature = "spat")] const PRUNE_INTERVAL: chrono::Duration = chrono::Duration::seconds(60); #[cfg(feature = "spat")] @@ -534,27 +531,18 @@ fn handle_frame(frame: wifi::sniffer::PromiscuousPkt<'_>) { .naive_utc() }; - // Ignore frames with errors and non-data frames - if frame.rx_cntl.rx_state != 0 { - warn!("Received frame has RX error: {}", frame.rx_cntl.rx_state); - return; - } - if !radio::PromiscuousPktType::Data.matches(frame.frame_type) { - return; - } - - // Ignore non-broadcast frames - if frame.len < (4 + 6) { - warn!( - "Received frame too small to be a valid broadcast frame: {} bytes", - frame.len - ); - return; - } - let dest_mac = &frame.data[4..10]; - if dest_mac != [0xff; 6] { - warn!("Received frame is not broadcast frame: Dest MAC {dest_mac:02x?}"); - return; + match esp32_cits_core::rx::is_broadcast_frame(&frame) { + Err(err) => { + warn!("{err}"); + return; + } + Ok(false) => { + // drop frame + return; + } + Ok(true) => { + // continue + } } // info!("Received frame with {} bytes", frame.len); @@ -885,3 +873,19 @@ fn send_uart0(data: &[u8]) { } }); } + +#[cfg(all(feature = "esp", feature = "cam_tx"))] +/// Creates a station ID from the WLAN MAC address +/// +/// Will use `0xC173` as static prefix and uses last 16 bit from MAC address for the remainder. +/// +/// # Panics +/// Won't panic in practive b/c of pre-conditions +#[must_use] +pub fn make_station_id(mac: esp_hal::efuse::MacAddress) -> u32 { + // unwrap is fine here since we can be sure that the MacAddress is 6 byte + // and a slice of 2 bytes can be converted to [u8; 2] + let mac_last_16bit = u16::from_be_bytes(mac.as_bytes().get(4..6).unwrap().try_into().unwrap()); + + 0xC173_0000 | u32::from(mac_last_16bit) +} diff --git a/src/radio.rs b/src/radio.rs deleted file mode 100644 index 46cd72a..0000000 --- a/src/radio.rs +++ /dev/null @@ -1,288 +0,0 @@ -//! IEEE 802.11p C-ITS Capture with ESP32-C5 -#![allow(unused)] - -// ----------------------------- -// missing interface definitions -// ----------------------------- - -pub type Channel = core::ffi::c_int; - -unsafe extern "C" { - /// Changes the 802.11 PHY channel - /// - /// C function signature: `void phy_change_channel(int,int,int,int)` - /// - /// Options: - /// - `primary`: Channel frequency in MHz - /// - `ignored1`: Ignored (set to 1) - /// - `ignored2`: Ignored (set to 0) - /// - `ht_mode`: Probably HT mode - unsafe fn phy_change_channel( - primary: Channel, - ignored1: core::ffi::c_int, - ignored2: core::ffi::c_int, - ht_mode: core::ffi::c_int, - ) -> core::ffi::c_int; - - /// Enables 802.11p mode on the ESP32-C5 - /// - /// C function signature: `void phy_11p_set(int,int)` - /// - /// Options: - /// - `enable`: Boolean option to enable 802.11p mode - /// - `half_rate`: 5 MHz mode - unsafe fn phy_11p_set(enable: u8, half_rate: u8); - -} - -/// Enum values for `wifi_promiscuous_pkt_type_t` used in `esp_radio::wifi::sniffer::PromiscuousPkt::frame_type` -/// -/// Original value is actually `core::ffi::c_uint`, but that can't be used in `#[repr()]` attributes, -/// so we're using `u8` as the smallest suitable type here that can be casted to a bigger type. -#[repr(u8)] -#[derive(Debug, Clone, Copy)] -#[allow(dead_code)] -pub(crate) enum PromiscuousPktType { - /// Management frame, indicates 'buf' argument is `wifi_promiscuous_pkt_t` - Mgmt = 0, - /// Control frame, indicates 'buf' argument is `wifi_promiscuous_pkt_t` - Ctrl = 1, - /// Data frame, indicates 'buf' argument is `wifi_promiscuous_pkt_t` - Data = 2, - /// Other type, such as MIMO etc. 'buf' argument is `wifi_promiscuous_pkt_t` but the payload is zero length. - Misc = 3, -} - -impl PromiscuousPktType { - #[allow(dead_code)] - pub fn as_repr(self) -> u8 { - self as u8 - } - - /// Converts to underlying type of `wifi_promiscuous_pkt_type_t` (used by `esp_radio::wifi::sniffer::PromiscuousPkt::frame_type`) - pub fn as_frame_type(self) -> core::ffi::c_uint { - core::ffi::c_uint::from(self.as_repr()) - } - - /// Determines if we are the same frame type as `esp_radio::wifi::sniffer::PromiscuousPkt::frame_type` - pub fn matches(self, frame_type: core::ffi::c_uint) -> bool { - self.as_frame_type() == frame_type - } -} - -#[cfg(feature = "esp")] -/// Configure WiFi Sniffer to a certain 802.11p channel -pub fn setup_wifi_sniffer( - channel: Channel, - sniffer: &mut esp_radio::wifi::sniffer::Sniffer, - callback: fn(esp_radio::wifi::sniffer::PromiscuousPkt<'_>), -) -> Result<(), alloc::string::String> { - sniffer - .set_promiscuous_mode(true) - .map_err(|err| alloc::format!("Failed to enable promiscuous mode: {err:?}"))?; - - sniffer.set_receive_cb(callback); - - // convert channel number to frequency in MHz - let primary = 5000 + (channel * 5); - - unsafe { - phy_11p_set(1, 0); - match phy_change_channel(primary, 1, 0, 0) { - 0 => Ok(()), - ret => Err(alloc::format!("Failed to change channel: {ret}")), - } - } -} - -#[cfg(feature = "esp")] -/// Sends 802.11p broadcast frame -/// -/// Expects `data` to contain GN header (BTP header and ITS payload) to be a valid C-ITS/ ITS-G5 packet/ frame. -/// -/// # Errors -/// Fails when MAC is not the right size or returns frame send error -pub fn send_80211_bcast_frame( - sniffer: &mut esp_radio::wifi::sniffer::Sniffer, - mac_addr: &[u8], - data: &[u8], -) -> Result<(), alloc::string::String> { - const HEADER_SIZE: usize = 24 + 8; - - let mac = mac_addr - .try_into() - .map_err(|err| alloc::format!("Unexpected MAC address size: {err}"))?; - - // assemble frame - let mut frame_buf = alloc::vec::Vec::with_capacity(HEADER_SIZE + data.len()); - frame_buf.extend_from_slice(&DataFrameHeader::new_80211p(mac).to_binary()); - frame_buf.extend_from_slice(&LlcHeader::new_80211p().to_binary()); - frame_buf.extend_from_slice(data); - - // send - sniffer - .send_raw_frame(true, &frame_buf, true) - .map_err(|err| alloc::format!("Failed to send 802.11p frame: {err:?}")) -} - -/// IEEE 802.11p frame header -#[derive(Debug, Default)] -struct DataFrameHeader { - /// byte 0: subtype in upper nibble, type in lower nibble - /// "normal" data frame: 0x08, QoS data frame: 0x88 - pub type_and_subtype: u8, - /// byte 1: frame contol field, 0x00 for 802.11p - pub fcf_flags: u8, - /// byte 2..3: duration in micros, usually 0x0000 for 802.11p (toDS = 0, fromDS = 0) - pub duration: u16, - /// byte 4..9: receiver/ destination for 802.11p - pub address_1: [u8; 6], - /// byte 10..15: transmitter/ source for 802.11p - pub address_2: [u8; 6], - /// byte 16..21: BSSID for 802.11p - pub address_3: [u8; 6], - /// byte 22..24: upper 12 bits: seq. no, lower 4 bits: fragment no - pub sequence_control: u16, - // Note: address 4 is not used in 802.11p - // pub qos: Option<[u8; 2]>, // QoS frames are supported by radio library - // Note: 802.11p does not use HT mode(s) -} - -impl DataFrameHeader { - const BCAST_MAC: [u8; 6] = [0xFF; 6]; - - /// Create an 802.11p (non-QoS) data frame header with a source MAC address - pub(crate) fn new_80211p(source_mac: &[u8; 6]) -> Self { - Self { - type_and_subtype: 0x08, - address_1: Self::BCAST_MAC, - address_2: *source_mac, - address_3: Self::BCAST_MAC, - ..Default::default() - } - } - - pub(crate) fn to_binary(&self) -> [u8; 24] { - let mut buf = [0u8; 24]; - - buf[0] = self.type_and_subtype; - - buf[1] = self.fcf_flags; - - Self::write_u16(&mut buf, 2, self.duration); - Self::write_mac_addr(&mut buf, 4, &self.address_1); - Self::write_mac_addr(&mut buf, 10, &self.address_2); - Self::write_mac_addr(&mut buf, 16, &self.address_3); - Self::write_u16(&mut buf, 22, self.sequence_control); - - buf - } - - fn write_u16(buf: &mut [u8; 24], offset: usize, data: u16) { - for (idx, byte) in data.to_be_bytes().iter().enumerate() { - buf[idx + offset] = *byte; - } - } - - fn write_mac_addr(buf: &mut [u8; 24], offset: usize, data: &[u8; 6]) { - for (idx, byte) in data.iter().enumerate() { - buf[idx + offset] = *byte; - } - } -} - -/// IEEE 802.11p LLC header -#[derive(Debug, Default)] -struct LlcHeader { - /// byte 0: SNAP (0xaa) in 802.11p - pub dsap: u8, // SNAP in - /// byte 1: SNAP (0xaa) in 802.11p - pub lsap: u8, // SNAP in - /// byte 2: SNAP (0xaa) in 802.11p - pub control_1: u8, // 0x03 - // Note: optional second control byte not used in 802.11p - - // SNAP fields - /// set to 0 since ethertype is used for protocol ID in 802.11p - pub oui: [u8; 3], - /// ethertype GeoNetworking (0x8947) in 802.11p - pub protocol_id: u16, -} - -impl LlcHeader { - pub(crate) fn new_80211p() -> Self { - Self { - dsap: 0xaa, - lsap: 0xaa, - control_1: 0x03, - oui: [0; 3], - protocol_id: 0x8947, - } - } - - pub(crate) fn to_binary(&self) -> [u8; 8] { - let mut buf: [u8; 8] = [ - self.dsap, - self.lsap, - self.control_1, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - ]; - - buf[3] = self.oui[0]; - buf[4] = self.oui[1]; - buf[5] = self.oui[2]; - - buf[6] = self.protocol_id.to_be_bytes()[0]; - buf[7] = self.protocol_id.to_be_bytes()[1]; - - buf - } -} - -#[cfg(test)] -mod tests { - - use super::*; - - #[test] - fn data_frame_test() { - // build a typical IEEE 802.11p frame - const OWN_MAC: [u8; 6] = [0x12, 0x34, 0x56, 0x78, 0xab, 0xcd]; - const REF_HEADER: [u8; 24] = [ - 0x08, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x12, 0x34, 0x56, 0x78, - 0xab, 0xcd, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, - ]; - - let frame = DataFrameHeader::new_80211p(&OWN_MAC); - - assert_eq!(0x08, frame.type_and_subtype); // data frame, subtype 0 - assert_eq!(0x0000, frame.fcf_flags); - assert_eq!(0x0000, frame.duration); - assert_eq!([0xFF; 6], frame.address_1); - assert_eq!(OWN_MAC, frame.address_2); - assert_eq!([0xFF; 6], frame.address_3); - assert_eq!(0x0000, frame.sequence_control); - - assert_eq!(REF_HEADER, frame.to_binary()); - } - - #[test] - fn llc_test() { - // build a typical IEEE 802.11p LLC header - const REF_HEADER: [u8; 8] = [0xaa, 0xaa, 0x03, 0x00, 0x00, 0x00, 0x89, 0x47]; - - let llc = LlcHeader::new_80211p(); - - assert_eq!(0xaa, llc.dsap); - assert_eq!(0xaa, llc.lsap); - assert_eq!(0x03, llc.control_1); - assert_eq!([0x00; 3], llc.oui); - assert_eq!(0x8947, llc.protocol_id); - - assert_eq!(REF_HEADER, llc.to_binary()); - } -} diff --git a/src/test.rs b/src/test.rs index 6bb20d2..a7a5bb5 100644 --- a/src/test.rs +++ b/src/test.rs @@ -7,9 +7,7 @@ mod applogic; mod cache; mod geo_alg; mod io; -mod radio; mod testdata; -mod v2x_tx; fn main() { // env_logger::init(); diff --git a/src/ubx.rs b/src/ubx.rs index 934af7b..8e1f32e 100644 --- a/src/ubx.rs +++ b/src/ubx.rs @@ -90,9 +90,9 @@ impl<'a> TryFrom<&ublox::nav_pvt::proto27::NavPvtRef<'a>> for crate::applogic::G let latitude_deg = pos.lat; let longitude_deg = pos.lon; - #[allow(clippy::cast_precision_loss)] + #[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)] let heading_deg = vel.heading as f32; - #[allow(clippy::cast_precision_loss)] + #[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)] let speed_mps = vel.speed as f32; Ok(Self { diff --git a/src/v2x_tx/gn.rs b/src/v2x_tx/gn.rs deleted file mode 100644 index 4b2b0b9..0000000 --- a/src/v2x_tx/gn.rs +++ /dev/null @@ -1,94 +0,0 @@ -//! Geonetworking utilities - -use c_its_parser::gn as geonetworking; - -#[allow(unused)] -pub fn make_shb_eh( - address: [u8; 6], - station_type: geonetworking::en302636_4_1::StationType, - own_position: &crate::applogic::PositionState, - time: chrono::DateTime, -) -> geonetworking::en302636_4_1::ExtendedHeader { - let source_position_vector = make_lpv(address, station_type, own_position, time); - - geonetworking::en302636_4_1::ExtendedHeader::SHB( - geonetworking::en302636_4_1::SingleHopBroadcast { - source_position_vector, - media_dependent_data: [0; 4], - }, - ) -} - -#[allow(unused)] -pub fn make_tsb_eh( - address: [u8; 6], - station_type: geonetworking::en302636_4_1::StationType, - own_position: &crate::applogic::PositionState, - time: chrono::DateTime, - sequence_number: u16, -) -> geonetworking::en302636_4_1::ExtendedHeader { - let source_position_vector = make_lpv(address, station_type, own_position, time); - - geonetworking::en302636_4_1::ExtendedHeader::TSB( - geonetworking::en302636_4_1::TopologicallyScopedBroadcast::new( - sequence_number, - source_position_vector, - ), - ) -} - -#[allow(unused)] -pub fn make_gbc_eh( - address: [u8; 6], - station_type: geonetworking::en302636_4_1::StationType, - own_position: &crate::applogic::PositionState, - time: chrono::DateTime, - target_pos: geo_types::Point, - radius_m: u16, - sequence_number: u16, -) -> geonetworking::en302636_4_1::ExtendedHeader { - let source_position_vector = make_lpv(address, station_type, own_position, time); - #[allow(clippy::cast_possible_truncation)] - let latitude_deg = target_pos.x() as f32; - #[allow(clippy::cast_possible_truncation)] - let longitude_deg = target_pos.y() as f32; - - geonetworking::en302636_4_1::ExtendedHeader::GBC( - geonetworking::en302636_4_1::GeoBroadcast::try_from_values( - sequence_number, - source_position_vector, - latitude_deg, - longitude_deg, - radius_m, - 0, - 0, - ) - .expect("Failed to create circular GBC"), - ) -} - -pub fn make_lpv( - address: [u8; 6], - station_type: geonetworking::en302636_4_1::StationType, - own_position: &crate::applogic::PositionState, - time: chrono::DateTime, -) -> geonetworking::en302636_4_1::LongPositionVector { - let timestamp_its = c_its_parser::time_utils::TimestampIts::from(time); - #[allow(clippy::cast_possible_truncation)] - let latitude_deg = own_position.position.y() as f32; - #[allow(clippy::cast_possible_truncation)] - let longitude_deg = own_position.position.x() as f32; - let speed_mps = own_position.speed_mps.unwrap_or_default(); - let heading_deg = own_position.heading_deg.unwrap_or_default(); - - geonetworking::en302636_4_1::LongPositionVector::try_from_values( - geonetworking::en302636_4_1::Address::new(false, station_type, address), - timestamp_its.0, - latitude_deg, - longitude_deg, - true, - speed_mps, - heading_deg, - ) - .expect("Failed to create LongPositionVector") -} diff --git a/src/v2x_tx/mod.rs b/src/v2x_tx/mod.rs deleted file mode 100644 index ee48578..0000000 --- a/src/v2x_tx/mod.rs +++ /dev/null @@ -1,164 +0,0 @@ -//! V2X Transmission - -use alloc::vec::Vec; - -use c_its_parser::gn::{self as geonetworking, Encode}; - -pub mod gn; - -#[cfg(all(feature = "esp", feature = "cam_tx"))] -/// Creates a station ID from the WLAN MAC address -/// -/// Will use `0xC173` as static prefix and uses last 16 bit from MAC address for the remainder. -pub fn make_station_id(mac: esp_hal::efuse::MacAddress) -> u32 { - // unwrap is fine here since we can be sure that the MacAddress is 6 byte - // and a slice of 2 bytes can be converted to [u8; 2] - let mac_last_16bit = u16::from_be_bytes(mac.as_bytes().get(4..6).unwrap().try_into().unwrap()); - - 0xC173_0000 | u32::from(mac_last_16bit) -} - -pub trait GnItsPayload { - // common GN parameters - const LIFETIME_MILLIS: u32 = 60_000; // itsGnDefaultPacketLifetime is 60 seconds according to ETSI EN 302 636-4-1 V1.4.1 - - // --------------------------------------------- - // methods to build a GN packet with ITS payload - // --------------------------------------------- - - /// Builds the [`geonetworking::en302636_4_1::ExtendedHeader`] and additional information for the ITS message - /// - /// Output: - /// - Extended header - /// - Area type (when using GAC or GBC) - /// - hop limit - /// - /// Note: Make sure to increase the sequence number on multi-hop packets. - fn make_eh( - &self, - address: [u8; 6], - own_position: &crate::applogic::PositionState, - time: chrono::DateTime, - seq_no: &mut u16, - ) -> Result< - ( - geonetworking::en302636_4_1::ExtendedHeader, - Option, - u8, - ), - alloc::string::String, - >; - - /// Creates the ITS payload (without BTP header) - fn make_payload( - &self, - ) -> Result<(Vec, c_its_parser::standards::extensions::ItsMessageId), alloc::string::String>; - - // --------------------------------------------- - // common methods (with implementation) - // --------------------------------------------- - - /// Encodes the GN packet - fn encode_packet( - &self, - address: [u8; 6], - own_position: &crate::applogic::PositionState, - time: chrono::DateTime, - is_mobile: bool, - seq_no: &mut u16, - ) -> Result, alloc::string::String> { - // Build payload (including BTP header) - let (mut its_payload, msg_type) = self.make_payload()?; - let mut payload = make_btp_b(msg_type).encode()?; - - payload.append(&mut its_payload); - - #[allow(clippy::cast_possible_truncation)] - let payload_length = (payload.len()) as u16; - - // get extended header (depending on message type and sometimes content as well) - let (eh, area_type, maximum_hop_limit) = - self.make_eh(address, own_position, time, seq_no)?; - - // Build GN headers - let traffic_class = geonetworking::en302636_4_1::TrafficClass::try_new( - false, - false, - make_traffic_class(msg_type), - ) - .map_err(|err| alloc::format!("Failed to create TrafficClass: {err:?}"))?; - let common = geonetworking::en302636_4_1::CommonHeader::from_values( - geonetworking::en302636_4_1::NextAfterCommon::BTPB, - geonetworking::en302636_4_1::HeaderType::new_from(&eh, area_type)?, - traffic_class, - is_mobile, - payload_length, - maximum_hop_limit, - ); - - let remaining_hop_limit = maximum_hop_limit; - let basic = geonetworking::en302636_4_1::BasicHeader::try_new( - 1, - geonetworking::en302636_4_1::NextAfterBasic::CommonHeader, - geonetworking::en302636_4_1::Lifetime::from_milliseconds(Self::LIFETIME_MILLIS), - remaining_hop_limit, - ) - .map_err(|err| alloc::format!("Failed to create BasicHeader: {err}"))?; - - // Assemble and encode packet - let packet = geonetworking::Packet::Unsecured { - basic, - common, - extended: Some(eh), - payload: &payload, - }; - - packet - .encode_to_vec() - .map_err(|err| alloc::format!("Failed to encode GN packet: {err:?}")) - } -} - -/// Creates a BTP-B header based on the ITS message ID (message type) -/// -/// Port numbers and destination port info according to ETSI TS 103 248 -fn make_btp_b( - msg_type: c_its_parser::standards::extensions::ItsMessageId, -) -> c_its_parser::transport::TransportHeader { - let (destination_port, destination_port_info) = match msg_type { - c_its_parser::standards::extensions::ItsMessageId::Denm => (2002, 0), - c_its_parser::standards::extensions::ItsMessageId::Cam => (2001, 0), - c_its_parser::standards::extensions::ItsMessageId::Mapem => (2003, 0), - c_its_parser::standards::extensions::ItsMessageId::Spatem => (2004, 0), - c_its_parser::standards::extensions::ItsMessageId::Saem => (2005, 0), - c_its_parser::standards::extensions::ItsMessageId::Ivim => (2006, 0), - c_its_parser::standards::extensions::ItsMessageId::Srem => (2007, 0), - c_its_parser::standards::extensions::ItsMessageId::Ssem => (2008, 0), - c_its_parser::standards::extensions::ItsMessageId::Cpm => (2009, 0), - c_its_parser::standards::extensions::ItsMessageId::Poi => (2010, 0), - c_its_parser::standards::extensions::ItsMessageId::EvRsr => (2012, 0), - c_its_parser::standards::extensions::ItsMessageId::Rtcmem => (2013, 0), - _ => (0, 0), // no information found for the subsequent types - }; - - c_its_parser::transport::TransportHeader::BtpB(c_its_parser::transport::BasicTransportBHeader { - destination_port, - destination_port_info, - }) -} - -/// Creates a traffic class ID based on the ITS message ID (message type) -fn make_traffic_class(msg_type: c_its_parser::standards::extensions::ItsMessageId) -> u8 { - #[allow(clippy::match_same_arms)] - match msg_type { - c_its_parser::standards::extensions::ItsMessageId::Denm => 1, - c_its_parser::standards::extensions::ItsMessageId::Cam => 2, - c_its_parser::standards::extensions::ItsMessageId::Mapem => 3, // from C-Roads RS_RSP_063(1) - c_its_parser::standards::extensions::ItsMessageId::Spatem => 3, // from C-Roads RS_RSP_063(1) - c_its_parser::standards::extensions::ItsMessageId::Ivim => 3, // from C-Roads RS_RSP_063(1) - c_its_parser::standards::extensions::ItsMessageId::Srem => 2, // from C-Roads RS_RSP_063(1) - c_its_parser::standards::extensions::ItsMessageId::Ssem => 2, // from C-Roads RS_RSP_063(1) - c_its_parser::standards::extensions::ItsMessageId::Rtcmem => 3, // CSP_MaxPrio=252 -> 3 - _ => 3, // fall-back to lowest priority - } -}