From afe19e7f3e4b410785dc546436a4dda3a46b6131 Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Mon, 27 Jul 2026 09:48:44 +0200 Subject: [PATCH 01/14] TMP: Add message generator --- Cargo.toml | 6 +++ src/test-proto.rs | 96 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 src/test-proto.rs diff --git a/Cargo.toml b/Cargo.toml index 9b3cbd9..45a5a52 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,12 @@ name = "test" path = "./src/test.rs" required-features = ["std", "cam"] +[[bin]] +## run with cargo run --target host-tuple --bin test-proto --no-default-features -F std,uart,cam +name = "test-proto" +path = "./src/test-proto.rs" +required-features = ["std", "uart", "cam"] + [features] default = ["esp", "spat", "gnss_ubx", "screen"] diff --git a/src/test-proto.rs b/src/test-proto.rs new file mode 100644 index 0000000..6513428 --- /dev/null +++ b/src/test-proto.rs @@ -0,0 +1,96 @@ +#![cfg(not(target_arch = "riscv32"))] +#![allow(unused)] + +extern crate alloc; + +mod applogic; +mod io; + +use esp32_cits_core::tx; + +fn main() { + use prost::Message as _; + + // env_logger::init(); + + // generate some test UART messages as hex strings + let raw_its = input_from_raw(io::msg::RawMsgTx { + payload: [0xaa, 0xbb, 0xcc, 0xdd].to_vec(), + message_type: 1, + gn_transport: io::msg::GnTransport::Shb.into(), + gn_area: io::msg::GnArea { + area: None, + position: None, + dist_a: None, + dist_b: None, + angle: None, + }, + hop_limit: 2, + station_type: None, + }); + println!("{}", serialize_serial_msg_hex(&raw_its)); + + let pos = io::msg::PositionState { + timestamp_ms: 1785144196000, + position: io::msg::Position { + latitude_deg: 42., + longitude_deg: 23., + }, + heading: None, + speed: None, + }; + println!("{}", serialize_serial_msg_hex(&input_from_position(pos))); + + // generate test BLE message as hex strings + println!("{}", buf_to_hex(&pos.encode_to_vec())); +} + +fn input_from_raw(value: io::msg::RawMsgTx) -> io::msg::SerialInputMsg { + use io::msg::serial_input_msg::Payload; + + io::msg::SerialInputMsg { + payload: Some(Payload::TxMsg(value)), + } +} +fn input_from_position(value: io::msg::PositionState) -> io::msg::SerialInputMsg { + use io::msg::serial_input_msg::Payload; + + io::msg::SerialInputMsg { + payload: Some(Payload::Position(value)), + } +} + +pub(crate) fn serialize_serial_msg_hex(msg: &io::msg::SerialInputMsg) -> alloc::string::String { + use prost::Message as _; + + let mut msg_buf = msg.encode_to_vec(); + + let sof = [0x56u8, 0x32, 0x58, 0x2B]; + let eof = [0x0du8, 0x0a]; + 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); + + buf_to_hex(&out) +} + +pub(crate) fn buf_to_hex(data: &[u8]) -> alloc::string::String { + let mut hex_str = alloc::string::String::default(); + for c in data { + hex_str.push_str(format!("{c:02x}").as_str()); + } + + hex_str +} + +// /// 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(); +// } From 17b84d9aece37ff08d5315ae3fb0f8d4494e625c Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Mon, 11 May 2026 21:08:23 +0200 Subject: [PATCH 02/14] Add basic BLE I/O --- Cargo.lock | 183 ++++++++++++++ Cargo.toml | 18 +- Readme.md | 11 +- docs/ble-protocol.md | 50 ++++ docs/c_its-messages.proto | 2 +- feat-permut.sh | 2 + run-smartphone-ble.sh | 6 + run-smartphone-both.sh | 6 + run-smartphone.sh => run-smartphone-uart.sh | 2 +- src/applogic/mod.rs | 1 + src/ble.rs | 263 ++++++++++++++++++++ src/io.rs | 238 +++++++++++++++++- src/main.rs | 47 +++- 13 files changed, 806 insertions(+), 23 deletions(-) create mode 100755 run-smartphone-ble.sh create mode 100755 run-smartphone-both.sh rename run-smartphone.sh => run-smartphone-uart.sh (69%) create mode 100644 src/ble.rs diff --git a/Cargo.lock b/Cargo.lock index 98a4c6a..4d9cb80 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -166,6 +166,29 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bt-hci" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "211713d2e9fb4793ce4360a712c0764264aff6be48932ccf02ca2a331c0436a9" +dependencies = [ + "btuuid", + "embassy-sync 0.8.0", + "embedded-io 0.7.1", + "embedded-io-async 0.7.0", + "futures-intrusive", + "heapless 0.9.2", +] + +[[package]] +name = "btuuid" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5f48f1e9b0aad0a4f05d17bdeae0fa20ff798e272a03a6940ca27ad9c5a6ae7" +dependencies = [ + "uuid", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -244,6 +267,15 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b396d1f76d455557e1218ec8066ae14bba60b4b36ecd55577ba979f5db7ecaa" +[[package]] +name = "convert_case" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaaa0ecca5b51987b9423ccdc971514dd8b0bb7b4060b983d3664dad3f1f89f" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "cordyceps" version = "0.3.4" @@ -956,6 +988,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23fbff98b06a96b6ce3791ecec5c668524052a068e23aacd23afe17ddba844ce" dependencies = [ "allocator-api2", + "bt-hci", "cfg-if", "docsplay", "document-features", @@ -1152,11 +1185,14 @@ name = "esp32-c_its-companion" version = "0.1.0" dependencies = [ "assert_float_eq", + "bt-hci", "c-its-parser", "chrono", "critical-section", "crossbeam-queue", "embassy-executor", + "embassy-futures", + "embassy-sync 0.7.2", "embassy-time", "embassy_gps", "embedded-graphics", @@ -1179,6 +1215,8 @@ dependencies = [ "profont", "prost", "prost-build", + "static_cell", + "trouble-host", "ublox", ] @@ -1320,12 +1358,52 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + [[package]] name = "futures-core" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + [[package]] name = "futures-sink" version = "0.3.32" @@ -1345,8 +1423,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", + "futures-sink", "futures-task", "pin-project-lite", + "slab", ] [[package]] @@ -1561,6 +1641,18 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "js-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1603,6 +1695,15 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.29" @@ -2184,6 +2285,12 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "semver" version = "1.0.28" @@ -2261,6 +2368,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "smallvec" version = "1.15.1" @@ -2309,6 +2422,15 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_cell" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0530892bb4fa575ee0da4b86f86c667132a94b74bb72160f58ee5a4afec74c23" +dependencies = [ + "portable-atomic", +] + [[package]] name = "strsim" version = "0.11.1" @@ -2498,6 +2620,39 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "trouble-host" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1df7817cead4b83dfbeeaa59736ecbc97c30b21dc3bfc0cc2ce8a6687f70b37a" +dependencies = [ + "bt-hci", + "embassy-futures", + "embassy-sync 0.7.2", + "embassy-time", + "embedded-io 0.7.1", + "futures", + "heapless 0.9.2", + "rand_core 0.6.4", + "static_cell", + "trouble-host-macros", + "zerocopy", +] + +[[package]] +name = "trouble-host-macros" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d809ae05f510cf8f1d863749281eb28e049d9ae4ad52d92ea7a0ac068a0e17c" +dependencies = [ + "convert_case", + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.117", + "uuid", +] + [[package]] name = "typenum" version = "1.20.0" @@ -2539,6 +2694,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + [[package]] name = "unicode-width" version = "0.1.14" @@ -2570,6 +2731,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ "getrandom", + "js-sys", + "wasm-bindgen", ] [[package]] @@ -2875,6 +3038,26 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zerocopy" +version = "0.8.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bce33a6288fa3f072a8c2c7d0f2fdbb90e28298f0135c1f99b96c3db2efcc60b" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd425244944f4ab65ccff928e7323354c5a018c75838362fdce749dfad2ee1e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index 9b3cbd9..aae53d0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ path = "./src/test.rs" required-features = ["std", "cam"] [features] -default = ["esp", "spat", "gnss_ubx", "screen"] +default = ["esp", "spat", "gnss_ubx", "screen", "ble"] # build for ESP (needed to build test for `host-tuple`) esp = [ @@ -23,6 +23,8 @@ esp = [ "dep:esp-rtos", "dep:esp-bootloader-esp-idf", "dep:embassy-executor", + "dep:embassy-futures", + "dep:embassy-sync", "dep:embassy-time", "dep:embedded-io", "dep:embedded-io-async", @@ -30,6 +32,7 @@ esp = [ "dep:esp-backtrace", "dep:esp-println", "dep:esp-radio", + "dep:bt-hci", "esp32-cits-core/esp32c5", "esp32-cits-core/log-04", ] @@ -52,7 +55,9 @@ 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"] +uart = ["_uart", "_gnss"] +# enable I/O via BLE +ble = ["_gnss", "denm"] # internal feature, that any UART I/O is enabled _uart = [] @@ -66,6 +71,7 @@ esp-rtos = { version = "0.3.0", optional = true, features = [ "embassy", "esp-alloc", "esp-radio", + "embassy", "esp32c5", "log-04", ] } @@ -74,7 +80,10 @@ esp-bootloader-esp-idf = { version = "0.5.0", optional = true, features = ["esp3 log = "0.4.27" critical-section = "1.2.0" +bt-hci = { version = "0.8.0", optional = true } embassy-executor = { version = "0.10.0", optional = true, features = ["log"] } +embassy-futures = { version = "0.1.2", optional = true } +embassy-sync = { version = "0.7", optional = true } 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 } @@ -92,7 +101,10 @@ esp-radio = { version = "0.18.0", optional = true, features = [ "unstable", "wifi", "sniffer", + "ble", ] } +trouble-host = { version = "0.6.0", features = ["gatt", "central", "derive", "default-packet-pool"] } +static_cell = "2" 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" } @@ -111,7 +123,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", optional = true, default-features = false, features = ["derive"] } +prost = { version = "0.14", default-features = false, features = ["derive"] } ublox = { version = "0.10", optional = true, default-features = false, features = ["alloc", "ubx_proto27"] } [dev-dependencies] diff --git a/Readme.md b/Readme.md index 2ae3aab..c2ecbab 100644 --- a/Readme.md +++ b/Readme.md @@ -55,6 +55,12 @@ This is **not** implemented in this application! ### UART Enable UART I/O protocol according to [docs/uart-protocol.md](./docs/uart-protocol.md). +### BLE +Enable BLE I/O protocol according to [docs/ble-protocol.md](./docs/ble-protocol.md). +Will enable `denm` feature, but also enabling `cam` is recommended for full functionality. + +Note: Raw message reception is currently not implemented! + ## Usage @@ -70,7 +76,7 @@ rustup target add riscv32imac-unknown-none-elf cargo install espflash --locked ``` -(Project was setup using esp-generate with the options `--chip esp32c5 -o esp32c5-wroom-1-psram -o alloc -o log -o unstable-hal -o wifi -o esp-backtrace -o embassy`.) +(Project was setup using esp-generate with the options `--chip esp32c5 -o esp32c5-wroom-1-psram -o alloc -o log -o unstable-hal -o wifi -o esp-backtrace -o embassy -o ble-trouble`.) To use the proper rustfmt features, enable the nightly toolchain: ```shell @@ -93,7 +99,8 @@ Officially supported feature combinations are: - `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") +- `esp,uart,cam,denm` ("smartphone companion with UART") +- `esp,ble,cam,denm,cam_tx` ("smartphone companion with BLE") Connect your ESP32-C5 and run: diff --git a/docs/ble-protocol.md b/docs/ble-protocol.md index 65f68ff..4560424 100644 --- a/docs/ble-protocol.md +++ b/docs/ble-protocol.md @@ -35,15 +35,65 @@ All data types are defined in one `.proto` file: [c_its-messages.proto](./c_its- 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)). +Maximum message size (assuming realistic value ranges): + +field name | tag size | (max.) data size +----------------- | -------- | --------- +timestamp_ms | 1 | 8 (I64) +position | 1 | 1 (LEN(10)) + sub-fields below +\+ latitude_deg | 1 | 4 (I32) +\+ longitude_deg | 1 | 4 (I32) +heading | 1 | 2 (VARINT(3600)) +speed | 1 | 2 (VARINT(~163 m/s)) +**SUM** | 6 + | 21 = 27 byte + + ### 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. +Maximum message size (assuming realistic value ranges): + +field name | tag size | (max.) data size +----------------- | -------- | --------- +timestamp | 1 | 8 (I64) +validity_end_ts | 1 | 8 (I64) +station_id | 1 | 8 (I64) +seq_num | 1 | 3 (VARINT(65535)) +event_position | 1 | 1 (LEN(10)) + sub-fields below +\+ latitude_deg | 1 | 4 (I32) +\+ longitude_deg | 1 | 4 (I32) +event_heading_deg | 1 | 1 (VARINT(360)) +direction | 1 | 1 (VARINT(3)) +cause_code | 1 | 1 (VARINT(127)) +sub_cause_code | 1 | 1 (VARINT(127)) +**SUM** | 11 + | 40 = 51 byte + +Note: Cause code and sub-cause code are technically 0..255 in the ASN.1 spec, but that's mainly to ensure backwards-compatibility of the CHOICE with the old wire format. +Realistically only cause codes up to 100 are defined and common sub-cause codes use values up to 22. + ### CAM Event Each received (low-frequency) CAM will be "published" as a `CamEvent` message (see [c_its-messages.proto](./c_its-messages.proto)). +Maximum message size (assuming realistic value ranges): + +field name | tag size | (max.) data size +--------------------- | -------- | --------- +station_id | 1 | 8 (I64) +station_type | 1 | 1 (VARINT(15)) +position | 1 | 1 (LEN(10)) + sub-fields below +\+ latitude_deg | 1 | 4 (I32) +\+ longitude_deg | 1 | 4 (I32) +vehicle_role | 1 | 1 (VARINT(15)) +vehicle_data | 1 | 1 (LEN(11)) + sub-fields below +\+ heading_deg | 1 | 2 (VARINT(360)) +\+ speed | 1 | 2 (VARINT(~163 m/s)) +\+ vehicle_length_dm | 1 | 2 (VARINT(1023)) +\+ vehicle_width_dm | 1 | 1 (VARINT(62)) +**SUM** | 11 + | 27 = 38 byte + ### Raw Received Message Received V2X messages will be "published" as a `RawMsgRx` message (see [c_its-messages.proto](./c_its-messages.proto)). diff --git a/docs/c_its-messages.proto b/docs/c_its-messages.proto index af8f802..47e523d 100644 --- a/docs/c_its-messages.proto +++ b/docs/c_its-messages.proto @@ -83,7 +83,7 @@ message PositionState { } message DenmEvent { - required fixed64 timestamp = 1; // DENM reference time as UNIX time + required fixed64 timestamp = 1; // DENM reference time as UNIX time (in seconds) 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` diff --git a/feat-permut.sh b/feat-permut.sh index 9c08d65..1ef6b62 100755 --- a/feat-permut.sh +++ b/feat-permut.sh @@ -9,6 +9,8 @@ extended=${2:-false} features_base=( uart,cam,denm + ble,cam,denm,cam_tx + uart,ble,cam,denm spat,gnss_ubx,screen spat,gnss_ubx,screen,cam_tx ) diff --git a/run-smartphone-ble.sh b/run-smartphone-ble.sh new file mode 100755 index 0000000..266ee7e --- /dev/null +++ b/run-smartphone-ble.sh @@ -0,0 +1,6 @@ +#!/bin/sh + +## Builds common config for smartphone connected device +## -> receive CAM and DENMs via BLE and send CAMs based on position data received from BLE + +cargo run --release --no-default-features -F esp,cam,denm,ble,cam_tx diff --git a/run-smartphone-both.sh b/run-smartphone-both.sh new file mode 100755 index 0000000..e6c067b --- /dev/null +++ b/run-smartphone-both.sh @@ -0,0 +1,6 @@ +#!/bin/sh + +## Builds common config for smartphone connected device +## -> only receive CAM and DENMs via UART and BLE + +cargo run --release --no-default-features -F esp,cam,denm,uart,ble diff --git a/run-smartphone.sh b/run-smartphone-uart.sh similarity index 69% rename from run-smartphone.sh rename to run-smartphone-uart.sh index ee5170d..b54fb6d 100755 --- a/run-smartphone.sh +++ b/run-smartphone-uart.sh @@ -1,6 +1,6 @@ #!/bin/sh ## Builds common config for smartphone connected device -## -> only receive CAM and DENMs via UART (TODO: and BLE) +## -> only receive CAM and DENMs via UART cargo run --release --no-default-features -F esp,cam,denm,uart diff --git a/src/applogic/mod.rs b/src/applogic/mod.rs index 70cc086..7e7ebc4 100644 --- a/src/applogic/mod.rs +++ b/src/applogic/mod.rs @@ -263,6 +263,7 @@ impl State { } #[cfg(feature = "spat")] + #[allow(clippy::too_many_lines)] pub fn run_glosa(&mut self) -> GlosaOutput { let Some(own_heading) = self.pos.heading_deg else { use alloc::string::ToString; diff --git a/src/ble.rs b/src/ble.rs new file mode 100644 index 0000000..b585e86 --- /dev/null +++ b/src/ble.rs @@ -0,0 +1,263 @@ +#![allow( + clippy::needless_borrows_for_generic_args, + reason = "gatt_service creates false positives" +)] + +use core::cell::RefCell; + +use critical_section::Mutex; +use esp_backtrace as _; +use log::{info, warn}; +use trouble_host::prelude::*; + +use crate::io; + +pub const CONTROLLER_SLOTS: usize = 20; +const CONNECTIONS_MAX: usize = 1; +const L2CAP_CHANNELS_MAX: usize = 1; + +pub static TX_DATA: Mutex>> = Mutex::new(RefCell::new(None)); +pub static RX_DATA: Mutex>> = Mutex::new(RefCell::new(None)); + +pub enum BleSendable { + #[cfg(feature = "cam")] + Cam(io::msg::CamEvent), + #[cfg(feature = "denm")] + Denm(io::msg::DenmEvent), +} + +#[gatt_server] +struct Server { + c_its_events: CITSEvents, +} + +#[cfg(feature = "cam")] +const CAM_EVENT_SIZE: usize = 38; +#[cfg(feature = "denm")] +const DENM_EVENT_SIZE: usize = 51; + +#[gatt_service(uuid = "c0b70000-d4f4-4000-ada8-f99a02ee315c")] +struct CITSEvents { + #[descriptor(uuid = descriptors::MEASUREMENT_DESCRIPTION, name = "Position", read, value = "PositionState proto msg", type = &'static str)] + #[characteristic(uuid = "c0b70001-d4f4-4001-ada8-f99a02ee315c", write)] + position: [u8; 27], + + #[cfg(feature = "cam")] + #[descriptor(uuid = descriptors::MEASUREMENT_DESCRIPTION, name = "CAM_Event", read, value = "CamEvent proto msg", type = &'static str)] + #[characteristic(uuid = "c0b70001-d4f4-4002-ada8-f99a02ee315c", read, notify, value = [0u8; CAM_EVENT_SIZE])] + cam_event: [u8; CAM_EVENT_SIZE], + + #[cfg(feature = "denm")] + #[descriptor(uuid = descriptors::MEASUREMENT_DESCRIPTION, name = "DENM_Event", read, value = "DenmEvent proto msg", type = &'static str)] + #[characteristic(uuid = "c0b70001-d4f4-4004-ada8-f99a02ee315c", read, notify, value = [0u8; DENM_EVENT_SIZE])] + denm_event: [u8; DENM_EVENT_SIZE], +} + +/// Creates the BLE controller +pub fn make_controller( + bt_peripheral: esp_hal::peripherals::BT<'static>, +) -> bt_hci::controller::ExternalController< + esp_radio::ble::controller::BleConnector<'static>, + CONTROLLER_SLOTS, +> { + let transport = esp_radio::ble::controller::BleConnector::new( + bt_peripheral, + esp_radio::ble::Config::default(), + ) + .unwrap(); + bt_hci::controller::ExternalController::<_, { CONTROLLER_SLOTS }>::new(transport) +} + +/// Run the BLE stack. +#[embassy_executor::task] +pub async fn run( + controller: bt_hci::controller::ExternalController< + esp_radio::ble::controller::BleConnector<'static>, + CONTROLLER_SLOTS, + >, +) { + let mut resources: HostResources = + HostResources::new(); + let stack = trouble_host::new(controller, &mut resources); + let Host { + mut peripheral, + runner, + .. + } = stack.build(); + + info!("Starting advertising and GATT service"); + let server = Server::new_with_config(GapConfig::Peripheral(PeripheralConfig { + name: "ESP32 C-ITS", + appearance: &appearance::UNKNOWN, + })) + .unwrap(); + + let _ = embassy_futures::join::join(ble_task(runner), async { + loop { + match advertise("ESP32 C-ITS", &mut peripheral, &server).await { + Ok(conn) => { + // set up tasks when the connection is established to a central, so they don't run when no one is connected. + let a = gatt_events_task(&server, &conn); + let b = custom_task(&server, &conn); + // run until any task ends (usually because the connection has been closed), + // then return to advertising state. + embassy_futures::select::select(a, b).await; + } + Err(e) => { + panic!("BLE ADV: fatal error {e:?}"); + } + } + } + }) + .await; +} + +/// Create an advertiser to use to connect to a BLE Central, and wait for it to connect. +async fn advertise<'values, 'server, C: Controller>( + name: &'values str, + peripheral: &mut Peripheral<'values, C, DefaultPacketPool>, + server: &'server Server<'values>, +) -> Result, BleHostError> { + let mut advertiser_data = [0; 35]; + let len = AdStructure::encode_slice( + &[ + AdStructure::Flags(LE_GENERAL_DISCOVERABLE | BR_EDR_NOT_SUPPORTED), + AdStructure::CompleteLocalName(name.as_bytes()), + ], + &mut advertiser_data[..], + )?; + + let advertiser = peripheral + .advertise( + &trouble_host::advertise::AdvertisementParameters { + timeout: Some(embassy_time::Duration::from_secs(2)), + ..Default::default() + }, + Advertisement::ConnectableScannableUndirected { + adv_data: &advertiser_data[..len], + scan_data: &[], + }, + ) + .await?; + // info!("[adv] advertising {len} bytes"); + let conn = advertiser.accept().await?.with_attribute_server(server)?; + // info!("[adv] connection established"); + Ok(conn) +} + +async fn ble_task(mut runner: Runner<'_, C, P>) { + loop { + if let Err(e) = runner.run().await { + panic!("BLE Task: fatal error {e:?}"); + } + } +} + +/// Stream Events until the connection closes. +/// +/// This function will handle the GATT events and process them. +/// This is how we interact with read and write requests. +async fn gatt_events_task( + server: &Server<'_>, + conn: &GattConnection<'_, '_, P>, +) -> Result<(), Error> { + let reason = loop { + match conn.next().await { + GattConnectionEvent::Disconnected { reason } => break reason, + GattConnectionEvent::Gatt { event } => { + match &event { + GattEvent::Write(event) + if event.handle() == server.c_its_events.position.handle => + { + use prost::Message as _; + + // parse and send to main thread + match io::msg::PositionState::decode(event.data()) { + Ok(data) => { + critical_section::with(|cs| { + let rx_data_ref = RX_DATA.borrow(cs); + let _ = rx_data_ref.replace(Some(data)); + }); + } + Err(err) => log::error!("BLE: Failed to parse input: {err:?}"), + } + } + + _ => {} + } + + // This step is also performed at drop(), but writing it explicitly is necessary + // in order to ensure reply is sent. + match event.accept() { + Ok(reply) => reply.send().await, + Err(e) => warn!("BLE: error sending response: {e:?}"), + } + } + _ => {} // ignore other Gatt Connection Events + } + }; + + info!("BLE: disconnected b/c {reason:?}"); + Ok(()) +} + +/// Example task to use the BLE notifier interface. +/// This task will notify the connected central of a counter value every 2 seconds. +/// It will also read the RSSI value every 2 seconds. +/// and will stop when the connection is closed by the central or an error occurs. +async fn custom_task(server: &Server<'_>, conn: &GattConnection<'_, '_, P>) { + loop { + embassy_time::Timer::after(embassy_time::Duration::from_millis(10)).await; + + let mut new_data = None; + critical_section::with(|cs| { + let tx_data_ref = TX_DATA.borrow(cs); + + if let Some(tx_data) = tx_data_ref.replace(None) { + new_data = Some(tx_data); + } + }); + + match new_data { + #[cfg(feature = "cam")] + Some(BleSendable::Cam(cam_event)) => { + let mut buf = cam_event.serialize_ble_proto(); + + if buf.len() <= CAM_EVENT_SIZE { + // pad with zeros + let pad = alloc::vec![0u8; CAM_EVENT_SIZE - buf.len()]; + buf.extend_from_slice(&pad); + + // unwrap should be fine since we ensured the vector size before + let value = buf.try_into().unwrap(); + + if let Err(err) = server.c_its_events.cam_event.notify(conn, &value).await { + warn!("BLE: failed notifying connection of new CAM event: {err:?}"); + } + } else { + warn!("BLE: CAM event too big with {} bytes", buf.len()); + } + } + #[cfg(feature = "denm")] + Some(BleSendable::Denm(denm_event)) => { + let mut buf = denm_event.serialize_ble_proto(); + + if buf.len() <= DENM_EVENT_SIZE { + // pad with zeros + let pad = alloc::vec![0u8; DENM_EVENT_SIZE - buf.len()]; + buf.extend_from_slice(&pad); + + // unwrap should be fine since we ensured the vector size before + let value = buf.try_into().unwrap(); + + if let Err(err) = server.c_its_events.denm_event.notify(conn, &value).await { + warn!("BLE: failed notifying connection of new DENM event: {err:?}"); + } + } else { + warn!("BLE: DENM event too big with {} bytes", buf.len()); + } + } + None => {} + } + } +} diff --git a/src/io.rs b/src/io.rs index 44997ee..3ef8c6d 100644 --- a/src/io.rs +++ b/src/io.rs @@ -1,16 +1,14 @@ -//! UART/ BLE I/O Protocol +//! UART and 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")); } -use alloc::string::ToString; -use alloc::vec::Vec; - +#[cfg(feature = "uart")] use c_its_parser::gn as geonetworking; -use log::error; +#[cfg(feature = "uart")] impl From for msg::SerialOutputMsg { fn from(value: msg::RawMsgRx) -> Self { use msg::serial_output_msg::Payload; @@ -21,6 +19,7 @@ impl From for msg::SerialOutputMsg { } } +#[cfg(feature = "uart")] impl From for msg::SerialOutputMsg { fn from(value: msg::DenmEvent) -> Self { use msg::serial_output_msg::Payload; @@ -32,22 +31,39 @@ impl From for msg::SerialOutputMsg { } impl msg::RawMsgRx { - pub fn serialize_uart_proto(&self) -> Vec { + #[cfg(feature = "uart")] + pub fn serialize_uart_proto(&self) -> alloc::vec::Vec { serialize_serial_msg(&Into::::into(self.clone())) } } + +#[cfg(feature = "denm")] impl msg::DenmEvent { - #[allow(unused)] - pub fn serialize_uart_proto(&self) -> Vec { - serialize_serial_msg(&Into::::into(*self)) + #[cfg(feature = "ble")] + pub fn serialize_ble_proto(&self) -> alloc::vec::Vec { + use prost::Message as _; + + self.encode_to_vec() } } +#[cfg(feature = "cam")] +impl msg::CamEvent { + #[cfg(feature = "ble")] + pub fn serialize_ble_proto(&self) -> alloc::vec::Vec { + use prost::Message as _; + + self.encode_to_vec() + } +} + +#[cfg(feature = "uart")] #[derive(Debug, Default)] pub struct Parser { buffer: alloc::vec::Vec, } +#[cfg(feature = "uart")] impl Parser { const SOF_LEN: u16 = 4; const SOF1: u8 = 0x56; @@ -91,7 +107,7 @@ impl Parser { Ok(data) => { output.push(data); } - Err(err) => error!("Failed to parse input: {err:?}"), + Err(err) => log::error!("Failed to parse input: {err:?}"), } // consume buffer @@ -122,7 +138,8 @@ impl Parser { } } -fn serialize_serial_msg(msg: &T) -> Vec +#[cfg(feature = "uart")] +fn serialize_serial_msg(msg: &T) -> alloc::vec::Vec where T: prost::Message, { @@ -141,6 +158,7 @@ where out } +#[cfg(any(feature = "uart", feature = "ble"))] impl From for crate::applogic::GnssFix { fn from(value: msg::PositionState) -> Self { let time = @@ -169,6 +187,7 @@ impl From for crate::applogic::GnssFix { } } +#[cfg(feature = "uart")] impl From for esp32_cits_core::PosVel { fn from(value: msg::PositionState) -> Self { let position = geo_types::Point::new( @@ -194,6 +213,7 @@ impl From for esp32_cits_core::PosVel { } } +#[cfg(feature = "uart")] impl From for esp32_cits_core::PosVel { fn from(value: msg::ItsPosition) -> Self { let latitude_deg = f64::from(value.latitude) / 10_000_000.; @@ -208,6 +228,7 @@ impl From for esp32_cits_core::PosVel { } } +#[cfg(feature = "uart")] impl esp32_cits_core::tx::GnItsPayload for msg::RawMsgTx { fn make_eh( &self, @@ -223,6 +244,8 @@ impl esp32_cits_core::tx::GnItsPayload for msg::RawMsgTx { ), alloc::string::String, > { + use alloc::string::ToString as _; + let station_type = self .station_type .and_then(|v| { @@ -296,14 +319,20 @@ impl esp32_cits_core::tx::GnItsPayload for msg::RawMsgTx { fn make_payload( &self, - ) -> Result<(Vec, c_its_parser::standards::extensions::ItsMessageId), alloc::string::String> - { + ) -> Result< + ( + alloc::vec::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)) } } +#[cfg(feature = "uart")] impl msg::RawMsgTx { fn make_geobcast( address: [u8; 6], @@ -384,12 +413,192 @@ impl msg::RawMsgTx { } } +#[cfg(feature = "cam")] +impl From> + for msg::CamEvent +{ + fn from( + value: alloc::boxed::Box, + ) -> Self { + use c_its_parser::standards::cam_1_4_1::cam_pdu_descriptions; + + let station_id = value.header.station_id.0; + let station_type = u32::from(value.cam.cam_parameters.basic_container.station_type.0); + let position = value + .cam + .cam_parameters + .basic_container + .reference_position + .into(); + + let vehicle_role = value + .cam + .cam_parameters + .low_frequency_container + .and_then(|v| match v { + cam_pdu_descriptions::LowFrequencyContainer::basicVehicleContainerLowFrequency( + basic_vehicle_container_low_frequency, + ) => Some(basic_vehicle_container_low_frequency.vehicle_role as u32), + _ => None, + }); + + let vehicle_data = match value.cam.cam_parameters.high_frequency_container { + cam_pdu_descriptions::HighFrequencyContainer::basicVehicleContainerHighFrequency( + basic_vehicle_container_high_frequency, + ) => Some(basic_vehicle_container_high_frequency.into()), + _ => None, + }; + + Self { + station_id, + station_type, + position, + vehicle_role, + vehicle_data, + } + } +} + +#[cfg(feature = "cam")] +impl From for msg::VehicleContainer { + fn from(value: c_its_parser::standards::cam_1_4_1::cam_pdu_descriptions::BasicVehicleContainerHighFrequency) -> Self { + let heading_deg = if value.heading.heading_value.is_unavailable() { + None + } else { + Some(u32::from(value.heading.heading_value.0)) + }; + let speed = if value.speed.speed_value.is_unavailable() { + None + } else { + Some(u32::from(value.speed.speed_value.0)) + }; + let vehicle_length_dm = if value.vehicle_length.vehicle_length_value.is_unavailable() { + None + } else { + Some(u32::from(value.vehicle_length.vehicle_length_value.0)) + }; + let vehicle_width_dm = if value.vehicle_width.is_unavailable() { + None + } else { + Some(u32::from(value.vehicle_width.0)) + }; + + + Self { heading_deg, speed, vehicle_length_dm, vehicle_width_dm } + } +} + +#[cfg(feature = "denm")] +impl From> + for msg::DenmEvent +{ + fn from( + value: alloc::boxed::Box, + ) -> Self { + let denm_mgmt = &value.denm.management; + + let station_id = value.header.station_id.0; // TODO: or from action ID? + + let ref_time = chrono::DateTime::::from(denm_mgmt.reference_time.clone()); + let end_time = ref_time + chrono::Duration::seconds(denm_mgmt.validity_duration.0.into()); + + #[allow(clippy::cast_sign_loss)] + let timestamp = ref_time.timestamp_millis() as u64; + #[allow(clippy::cast_sign_loss)] + let validity_end_ts = end_time.timestamp_millis() as u64; + let seq_num = denm_mgmt.action_id.sequence_number.0.into(); + + let event_position = Some(denm_mgmt.event_position.clone().into()); + let event_heading_deg = value + .denm + .location + .and_then(|v| v.event_position_heading) + .map(|v| { + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + let conv = v.value.as_deg() as u32; + conv + }); + let direction = denm_mgmt + .traffic_direction + .map(|v| msg::TrafficDir::from(v) as i32); + let cc_tuple = value + .denm + .situation + .map(|v| v.event_type.cc_and_scc.to_u8_tuple()); + let cause_code = cc_tuple.map(|v| v.0.into()); + let sub_cause_code = cc_tuple.map(|v| v.1.into()); + + Self { + timestamp, + validity_end_ts, + station_id, + seq_num, + event_position, + event_heading_deg, + direction, + cause_code, + sub_cause_code, + } + } +} + +#[cfg(feature = "cam")] +impl From + for msg::Position +{ + fn from(value: c_its_parser::standards::cdd_1_3_1_1::its_container::ReferencePosition) -> Self { + #[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)] + let latitude_deg = value.latitude.as_deg() as f32; + #[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)] + let longitude_deg = value.longitude.as_deg() as f32; + + Self { + latitude_deg, + longitude_deg, + } + } +} + +#[cfg(feature = "denm")] +impl From for msg::Position { + fn from(value: c_its_parser::standards::cdd_2_2_1::etsi_its_cdd::ReferencePosition) -> Self { + #[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)] + let latitude_deg = value.latitude.as_deg() as f32; + #[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)] + let longitude_deg = value.longitude.as_deg() as f32; + + Self { + latitude_deg, + longitude_deg, + } + } +} + +#[cfg(feature = "denm")] +impl From for msg::TrafficDir { + fn from(value: c_its_parser::standards::cdd_2_2_1::etsi_its_cdd::TrafficDirection) -> Self { + use c_its_parser::standards::cdd_2_2_1::etsi_its_cdd::TrafficDirection; + + match value { + TrafficDirection::allTrafficDirections => Self::AllDirections, + TrafficDirection::sameAsReferenceDirection_upstreamOfReferencePosition => { + Self::Upstream + } + TrafficDirection::sameAsReferenceDirection_downstreamOfReferencePosition => { + Self::Downstream + } + TrafficDirection::oppositeToReferenceDirection => Self::Opposite, + } + } +} + #[cfg(test)] mod tests { use super::*; use crate::init_test_env_logger; + #[cfg(feature = "uart")] impl From for msg::SerialInputMsg { fn from(value: msg::RawMsgTx) -> Self { use msg::serial_input_msg::Payload; @@ -400,6 +609,7 @@ mod tests { } } + #[cfg(feature = "uart")] impl From for msg::SerialInputMsg { fn from(value: msg::PositionState) -> Self { use msg::serial_input_msg::Payload; @@ -413,7 +623,9 @@ mod tests { // manual example data: // SerialInputMsg(RawMsgTx): 5632582b0040123e0a340202c173256817284029d5b3400f1feaf01ffffffc23b7743e00000fc0007e8138250737feebfff600000dffff7ffff1ce40400010011807220028020d0a // SerialInputMsg(PositionState): 5632582b00170a1509a0bbe2a29f010000120a0d00002842150000b8410d0a + // PositionState: 09a0bbe2a29f010000120a0d00002842150000b841 + #[cfg(feature = "uart")] #[test] fn uart_parser() { // generate some test UART input messages diff --git a/src/main.rs b/src/main.rs index 67e181c..e37ba83 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,6 +18,9 @@ compile_error!("feature \"cam_tx\" needs a time and position source (either GNSS // 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"); +// at least one V2X feature needs to be enabled +#[cfg(not(any(feature = "spat", feature = "cam", feature = "denm")))] +compile_error!("at least one V2X feature (\"spat\", \"cam\" or \"denm\") needs to be enabled"); use alloc::string::ToString as _; use alloc::vec::Vec; @@ -45,9 +48,11 @@ use log::{error, info, warn}; extern crate alloc; mod applogic; +#[cfg(feature = "ble")] +mod ble; mod cache; mod geo_alg; -#[cfg(feature = "uart")] +#[cfg(any(feature = "uart", feature = "ble"))] mod io; #[cfg(feature = "screen")] mod screen; @@ -89,7 +94,7 @@ static SERIAL: Mutex ! { // generator version: 1.3.0 - // generator parameters: --chip esp32c5 -o esp32c5-wroom-1-psram -o alloc -o log -o unstable-hal -o wifi -o esp-backtrace -o embassy + // generator parameters: --chip esp32c5 -o esp32c5-wroom-1-psram -o alloc -o log -o unstable-hal -o wifi -o esp-backtrace -o embassy -o ble-trouble esp_println::logger::init_logger_from_env(); @@ -107,6 +112,8 @@ async fn main(spawner: Spawner) -> ! { // - GPIO28 esp_alloc::heap_allocator!(#[esp_hal::ram(reclaimed)] size: 65536); + // COEX needs more RAM - so we've added some more + esp_alloc::heap_allocator!(size: 64 * 1024); esp_alloc::psram_allocator!(peripherals.PSRAM, esp_hal::psram); let timg0 = TimerGroup::new(peripherals.TIMG0); @@ -188,6 +195,13 @@ async fn main(spawner: Spawner) -> ! { let mac = esp_hal::efuse::base_mac_address(); info!("WiFi MAC: {mac}"); + // Setup BLE + #[cfg(feature = "ble")] + { + let controller = ble::make_controller(peripherals.BT); + spawner.spawn(ble::run(controller).expect("Failed to spawn BLE task")); + } + // Setup GNSS: // Seeed XIAO L67K: // RX: D7/ GPIO12 @@ -365,6 +379,17 @@ async fn main(spawner: Spawner) -> ! { } } + #[cfg(feature = "ble")] + { + critical_section::with(|cs| { + let mut data_rc = ble::RX_DATA.borrow(cs).borrow_mut(); + if let Some(pos) = data_rc.take() { + let gnss_update_ref = GNSS_UPDATE.borrow(cs); + gnss_update_ref.replace(Some(pos.into())); + } + }); + } + #[cfg(feature = "_gnss")] { let mut new_position = false; @@ -474,6 +499,13 @@ async fn main(spawner: Spawner) -> ! { } => { #[cfg(feature = "cam")] applogic::cam::handle_cam(&etsi); + + // Send data to BLE thread + #[cfg(all(feature = "ble", feature = "cam"))] + critical_section::with(|cs| { + let ble_update_ref = ble::TX_DATA.borrow(cs); + ble_update_ref.replace(Some(ble::BleSendable::Cam(etsi.into()))); + }); } #[cfg(feature = "denm")] @@ -481,7 +513,16 @@ async fn main(spawner: Spawner) -> ! { geonetworking: _, transport: _, etsi, - } => applogic::denm::handle_denm(&etsi), + } => { + applogic::denm::handle_denm(&etsi); + + // Send data to BLE thread + #[cfg(feature = "ble")] + critical_section::with(|cs| { + let ble_update_ref = ble::TX_DATA.borrow(cs); + ble_update_ref.replace(Some(ble::BleSendable::Denm(etsi.into()))); + }); + } #[cfg(feature = "denm")] ItsMessage::DenmV1 { geonetworking: _, From 4583ad8322ed5849d52d08323a9827c394fca3cf Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Mon, 3 Aug 2026 11:15:19 +0200 Subject: [PATCH 03/14] main: reboot on panic --- Cargo.toml | 1 - src/main.rs | 22 +++++++++++++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index aae53d0..0136b8e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -90,7 +90,6 @@ 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", optional = true, default-features = false, features = ["colors", "critical-section", "jtag-serial", "esp32c5", "log-04"] } diff --git a/src/main.rs b/src/main.rs index e37ba83..28c823e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -39,7 +39,7 @@ use embassy_gps::gps::l76k; use esp_backtrace as _; use esp_hal::clock::CpuClock; use esp_hal::timer::timg::TimerGroup; -#[cfg(all(target_arch = "riscv32", feature = "spat"))] +#[cfg(target_arch = "riscv32")] use esp_println::println; use esp32_cits_core::radio; use geonetworking::Decode as _; @@ -78,6 +78,26 @@ const SERIAL_FIFO_SIZE_THLD: u8 = 100; // hardware has max. 128 byte // For more information see: esp_bootloader_esp_idf::esp_app_desc!(); +// Print a backtrace and reset (instead of halting) +#[panic_handler] +fn panic_handler(info: &core::panic::PanicInfo) -> ! { + println!(""); + println!("====================== PANIC ======================"); + println!("{}", info); + + println!("\nBacktrace:"); + let backtrace = esp_backtrace::Backtrace::capture(); + for frame in backtrace.frames() { + println!("- 0x{:x}", frame.program_counter()); + } + + println!("================== BACKTRACE END =================="); + + // reboot + println!("-> Doing a SW reset after panic...\n"); + esp_hal::system::software_reset(); +} + #[cfg(feature = "_gnss")] static GNSS_UPDATE: Mutex>> = Mutex::new(RefCell::new(None)); static WIFI_RX_QUEUE: Mutex>>>> = Mutex::new(RefCell::new(None)); From 10653429e578a40f58dd23854f08c3d10e5b3880 Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Sat, 15 Aug 2026 14:18:06 +0200 Subject: [PATCH 04/14] GNSS: Increase update rate to 2 Hz --- src/main.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main.rs b/src/main.rs index 28c823e..02e4855 100644 --- a/src/main.rs +++ b/src/main.rs @@ -825,6 +825,8 @@ async fn gnss_ubx_config(reset_pin: esp_hal::peripherals::GPIO25<'static>) { cfg_data: &[ ublox::cfg_val::CfgVal::Uart1OutProtNmea(false), ublox::cfg_val::CfgVal::Uart1OutProtUbx(true), + // CFG-RATE-MEAS + ublox::cfg_val::CfgVal::RateMeas(500), // CFG-MSGOUT-UBX_NAV_PVT_UART1 ublox::cfg_val::CfgVal::MsgOutUbxNavPvtUart1(1), // CFG-MSGOUT-UBX_NAV_SAT_UART1 From 38434b0d864ade6d784285ee6fdaafd75bfca70c Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Sat, 15 Aug 2026 21:08:50 +0200 Subject: [PATCH 05/14] map: Extract max end time --- src/applogic/v2x/map.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/applogic/v2x/map.rs b/src/applogic/v2x/map.rs index bac8a69..d7e2195 100644 --- a/src/applogic/v2x/map.rs +++ b/src/applogic/v2x/map.rs @@ -173,6 +173,12 @@ impl Intersection { { sig_grp.likely_time = Some(likely.to_datetime_from_moy(moy, year)); } + if let Some(max) = &timing.max_end_time + && !max.is_out_of_range() + && !max.is_unknown() + { + sig_grp.max_end_time = Some(max.to_datetime_from_moy(moy, year)); + } } } } else { @@ -223,6 +229,7 @@ impl Intersection { target_lanes, phase: None, min_end_time: None, + max_end_time: None, likely_time: None, } }) @@ -410,7 +417,7 @@ pub struct SignalGroup { /// current signal phase phase: Option, min_end_time: Option>, - // max_end_time: Option>, + max_end_time: Option>, likely_time: Option>, } @@ -443,6 +450,9 @@ impl SignalGroup { pub fn likely_time(&self) -> Option> { self.likely_time } + pub fn max_end_time(&self) -> Option> { + self.max_end_time + } pub fn phase_to_str(&self) -> Option { self.phase.map(|v| { From 1a5012dec51d5daa7fe0cd2727c79b20b4ec63cf Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Sat, 1 Aug 2026 21:31:04 +0200 Subject: [PATCH 06/14] Adapt to new screen, add more GNSS and timing info --- Readme.md | 2 +- src/applogic/mod.rs | 70 +++++++---- src/main.rs | 20 +++- src/screen.rs | 277 +++++++++++++++++++++++++++++++++++++------- 4 files changed, 300 insertions(+), 69 deletions(-) diff --git a/Readme.md b/Readme.md index c2ecbab..5fb34e6 100644 --- a/Readme.md +++ b/Readme.md @@ -8,7 +8,7 @@ The code is currently tailored to: - 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)) +- Some ST7789 240*280px screen (connected on GPIO8 (SCL/ SCK), GPIO10 (SDA/ MOSI), GPIO7 (reset), GPIO23 (DC), GPIO24 (CS), 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 7e7ebc4..55672a7 100644 --- a/src/applogic/mod.rs +++ b/src/applogic/mod.rs @@ -106,7 +106,51 @@ pub struct GlosaSignalInfo { pub struct SignalGroupData { pub phase: SignalPhase, pub maneuver: map::Maneuvers, - pub end_sec: Option, + pub timing: Option, +} + +#[cfg(feature = "spat")] +#[derive(Debug, Default, Clone, PartialEq, Eq)] +#[allow(clippy::struct_field_names)] +pub struct TimingData { + pub min_end_sec: i16, + pub likely_end_sec: Option, + pub max_end_sec: Option, +} + +impl TimingData { + pub fn new(value: &map::SignalGroup, now: chrono::DateTime) -> Option { + let min = value + .min_end_time() + .map(|val| Self::time_to_duration_sec(now, val)); + let likely_end_sec = value + .likely_time() + .map(|val| Self::time_to_duration_sec(now, val)); + let max_end_sec = value + .max_end_time() + .map(|val| Self::time_to_duration_sec(now, val)); + + min.map(|min_end_sec| Self { + min_end_sec, + likely_end_sec, + max_end_sec, + }) + } + + fn time_to_duration_sec( + now: chrono::DateTime, + time_point: chrono::DateTime, + ) -> i16 { + use core::ops::Sub as _; + + use num_traits::Float as _; + + let dur = time_point.sub(now).as_seconds_f32(); + #[allow(clippy::cast_possible_truncation)] + let duration_sec = dur.round() as i16; + + duration_sec + } } #[cfg(feature = "spat")] @@ -357,33 +401,17 @@ impl State { let sig_grps = approach_sig_grps .iter() .filter_map(|sig| { - if let Some(manv) = sig.maneuvers() { - use core::ops::Sub; - - use num_traits::Float; - + if let Some(maneuver) = sig.maneuvers() { let phase = sig .phase() .map(core::convert::Into::into) .unwrap_or_default(); - - 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 end_sec = dur.round() as i16; - - end_sec - }); + let timing = TimingData::new(sig, self.time.and_utc()); Some(SignalGroupData { phase, - maneuver: manv, - end_sec, + maneuver, + timing, }) } else { None diff --git a/src/main.rs b/src/main.rs index 02e4855..13be5d6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -154,8 +154,8 @@ async fn main(spawner: Spawner) -> ! { let mut spi_buffer = [0_u8; 512]; #[cfg(feature = "screen")] let mut screen = { - // 172*320px in landscape orientation - let (display, width, height) = screen::make_small_display( + // 240*280px in portrait orientation + let (display, size) = screen::make_large_display( peripherals.GPIO8, peripherals.GPIO10, peripherals.GPIO7, @@ -167,7 +167,7 @@ async fn main(spawner: Spawner) -> ! { ) .expect("Fatal error building screen"); - let mut screen = screen::Handler::new(display, width, height); + let mut screen = screen::Handler::new(display, size); let _ = screen .update(screen::ScreenState::Initial) @@ -381,6 +381,15 @@ async fn main(spawner: Spawner) -> ! { } Err(err) => warn!("{err}"), } + + // update GNSS HB on screen + let _ = screen.update_gnss(pvt); + } + ublox::proto27::PacketRef::AckNak(nak) => { + error!("UBX: {nak:?}"); + } + ublox::proto27::PacketRef::AckAck(ack) => { + info!("UBX: {ack:?}"); } _ => { println!("UBX: {packet_ref:?}"); @@ -460,8 +469,9 @@ async fn main(spawner: Spawner) -> ! { 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")) + .timing + .as_ref() + .map(|v| alloc::format!("for {} s", v.min_end_sec)) .unwrap_or_default(); println!("GLOSA: {} {} {duration_str}", sig.maneuver, sig.phase); } diff --git a/src/screen.rs b/src/screen.rs index b2ddf90..791d086 100644 --- a/src/screen.rs +++ b/src/screen.rs @@ -5,17 +5,24 @@ use embedded_graphics::{geometry, mono_font, pixelcolor, primitives, text}; use crate::applogic; +pub struct DisplaySize { + width: u16, + height: u16, + offset_top: i32, + corner_radius: i32, +} + pub struct Handler where D: DrawTarget, { target: D, - width: u16, - height: u16, + size: DisplaySize, // internal state prev_state: ScreenState, hb_state: bool, + gnss_hb_state: bool, } #[derive(Debug, Default, PartialEq, Eq)] @@ -79,16 +86,20 @@ where const DEFAULT_TEXT_STYLE: mono_font::MonoTextStyle<'static, pixelcolor::Rgb565> = mono_font::MonoTextStyle::new(&profont::PROFONT_24_POINT, Self::COLOR_FG); + const MID_TEXT_STYLE: mono_font::MonoTextStyle<'static, pixelcolor::Rgb565> = + mono_font::MonoTextStyle::new(&profont::PROFONT_18_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); - pub fn new(target: D, width: u16, height: u16) -> Self { + const BIG_HEIGHT_THLD: u16 = 172; + + pub fn new(target: D, display_size: DisplaySize) -> Self { Self { target, - width, - height, + size: display_size, prev_state: ScreenState::Uninitialized, hb_state: false, + gnss_hb_state: false, } } @@ -115,18 +126,19 @@ where // Update indicator/ heartbeat { let update_ind_dia = 10; - let circle_x_pos = i32::from(self.width) - update_ind_dia - 10; + let circle_x_pos = + i32::from(self.size.width) - update_ind_dia - self.size.corner_radius; text::Text::with_alignment( "HB", - geometry::Point::new(circle_x_pos - 5, 10), + geometry::Point::new(circle_x_pos - 5, 10 + self.size.offset_top), Self::SMALL_TEXT_STYLE, text::Alignment::Right, ) .draw(&mut self.target)?; primitives::Circle::new( - geometry::Point::new(circle_x_pos, 2), + geometry::Point::new(circle_x_pos, 2 + self.size.offset_top), update_ind_dia.cast_unsigned(), ) .into_styled(if self.hb_state { @@ -143,13 +155,67 @@ where Ok(()) } + // on big screns: uses full screen width and height from 250 to 280 pixels + pub fn update_gnss( + &mut self, + pvt: &ublox::nav_pvt::proto27::NavPvtRef, + ) -> Result<(), D::Error> { + use alloc::string::ToString as _; + + // this only applies to big screens + if !self.is_big_height() { + return Ok(()); + } + + let num_sat = pvt.num_satellites(); + let fix_str = match pvt.fix_type() { + ublox::GnssFixType::NoFix + | ublox::GnssFixType::DeadReckoningOnly + | ublox::GnssFixType::TimeOnlyFix => { + alloc::format!("{num_sat:2} sat") + } + ublox::GnssFixType::Fix2D => "2D fix".to_string(), + ublox::GnssFixType::Fix3D | ublox::GnssFixType::GPSPlusDeadReckoning => { + "3D fix".to_string() + } + _ => " ".to_string(), + }; + + // clear area and write information + // note: text position is x baseline, not top left corner + primitives::Rectangle::new( + geometry::Point::new(0, 250 + self.size.offset_top), + geometry::Size::new(self.size.width.into(), 20), + ) + .into_styled(Self::BK_STYLE) + .draw(&mut self.target)?; + + let colon = if self.gnss_hb_state { ":" } else { " " }; + let message = alloc::format!( + "{fix_str}{colon} {:2.0}km/h, {:3.0}°", + pvt.ground_speed_2d() * 3.6, + pvt.heading_motion() + ); + text::Text::with_alignment( + &message, + geometry::Point::new(self.size.corner_radius / 2, 265 + self.size.offset_top), + Self::SMALL_TEXT_STYLE, + text::Alignment::Left, + ) + .draw(&mut self.target)?; + + self.gnss_hb_state = !self.gnss_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()), + geometry::Size::new(self.size.width.into(), self.size.height.into()), ) .into_styled(Self::BK_STYLE) .draw(&mut self.target)?; @@ -157,6 +223,10 @@ where Ok(()) } + fn is_big_height(&self) -> bool { + self.size.height > Self::BIG_HEIGHT_THLD + } + #[allow(unused)] pub fn test_img(&mut self) -> Result<(), D::Error> { mipidsi::TestImage::new().draw(&mut self.target)?; @@ -171,7 +241,7 @@ where text::Text::with_alignment( msg, - geometry::Point::new(i32::from(self.width) / 2, 40), + geometry::Point::new(i32::from(self.size.width) / 2, 40 + self.size.offset_top), Self::DEFAULT_TEXT_STYLE, text::Alignment::Center, ) @@ -186,8 +256,8 @@ where self.clear()?; text::Text::with_alignment( - "No upcoming SPAT", - geometry::Point::new(i32::from(self.width) / 2, 130), + "Searching...", + geometry::Point::new(i32::from(self.size.width) / 2, 130 + self.size.offset_top), Self::DEFAULT_TEXT_STYLE, text::Alignment::Center, ) @@ -209,7 +279,7 @@ where text::Text::with_alignment( &text, - geometry::Point::new(i32::from(self.width) / 2, 130), + geometry::Point::new(i32::from(self.size.width) / 2, 130 + self.size.offset_top), Self::DEFAULT_TEXT_STYLE, text::Alignment::Center, ) @@ -218,6 +288,9 @@ where Ok(()) } + // uses full screen width and height + // - from 0 to 105+67 = 172 pixels for small screens + // - from 0 to 105+67+20 = 192 pixels for big screens #[allow(unused)] fn draw_glosa( &mut self, @@ -226,6 +299,8 @@ where ) -> Result<(), D::Error> { let num_signals = data.signal_groups.len(); + let is_big_height = self.is_big_height(); + if let Some(prev) = prev_data && prev.signal_groups.len() != num_signals { @@ -233,25 +308,30 @@ where self.clear(); } else { // only clear maneuver and timing area otherwise - + let sig_total_height = 67 + if is_big_height { 20 } else { 0 }; primitives::Rectangle::new( - geometry::Point::new(0, 105), - geometry::Size::new(self.width.into(), 67), + geometry::Point::new(0, 105 + self.size.offset_top), + geometry::Size::new(self.size.width.into(), sig_total_height), ) .into_styled(Self::BK_STYLE) .draw(&mut self.target)?; } + // do nothing more when no signals + if num_signals == 0 { + return Ok(()); + } + // draw intersection ID text::Text::new( &alloc::format!("#{}", data.intersection_id), - geometry::Point::new(15, 10), + geometry::Point::new(self.size.corner_radius + 5, 10 + self.size.offset_top), Self::SMALL_TEXT_STYLE, ) .draw(&mut self.target)?; #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)] - let sig_width = i32::from(self.width) / (num_signals as i32); + let sig_width = i32::from(self.size.width) / (num_signals as i32); for (idx, sig) in data.signal_groups.iter().enumerate() { #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)] @@ -269,7 +349,7 @@ where applogic::SignalPhase::RedYellow | applogic::SignalPhase::Yellow => Self::YE_STYLE, }; primitives::Circle::new( - geometry::Point::new(offset + circle_margin, 15), + geometry::Point::new(offset + circle_margin, 15 + self.size.offset_top), circle_dia.cast_unsigned(), ) .into_styled(style) @@ -280,24 +360,50 @@ where // draw maneuver text::Text::with_alignment( &maneuver_to_str(sig.maneuver), - geometry::Point::new(centerline, 130), + geometry::Point::new(centerline, 130 + self.size.offset_top), 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)?; + // draw timing indicator(s) + if let Some(timing) = &sig.timing { + if let (Some(likely), Some(max)) = (timing.likely_end_sec, timing.max_end_sec) { + text::Text::with_alignment( + &alloc::format!("{likely}s"), + geometry::Point::new(centerline, 160 + self.size.offset_top), + Self::DEFAULT_TEXT_STYLE, + text::Alignment::Center, + ) + .draw(&mut self.target)?; + + // only for big screen + if is_big_height { + // TODO: show as diff to likely time? + text::Text::with_alignment( + &alloc::format!("{}-{max}", timing.min_end_sec), + geometry::Point::new(centerline, 185 + self.size.offset_top), + Self::MID_TEXT_STYLE, + text::Alignment::Center, + ) + .draw(&mut self.target)?; + } + } else { + text::Text::with_alignment( + &alloc::format!("{}s", timing.min_end_sec), + geometry::Point::new(centerline, 160 + self.size.offset_top), + Self::DEFAULT_TEXT_STYLE, + text::Alignment::Center, + ) + .draw(&mut self.target)?; + } } } + // if is_big_height { + // // TODO: Add ETA + // } + Ok(()) } @@ -307,7 +413,7 @@ where self.clear()?; - let sig_width = i32::from(self.width) / 3; + let sig_width = i32::from(self.size.width) / 3; let circle_dia = 50; let circle_margin = (sig_width - circle_dia) / 2; @@ -319,7 +425,7 @@ where let offset = (idx as i32) * sig_width; primitives::Circle::new( - geometry::Point::new(offset + circle_margin, 15), + geometry::Point::new(offset + circle_margin, 15 + self.size.offset_top), circle_dia.cast_unsigned(), ) .into_styled(*style) @@ -327,10 +433,10 @@ where } // draw welcome text - let centerline = i32::from(self.width) / 2; + let centerline = i32::from(self.size.width) / 2; text::Text::with_alignment( - "C-ITS Signal Phase Display\nWaiting for GNSS fix...", - geometry::Point::new(centerline, 130), + "C-ITS Signal Phase\nWaiting for GNSS...", + geometry::Point::new(centerline, 130 + self.size.offset_top), text_style, text::Alignment::Center, ) @@ -353,7 +459,7 @@ fn maneuver_to_str(value: applogic::v2x::map::Maneuvers) -> alloc::string::Strin /// /// # Errors /// Human-readable fatal errors -#[allow(clippy::too_many_arguments, clippy::type_complexity)] +#[allow(unused, clippy::too_many_arguments, clippy::type_complexity)] pub fn make_small_display( scl: IoScl, sda: IoSda, @@ -378,8 +484,7 @@ pub fn make_small_display( mipidsi::models::ST7789, esp_hal::gpio::Output<'_>, >, - u16, - u16, + DisplaySize, ), alloc::string::String, > @@ -396,10 +501,98 @@ where 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 size = DisplaySize { + width: 320, + height: 172, + offset_top: 0, + corner_radius: 10, + }; + + 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}"))?; + + // Note: height and width are swapped b/c of 90° display rotation + let display = mipidsi::Builder::new( + mipidsi::models::ST7789, + mipidsi::interface::SpiInterface::new(spi_device, dc_output, spi_buffer), + ) + .display_size(size.height + DISPLAY_HIDDEN_X, size.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, size)) +} + +/// Creates an [`mipidsi::Display`], width, height tuple for an ST7789 240*280px screen in portrait orientation +/// +/// # Errors +/// Human-readable fatal errors +#[allow(unused, clippy::too_many_arguments, clippy::type_complexity)] +pub fn make_large_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<'_>, + >, + DisplaySize, + ), + 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_HIDDEN_Y: u16 = 20; // somehow this display has 20 px of invisible space on the top (in native orientation) + + let size = DisplaySize { + width: 240, + height: 280 + DISPLAY_HIDDEN_Y, + offset_top: DISPLAY_HIDDEN_Y.into(), + corner_radius: 30, + }; + let (spi, cs) = setup_st7789_spi(scl, sda, cs, spi) .map_err(|err| alloc::format!("Failed to build SPI config: {err}"))?; @@ -417,14 +610,14 @@ where mipidsi::models::ST7789, mipidsi::interface::SpiInterface::new(spi_device, dc_output, spi_buffer), ) - .display_size(DISPLAY_HEIGHT + DISPLAY_HIDDEN_X, DISPLAY_WIDTH) + .display_size(size.width, size.height + DISPLAY_HIDDEN_Y) .reset_pin(rst_output) .invert_colors(mipidsi::options::ColorInversion::Inverted) - .orientation(mipidsi::options::Orientation::new().rotate(mipidsi::options::Rotation::Deg90)) + .orientation(mipidsi::options::Orientation::new().rotate(mipidsi::options::Rotation::Deg0)) .init(&mut display_delay) .map_err(|err| alloc::format!("Failed to initialize display: {err:?}"))?; - Ok((display, DISPLAY_WIDTH, DISPLAY_HEIGHT)) + Ok((display, size)) } /// Builds the SPI interface for the ST7789 display From fb47f17c3fb7ffe3807a12a91635873c2bf8d6e3 Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Sat, 15 Aug 2026 21:29:41 +0200 Subject: [PATCH 07/14] GNSS: Don't print warnings on no fix --- src/main.rs | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/main.rs b/src/main.rs index 13be5d6..5e4e0c5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -372,14 +372,12 @@ async fn main(spawner: Spawner) -> ! { 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}"), + // error can be ignored b/c it's just when no GNSS fix yet + if let Ok(pos_state) = applogic::GnssFix::try_from(pvt) { + critical_section::with(|cs| { + let gnss_update_ref = GNSS_UPDATE.borrow(cs); + gnss_update_ref.replace(Some(pos_state)); + }); } // update GNSS HB on screen From a2b0819fb011a4a333bbbdc5b2bd806f05f0f0a1 Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Mon, 17 Aug 2026 15:00:00 +0200 Subject: [PATCH 08/14] main: Tweak vehicle size --- src/main.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main.rs b/src/main.rs index 5e4e0c5..6eb670c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -68,9 +68,9 @@ 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! +const OWN_VEHICLE_WIDTH: u8 = 7; // in 10cm steps! #[cfg(feature = "cam_tx")] -const OWN_VEHICLE_LENGTH: u16 = 20; // in 10cm steps! +const OWN_VEHICLE_LENGTH: u16 = 18; // in 10cm steps! #[cfg(feature = "_uart")] const SERIAL_FIFO_SIZE_THLD: u8 = 100; // hardware has max. 128 byte From 352ef6fb7a1c945f4fddf8d7b8942b8a567daecc Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Mon, 17 Aug 2026 15:20:49 +0200 Subject: [PATCH 09/14] applogic: Add missing feature flag for `TimingData` impl --- src/applogic/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/applogic/mod.rs b/src/applogic/mod.rs index 55672a7..0ced524 100644 --- a/src/applogic/mod.rs +++ b/src/applogic/mod.rs @@ -118,6 +118,7 @@ pub struct TimingData { pub max_end_sec: Option, } +#[cfg(feature = "spat")] impl TimingData { pub fn new(value: &map::SignalGroup, now: chrono::DateTime) -> Option { let min = value From 2dfcfef58d568bbf8686386acb0cbafb72fd7b92 Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Wed, 19 Aug 2026 15:35:38 +0200 Subject: [PATCH 10/14] BLE: Remove denm dependency, align default features with readme --- Cargo.toml | 4 ++-- Readme.md | 4 +--- feat-permut.sh | 2 ++ 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 0136b8e..6dc5d8e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ path = "./src/test.rs" required-features = ["std", "cam"] [features] -default = ["esp", "spat", "gnss_ubx", "screen", "ble"] +default = ["esp", "spat", "gnss_ubx", "screen"] # build for ESP (needed to build test for `host-tuple`) esp = [ @@ -57,7 +57,7 @@ screen = ["dep:mipidsi", "dep:embedded-graphics", "dep:embedded-hal-bus", "dep:p # enable I/O via UART uart = ["_uart", "_gnss"] # enable I/O via BLE -ble = ["_gnss", "denm"] +ble = ["_gnss"] # internal feature, that any UART I/O is enabled _uart = [] diff --git a/Readme.md b/Readme.md index 5fb34e6..16453c6 100644 --- a/Readme.md +++ b/Readme.md @@ -57,9 +57,7 @@ Enable UART I/O protocol according to [docs/uart-protocol.md](./docs/uart-protoc ### BLE Enable BLE I/O protocol according to [docs/ble-protocol.md](./docs/ble-protocol.md). -Will enable `denm` feature, but also enabling `cam` is recommended for full functionality. - -Note: Raw message reception is currently not implemented! +Enabling `denm` feature or `cam` feature is recommended, otherwise no data will be received. ## Usage diff --git a/feat-permut.sh b/feat-permut.sh index 1ef6b62..287dbcb 100755 --- a/feat-permut.sh +++ b/feat-permut.sh @@ -15,6 +15,8 @@ features_base=( spat,gnss_ubx,screen,cam_tx ) features_more=( + spat,gnss_ubx,screen,ble + spat,gnss_ubx,screen,ble,cam_tx spat,gnss_ubx,screen,cam,denm spat,gnss_ubx,screen,cam_tx,cam,denm ) From 9211c82d6fb395961223013c8b5176001b5b745a Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Wed, 19 Aug 2026 14:37:49 +0200 Subject: [PATCH 11/14] BLE: Use unique name --- src/ble.rs | 3 ++- src/main.rs | 9 ++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/ble.rs b/src/ble.rs index b585e86..eb95c93 100644 --- a/src/ble.rs +++ b/src/ble.rs @@ -75,6 +75,7 @@ pub async fn run( esp_radio::ble::controller::BleConnector<'static>, CONTROLLER_SLOTS, >, + name: alloc::string::String, ) { let mut resources: HostResources = HostResources::new(); @@ -87,7 +88,7 @@ pub async fn run( info!("Starting advertising and GATT service"); let server = Server::new_with_config(GapConfig::Peripheral(PeripheralConfig { - name: "ESP32 C-ITS", + name: &name, appearance: &appearance::UNKNOWN, })) .unwrap(); diff --git a/src/main.rs b/src/main.rs index 6eb670c..d3adbbd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -219,7 +219,14 @@ async fn main(spawner: Spawner) -> ! { #[cfg(feature = "ble")] { let controller = ble::make_controller(peripherals.BT); - spawner.spawn(ble::run(controller).expect("Failed to spawn BLE task")); + let serial = &mac.as_bytes()[3..6]; + let name = alloc::format!( + "ESP32 C-ITS {:02x}{:02x}{:02x}", + serial[0], + serial[1], + serial[2] + ); + spawner.spawn(ble::run(controller, name).expect("Failed to spawn BLE task")); } // Setup GNSS: From 610d4a925ce8b04e240433a2f3b876b1d49a971a Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Mon, 17 Aug 2026 11:35:00 +0200 Subject: [PATCH 12/14] BLE: Fix UUIDs --- docs/ble-protocol.md | 12 ++++++------ src/ble.rs | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/ble-protocol.md b/docs/ble-protocol.md index 4560424..8d742e2 100644 --- a/docs/ble-protocol.md +++ b/docs/ble-protocol.md @@ -19,12 +19,12 @@ V2X data is exposed via the service UUID `c0b70000-d4f4-4000-ada8-f99a02ee315c` 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) +c0b70001-d4f4-4002-ada8-f99a02ee315c | `RawMsgRx` | Raw CAM message (de-duplicated/ rate-limited) +c0b70001-d4f4-4003-ada8-f99a02ee315c | `CamEvent` | Latest received CAM +c0b70001-d4f4-4004-ada8-f99a02ee315c | `RawMsgRx` | Raw DENM message (de-duplicated/ rate-limited) +c0b70001-d4f4-4005-ada8-f99a02ee315c | `DenmEvent` | Latest received DENM +c0b70001-d4f4-4006-ada8-f99a02ee315c | `RawMsgRx` | Raw MAPEM message (de-duplicated/ rate-limited) +c0b70001-d4f4-4007-ada8-f99a02ee315c | `RawMsgRx` | Raw SPATEM message (de-duplicated/ rate-limited) ## Message Format diff --git a/src/ble.rs b/src/ble.rs index eb95c93..030d843 100644 --- a/src/ble.rs +++ b/src/ble.rs @@ -44,12 +44,12 @@ struct CITSEvents { #[cfg(feature = "cam")] #[descriptor(uuid = descriptors::MEASUREMENT_DESCRIPTION, name = "CAM_Event", read, value = "CamEvent proto msg", type = &'static str)] - #[characteristic(uuid = "c0b70001-d4f4-4002-ada8-f99a02ee315c", read, notify, value = [0u8; CAM_EVENT_SIZE])] + #[characteristic(uuid = "c0b70001-d4f4-4003-ada8-f99a02ee315c", read, notify, value = [0u8; CAM_EVENT_SIZE])] cam_event: [u8; CAM_EVENT_SIZE], #[cfg(feature = "denm")] #[descriptor(uuid = descriptors::MEASUREMENT_DESCRIPTION, name = "DENM_Event", read, value = "DenmEvent proto msg", type = &'static str)] - #[characteristic(uuid = "c0b70001-d4f4-4004-ada8-f99a02ee315c", read, notify, value = [0u8; DENM_EVENT_SIZE])] + #[characteristic(uuid = "c0b70001-d4f4-4005-ada8-f99a02ee315c", read, notify, value = [0u8; DENM_EVENT_SIZE])] denm_event: [u8; DENM_EVENT_SIZE], } From 059f47df561788403db8c8325ac677b42aaef0b1 Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Mon, 17 Aug 2026 14:53:48 +0200 Subject: [PATCH 13/14] BLE: Add raw messages BLE can only fit 251 bytes of payload in notifications which isn't nearly enough for a raw V2X message. So only publish a notification which message was received and let the client poll the content from the characteristic (since reads can get data in fragments) --- docs/ble-protocol.md | 33 ++++- docs/c_its-messages.proto | 4 + src/ble.rs | 247 ++++++++++++++++++++++++++++++-------- src/io.rs | 146 ++++++++++++++++++++-- src/main.rs | 67 ++++++++--- 5 files changed, 413 insertions(+), 84 deletions(-) diff --git a/docs/ble-protocol.md b/docs/ble-protocol.md index 8d742e2..e8ad492 100644 --- a/docs/ble-protocol.md +++ b/docs/ble-protocol.md @@ -19,12 +19,16 @@ V2X data is exposed via the service UUID `c0b70000-d4f4-4000-ada8-f99a02ee315c` UUID | Type | Description ------------------------------------- | --------------- | ----------- c0b70001-d4f4-4001-ada8-f99a02ee315c | `PositionState` | Set position from external source -c0b70001-d4f4-4002-ada8-f99a02ee315c | `RawMsgRx` | Raw CAM message (de-duplicated/ rate-limited) -c0b70001-d4f4-4003-ada8-f99a02ee315c | `CamEvent` | Latest received CAM -c0b70001-d4f4-4004-ada8-f99a02ee315c | `RawMsgRx` | Raw DENM message (de-duplicated/ rate-limited) -c0b70001-d4f4-4005-ada8-f99a02ee315c | `DenmEvent` | Latest received DENM -c0b70001-d4f4-4006-ada8-f99a02ee315c | `RawMsgRx` | Raw MAPEM message (de-duplicated/ rate-limited) -c0b70001-d4f4-4007-ada8-f99a02ee315c | `RawMsgRx` | Raw SPATEM message (de-duplicated/ rate-limited) +c0b70001-d4f4-4002-ada8-f99a02ee315c | `RawNotify` | Raw message notification +c0b70001-d4f4-4003-ada8-f99a02ee315c | `RawMsgRx` | Raw CAM message (de-duplicated/ rate-limited) +c0b70001-d4f4-4004-ada8-f99a02ee315c | `CamEvent` | Latest received CAM +c0b70001-d4f4-4005-ada8-f99a02ee315c | `RawMsgRx` | Raw DENM message (de-duplicated/ rate-limited) +c0b70001-d4f4-4006-ada8-f99a02ee315c | `DenmEvent` | Latest received DENM +c0b70001-d4f4-4007-ada8-f99a02ee315c | `RawMsgRx` | Raw MAPEM message (de-duplicated/ rate-limited) +c0b70001-d4f4-4008-ada8-f99a02ee315c | `RawMsgRx` | Raw SPATEM message (de-duplicated/ rate-limited) + +Since a modern BLE notification can only fit up tp 251 bytes of payload, the raw messages can only be polled. +Please subscribe to `c0b70001-d4f4-4002-ada8-f99a02ee315c` and use the message ID to determine from which service to read the incoming value. ## Message Format @@ -101,3 +105,20 @@ 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) + +Maximum message size (assuming realistic value ranges): + +field name | tag size | (max.) data size +--------------------- | -------- | --------- +msg_type | 1 | 1 (VARINT(21)) +payload | 1 | 2+1394 (LEN(1394) + payload) +**SUM** | 2 + | 1397 = 1399 byte + +### Raw Message Notification + +Maximum message size (assuming realistic value ranges): + +field name | tag size | (max.) data size +--------------------- | -------- | --------- +msg_type | 1 | 1 (VARINT(21)) +**SUM** | 1 + | 1 = 2 byte diff --git a/docs/c_its-messages.proto b/docs/c_its-messages.proto index 47e523d..9323b3e 100644 --- a/docs/c_its-messages.proto +++ b/docs/c_its-messages.proto @@ -71,6 +71,10 @@ message RawMsgTx { optional uint32 stationType = 6; // for source position vector } +message RawNotify { + required uint32 message_type = 1; // ITS `MessageId` integer value +} + // ------------------------------ // Messages // ------------------------------ diff --git a/src/ble.rs b/src/ble.rs index 030d843..8fa5ead 100644 --- a/src/ble.rs +++ b/src/ble.rs @@ -6,6 +6,7 @@ use core::cell::RefCell; use critical_section::Mutex; +use crossbeam_queue::ArrayQueue; use esp_backtrace as _; use log::{info, warn}; use trouble_host::prelude::*; @@ -16,14 +17,26 @@ pub const CONTROLLER_SLOTS: usize = 20; const CONNECTIONS_MAX: usize = 1; const L2CAP_CHANNELS_MAX: usize = 1; -pub static TX_DATA: Mutex>> = Mutex::new(RefCell::new(None)); +pub static CLIENT_CONNECTED: Mutex> = Mutex::new(RefCell::new(false)); +pub static TX_DATA: Mutex>>> = + Mutex::new(RefCell::new(None)); pub static RX_DATA: Mutex>> = Mutex::new(RefCell::new(None)); pub enum BleSendable { + #[cfg(feature = "cam")] + CamRaw(io::msg::RawMsgRx), #[cfg(feature = "cam")] Cam(io::msg::CamEvent), + + #[cfg(feature = "denm")] + DenmRaw(io::msg::RawMsgRx), #[cfg(feature = "denm")] Denm(io::msg::DenmEvent), + + #[cfg(feature = "spat")] + MapemRaw(io::msg::RawMsgRx), + #[cfg(feature = "spat")] + SpatemRaw(io::msg::RawMsgRx), } #[gatt_server] @@ -31,26 +44,43 @@ struct Server { c_its_events: CITSEvents, } -#[cfg(feature = "cam")] -const CAM_EVENT_SIZE: usize = 38; -#[cfg(feature = "denm")] -const DENM_EVENT_SIZE: usize = 51; - #[gatt_service(uuid = "c0b70000-d4f4-4000-ada8-f99a02ee315c")] struct CITSEvents { #[descriptor(uuid = descriptors::MEASUREMENT_DESCRIPTION, name = "Position", read, value = "PositionState proto msg", type = &'static str)] #[characteristic(uuid = "c0b70001-d4f4-4001-ada8-f99a02ee315c", write)] position: [u8; 27], + #[cfg(any(feature = "cam", feature = "denm", feature = "spat"))] + #[descriptor(uuid = descriptors::MEASUREMENT_DESCRIPTION, name = "Raw_Notify", read, value = "Raw Message Notification", type = &'static str)] + #[characteristic(uuid = "c0b70001-d4f4-4002-ada8-f99a02ee315c", read, notify, value = [0u8; io::msg::RawNotify::PROTO_SIZE])] + raw_notify: [u8; io::msg::RawNotify::PROTO_SIZE], + + #[cfg(feature = "cam")] + #[descriptor(uuid = descriptors::MEASUREMENT_DESCRIPTION, name = "CAM", read, value = "RawMsgRx proto msg", type = &'static str)] + #[characteristic(uuid = "c0b70001-d4f4-4003-ada8-f99a02ee315c", read, value = [0u8; io::msg::RawMsgRx::PROTO_SIZE])] + cam_raw: [u8; io::msg::RawMsgRx::PROTO_SIZE], #[cfg(feature = "cam")] #[descriptor(uuid = descriptors::MEASUREMENT_DESCRIPTION, name = "CAM_Event", read, value = "CamEvent proto msg", type = &'static str)] - #[characteristic(uuid = "c0b70001-d4f4-4003-ada8-f99a02ee315c", read, notify, value = [0u8; CAM_EVENT_SIZE])] - cam_event: [u8; CAM_EVENT_SIZE], + #[characteristic(uuid = "c0b70001-d4f4-4004-ada8-f99a02ee315c", read, notify, value = [0u8; io::msg::CamEvent::PROTO_SIZE])] + cam_event: [u8; io::msg::CamEvent::PROTO_SIZE], + #[cfg(feature = "denm")] + #[descriptor(uuid = descriptors::MEASUREMENT_DESCRIPTION, name = "DENM", read, value = "RawMsgRx proto msg", type = &'static str)] + #[characteristic(uuid = "c0b70001-d4f4-4005-ada8-f99a02ee315c", read, value = [0u8; io::msg::RawMsgRx::PROTO_SIZE])] + denm_raw: [u8; io::msg::RawMsgRx::PROTO_SIZE], #[cfg(feature = "denm")] #[descriptor(uuid = descriptors::MEASUREMENT_DESCRIPTION, name = "DENM_Event", read, value = "DenmEvent proto msg", type = &'static str)] - #[characteristic(uuid = "c0b70001-d4f4-4005-ada8-f99a02ee315c", read, notify, value = [0u8; DENM_EVENT_SIZE])] - denm_event: [u8; DENM_EVENT_SIZE], + #[characteristic(uuid = "c0b70001-d4f4-4006-ada8-f99a02ee315c", read, notify, value = [0u8; io::msg::DenmEvent::PROTO_SIZE])] + denm_event: [u8; io::msg::DenmEvent::PROTO_SIZE], + + #[cfg(feature = "spat")] + #[descriptor(uuid = descriptors::MEASUREMENT_DESCRIPTION, name = "MAPEM", read, value = "RawMsgRx proto msg", type = &'static str)] + #[characteristic(uuid = "c0b70001-d4f4-4007-ada8-f99a02ee315c", read, value = [0u8; io::msg::RawMsgRx::PROTO_SIZE])] + mapem_raw: [u8; io::msg::RawMsgRx::PROTO_SIZE], + #[cfg(feature = "spat")] + #[descriptor(uuid = descriptors::MEASUREMENT_DESCRIPTION, name = "SPATEM", read, value = "RawMsgRx proto msg", type = &'static str)] + #[characteristic(uuid = "c0b70001-d4f4-4008-ada8-f99a02ee315c", read, value = [0u8; io::msg::RawMsgRx::PROTO_SIZE])] + spatem_raw: [u8; io::msg::RawMsgRx::PROTO_SIZE], } /// Creates the BLE controller @@ -60,6 +90,10 @@ pub fn make_controller( esp_radio::ble::controller::BleConnector<'static>, CONTROLLER_SLOTS, > { + critical_section::with(|cs| { + TX_DATA.borrow(cs).replace(Some(ArrayQueue::new(2))); + }); + let transport = esp_radio::ble::controller::BleConnector::new( bt_peripheral, esp_radio::ble::Config::default(), @@ -86,7 +120,21 @@ pub async fn run( .. } = stack.build(); - info!("Starting advertising and GATT service"); + let mut features = alloc::vec![]; + if cfg!(feature = "cam") { + features.push("cam"); + } + if cfg!(feature = "denm") { + features.push("denm"); + } + if cfg!(feature = "spat") { + features.push("mapem"); + features.push("spatem"); + } + info!( + "Starting advertising and GATT service for {}", + features.join(",") + ); let server = Server::new_with_config(GapConfig::Peripheral(PeripheralConfig { name: &name, appearance: &appearance::UNKNOWN, @@ -99,10 +147,15 @@ pub async fn run( Ok(conn) => { // set up tasks when the connection is established to a central, so they don't run when no one is connected. let a = gatt_events_task(&server, &conn); - let b = custom_task(&server, &conn); - // run until any task ends (usually because the connection has been closed), - // then return to advertising state. + let b = publishing_task(&server, &conn); + + // run until any task ends (usually because the connection has been closed), then return to advertising state. embassy_futures::select::select(a, b).await; + + critical_section::with(|cs| { + let value_ref = CLIENT_CONNECTED.borrow(cs); + let _ = value_ref.replace(false); + }); } Err(e) => { panic!("BLE ADV: fatal error {e:?}"); @@ -113,6 +166,21 @@ pub async fn run( .await; } +pub fn update(payload: BleSendable) { + critical_section::with(|cs| { + // only add to queue when someone is connected + if *CLIENT_CONNECTED.borrow(cs).borrow() { + let queue_rc = TX_DATA.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).is_err() { + log::error!("BLE TX queue is full"); + } + } + }); +} + /// Create an advertiser to use to connect to a BLE Central, and wait for it to connect. async fn advertise<'values, 'server, C: Controller>( name: &'values str, @@ -202,63 +270,142 @@ async fn gatt_events_task( Ok(()) } -/// Example task to use the BLE notifier interface. -/// This task will notify the connected central of a counter value every 2 seconds. -/// It will also read the RSSI value every 2 seconds. -/// and will stop when the connection is closed by the central or an error occurs. -async fn custom_task(server: &Server<'_>, conn: &GattConnection<'_, '_, P>) { +#[allow(clippy::too_many_lines)] +async fn publishing_task(server: &Server<'_>, conn: &GattConnection<'_, '_, P>) { + critical_section::with(|cs| { + let value_ref = CLIENT_CONNECTED.borrow(cs); + let _ = value_ref.replace(true); + }); + loop { embassy_time::Timer::after(embassy_time::Duration::from_millis(10)).await; let mut new_data = None; critical_section::with(|cs| { - let tx_data_ref = TX_DATA.borrow(cs); + let queue_rc = TX_DATA.borrow(cs).borrow(); + // unwrap is fine b/c we stored something in it before + let queue = queue_rc.as_ref().unwrap(); - if let Some(tx_data) = tx_data_ref.replace(None) { - new_data = Some(tx_data); + if let Some(data) = queue.pop() { + new_data = Some(data); } }); match new_data { #[cfg(feature = "cam")] - Some(BleSendable::Cam(cam_event)) => { - let mut buf = cam_event.serialize_ble_proto(); - - if buf.len() <= CAM_EVENT_SIZE { - // pad with zeros - let pad = alloc::vec![0u8; CAM_EVENT_SIZE - buf.len()]; - buf.extend_from_slice(&pad); - - // unwrap should be fine since we ensured the vector size before - let value = buf.try_into().unwrap(); - + Some(BleSendable::Cam(cam_event)) => match cam_event.serialize_ble_proto() { + Ok(value) => { if let Err(err) = server.c_its_events.cam_event.notify(conn, &value).await { warn!("BLE: failed notifying connection of new CAM event: {err:?}"); } - } else { - warn!("BLE: CAM event too big with {} bytes", buf.len()); } - } + Err(err) => warn!("BLE: CAM event {err}"), + }, + #[cfg(feature = "denm")] - Some(BleSendable::Denm(denm_event)) => { - let mut buf = denm_event.serialize_ble_proto(); - - if buf.len() <= DENM_EVENT_SIZE { - // pad with zeros - let pad = alloc::vec![0u8; DENM_EVENT_SIZE - buf.len()]; - buf.extend_from_slice(&pad); - - // unwrap should be fine since we ensured the vector size before - let value = buf.try_into().unwrap(); - + Some(BleSendable::Denm(denm_event)) => match denm_event.serialize_ble_proto() { + Ok(value) => { if let Err(err) = server.c_its_events.denm_event.notify(conn, &value).await { warn!("BLE: failed notifying connection of new DENM event: {err:?}"); } - } else { - warn!("BLE: DENM event too big with {} bytes", buf.len()); } - } + Err(err) => warn!("BLE: DENM event {err}"), + }, + + #[cfg(feature = "cam")] + Some(BleSendable::CamRaw(raw_msg)) => match raw_msg.serialize_ble_proto() { + Ok(value) => { + if let Err(err) = server.c_its_events.cam_raw.set(server, &value) { + warn!("BLE: failed setting new raw CAM value: {err:?}"); + } + + if let Err(err) = send_raw_notify( + server, + conn, + c_its_parser::standards::extensions::ItsMessageId::Cam, + ) + .await + { + warn!("BLE: {err}"); + } + } + Err(err) => warn!("BLE: Raw CAM too big: {err}"), + }, + #[cfg(feature = "denm")] + Some(BleSendable::DenmRaw(raw_msg)) => match raw_msg.serialize_ble_proto() { + Ok(value) => { + if let Err(err) = server.c_its_events.denm_raw.set(server, &value) { + warn!("BLE: failed setting new raw DENM value: {err:?}"); + } + + if let Err(err) = send_raw_notify( + server, + conn, + c_its_parser::standards::extensions::ItsMessageId::Denm, + ) + .await + { + warn!("BLE: {err}"); + } + } + Err(err) => warn!("BLE: Raw DENM {err}"), + }, + #[cfg(feature = "spat")] + Some(BleSendable::MapemRaw(raw_msg)) => match raw_msg.serialize_ble_proto() { + Ok(value) => { + if let Err(err) = server.c_its_events.mapem_raw.set(server, &value) { + warn!("BLE: failed setting new raw MAPEM value: {err:?}"); + } + + if let Err(err) = send_raw_notify( + server, + conn, + c_its_parser::standards::extensions::ItsMessageId::Mapem, + ) + .await + { + warn!("BLE: {err}"); + } + } + Err(err) => warn!("BLE: Raw MAPEM {err}"), + }, + #[cfg(feature = "spat")] + Some(BleSendable::SpatemRaw(raw_msg)) => match raw_msg.serialize_ble_proto() { + Ok(value) => { + if let Err(err) = server.c_its_events.spatem_raw.set(server, &value) { + warn!("BLE: failed setting new raw SPATEM value: {err:?}"); + } + + if let Err(err) = send_raw_notify( + server, + conn, + c_its_parser::standards::extensions::ItsMessageId::Spatem, + ) + .await + { + warn!("BLE: {err}"); + } + } + Err(err) => warn!("BLE: Raw SPATEM {err}"), + }, None => {} } } } + +async fn send_raw_notify( + server: &Server<'_>, + conn: &GattConnection<'_, '_, P>, + msg_id: c_its_parser::standards::extensions::ItsMessageId, +) -> Result<(), alloc::string::String> { + let notify_buf = io::msg::RawNotify::from(msg_id) + .serialize_ble_proto() + .expect("BLE: Raw notify too big"); + + server + .c_its_events + .raw_notify + .notify(conn, ¬ify_buf) + .await + .map_err(|err| alloc::format!("failed notifying connection of new raw {msg_id:?}: {err:?}")) +} diff --git a/src/io.rs b/src/io.rs index 3ef8c6d..642807f 100644 --- a/src/io.rs +++ b/src/io.rs @@ -40,20 +40,91 @@ impl msg::RawMsgRx { #[cfg(feature = "denm")] impl msg::DenmEvent { #[cfg(feature = "ble")] - pub fn serialize_ble_proto(&self) -> alloc::vec::Vec { + pub const PROTO_SIZE: usize = 51; + + #[cfg(feature = "ble")] + pub fn serialize_ble_proto(&self) -> Result<[u8; Self::PROTO_SIZE], alloc::string::String> { use prost::Message as _; - self.encode_to_vec() + let mut data = [0u8; Self::PROTO_SIZE]; + + let mut mut_buf = &mut data[..]; + if let Err(err) = self.encode(&mut mut_buf) { + Err(alloc::format!("Buffer too small: {err:?}")) + } else { + Ok(data) + } } } #[cfg(feature = "cam")] impl msg::CamEvent { #[cfg(feature = "ble")] - pub fn serialize_ble_proto(&self) -> alloc::vec::Vec { + pub const PROTO_SIZE: usize = 38; + + #[cfg(feature = "ble")] + pub fn serialize_ble_proto(&self) -> Result<[u8; Self::PROTO_SIZE], alloc::string::String> { use prost::Message as _; - self.encode_to_vec() + let mut data = [0u8; Self::PROTO_SIZE]; + + let mut mut_buf = &mut data[..]; + if let Err(err) = self.encode(&mut mut_buf) { + Err(alloc::format!("Buffer too small: {err:?}")) + } else { + Ok(data) + } + } +} + +#[cfg(any(feature = "cam", feature = "denm", feature = "spat"))] +impl msg::RawMsgRx { + #[cfg(feature = "ble")] + pub const PROTO_SIZE: usize = 1400; + + #[cfg(feature = "ble")] + pub fn serialize_ble_proto(&self) -> Result<[u8; Self::PROTO_SIZE], alloc::string::String> { + use prost::Message as _; + + let mut data = [0u8; Self::PROTO_SIZE]; + + let mut mut_buf = &mut data[..]; + if let Err(err) = self.encode(&mut mut_buf) { + Err(alloc::format!("Buffer too small: {err:?}")) + } else { + Ok(data) + } + } +} + +#[cfg(all( + feature = "ble", + any(feature = "cam", feature = "denm", feature = "spat") +))] +impl From for msg::RawNotify { + fn from(value: c_its_parser::standards::extensions::ItsMessageId) -> Self { + let message_type = value.as_u8().into(); + Self { message_type } + } +} + +#[cfg(any(feature = "cam", feature = "denm", feature = "spat"))] +impl msg::RawNotify { + #[cfg(feature = "ble")] + pub const PROTO_SIZE: usize = 2; + + #[cfg(feature = "ble")] + pub fn serialize_ble_proto(self) -> Result<[u8; Self::PROTO_SIZE], alloc::string::String> { + use prost::Message as _; + + let mut data = [0u8; Self::PROTO_SIZE]; + + let mut mut_buf = &mut data[..]; + if let Err(err) = self.encode(&mut mut_buf) { + Err(alloc::format!("Buffer too small: {err:?}")) + } else { + Ok(data) + } } } @@ -414,11 +485,11 @@ impl msg::RawMsgTx { } #[cfg(feature = "cam")] -impl From> +impl From<&alloc::boxed::Box> for msg::CamEvent { fn from( - value: alloc::boxed::Box, + value: &alloc::boxed::Box, ) -> Self { use c_its_parser::standards::cam_1_4_1::cam_pdu_descriptions; @@ -429,12 +500,14 @@ impl From None, }); - let vehicle_data = match value.cam.cam_parameters.high_frequency_container { + let vehicle_data = match &value.cam.cam_parameters.high_frequency_container { cam_pdu_descriptions::HighFrequencyContainer::basicVehicleContainerHighFrequency( basic_vehicle_container_high_frequency, ) => Some(basic_vehicle_container_high_frequency.into()), @@ -460,8 +533,8 @@ impl From for msg::VehicleContainer { - fn from(value: c_its_parser::standards::cam_1_4_1::cam_pdu_descriptions::BasicVehicleContainerHighFrequency) -> Self { +impl From<&c_its_parser::standards::cam_1_4_1::cam_pdu_descriptions::BasicVehicleContainerHighFrequency> for msg::VehicleContainer { + fn from(value: &c_its_parser::standards::cam_1_4_1::cam_pdu_descriptions::BasicVehicleContainerHighFrequency) -> Self { let heading_deg = if value.heading.heading_value.is_unavailable() { None } else { @@ -489,11 +562,11 @@ impl From> +impl From<&alloc::boxed::Box> for msg::DenmEvent { fn from( - value: alloc::boxed::Box, + value: &alloc::boxed::Box, ) -> Self { let denm_mgmt = &value.denm.management; @@ -512,7 +585,8 @@ impl From fo } } +#[cfg(any(feature = "cam", feature = "denm", feature = "spat"))] +impl TryFrom<&c_its_parser::ItsMessage<'_>> for msg::RawMsgRx { + type Error = alloc::string::String; + + fn try_from(value: &c_its_parser::ItsMessage<'_>) -> Result { + let msg_id = c_its_parser::standards::extensions::ItsMessageId::from(value); + + let payload = match value { + #[cfg(feature = "denm")] + c_its_parser::ItsMessage::DenmV1 { + geonetworking: _, + transport: _, + etsi, + } => etsi.encode_to_uper(), + #[cfg(feature = "denm")] + c_its_parser::ItsMessage::DenmV2 { + geonetworking: _, + transport: _, + etsi, + } => etsi.encode_to_uper(), + #[cfg(any(feature = "cam", feature = "cam_tx"))] + c_its_parser::ItsMessage::Cam { + geonetworking: _, + transport: _, + etsi, + } => etsi.encode_to_uper(), + #[cfg(feature = "spat")] + c_its_parser::ItsMessage::Spatem { + geonetworking: _, + transport: _, + etsi, + } => etsi.encode_to_uper(), + #[cfg(feature = "spat")] + c_its_parser::ItsMessage::Mapem { + geonetworking: _, + transport: _, + etsi, + } => etsi.encode_to_uper(), + }?; + + Ok(Self { + msg_type: msg_id.as_u8().into(), + payload, + }) + } +} + #[cfg(test)] mod tests { diff --git a/src/main.rs b/src/main.rs index d3adbbd..ae2a14e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -513,16 +513,36 @@ async fn main(spawner: Spawner) -> ! { ItsMessage::Mapem { geonetworking: _, transport: _, - etsi, - } => state.handle_mapem(&etsi), + ref etsi, + } => { + state.handle_mapem(etsi); + + // Send data to BLE thread + #[cfg(feature = "ble")] + match io::msg::RawMsgRx::try_from(&msg) { + Ok(data) => { + ble::update(ble::BleSendable::MapemRaw(data)); + } + Err(err) => warn!("Failed to create RawMsgRx: {err}"), + } + } #[cfg(feature = "spat")] ItsMessage::Spatem { geonetworking: _, transport: _, - etsi, + ref etsi, } => { if state.initialized() { - state.handle_spatem(&etsi); + state.handle_spatem(etsi); + } + + // Send data to BLE thread + #[cfg(feature = "ble")] + match io::msg::RawMsgRx::try_from(&msg) { + Ok(data) => { + ble::update(ble::BleSendable::SpatemRaw(data)); + } + Err(err) => warn!("Failed to create RawMsgRx: {err}"), } } @@ -530,33 +550,48 @@ async fn main(spawner: Spawner) -> ! { ItsMessage::Cam { geonetworking: _, transport: _, - etsi, + ref etsi, } => { #[cfg(feature = "cam")] - applogic::cam::handle_cam(&etsi); + applogic::cam::handle_cam(etsi); // Send data to BLE thread #[cfg(all(feature = "ble", feature = "cam"))] - critical_section::with(|cs| { - let ble_update_ref = ble::TX_DATA.borrow(cs); - ble_update_ref.replace(Some(ble::BleSendable::Cam(etsi.into()))); - }); + { + ble::update(ble::BleSendable::Cam(etsi.into())); + + // raw data only when it's a full CAM + if etsi.cam.cam_parameters.low_frequency_container.is_some() { + match io::msg::RawMsgRx::try_from(&msg) { + Ok(data) => { + ble::update(ble::BleSendable::CamRaw(data)); + } + Err(err) => warn!("Failed to create RawMsgRx: {err}"), + } + } + } } #[cfg(feature = "denm")] ItsMessage::DenmV2 { geonetworking: _, transport: _, - etsi, + ref etsi, } => { - applogic::denm::handle_denm(&etsi); + applogic::denm::handle_denm(etsi); // Send data to BLE thread #[cfg(feature = "ble")] - critical_section::with(|cs| { - let ble_update_ref = ble::TX_DATA.borrow(cs); - ble_update_ref.replace(Some(ble::BleSendable::Denm(etsi.into()))); - }); + { + ble::update(ble::BleSendable::Denm(etsi.into())); + + match io::msg::RawMsgRx::try_from(&msg) { + Ok(data) => { + ble::update(ble::BleSendable::DenmRaw(data)); + } + Err(err) => warn!("Failed to create RawMsgRx: {err}"), + } + } } #[cfg(feature = "denm")] ItsMessage::DenmV1 { From 3213667eead4e77c7cb3c72ac4b0b320b0424f2c Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Mon, 24 Aug 2026 15:16:36 +0200 Subject: [PATCH 14/14] BLE: Add service UUID to advertisement, remove descriptors to save space --- Readme.md | 3 +++ src/ble.rs | 21 +++++++++++++-------- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/Readme.md b/Readme.md index 16453c6..97dfd2b 100644 --- a/Readme.md +++ b/Readme.md @@ -59,6 +59,9 @@ Enable UART I/O protocol according to [docs/uart-protocol.md](./docs/uart-protoc Enable BLE I/O protocol according to [docs/ble-protocol.md](./docs/ble-protocol.md). Enabling `denm` feature or `cam` feature is recommended, otherwise no data will be received. +Note: When changing the set of supported messages on the BLE interface, you may need to turn bluetooth off and on again on your client device. +Otherwise the client may not re-scan the service's characteristics and still use the previous set of characteristics. + ## Usage diff --git a/src/ble.rs b/src/ble.rs index 8fa5ead..bdb32be 100644 --- a/src/ble.rs +++ b/src/ble.rs @@ -56,7 +56,7 @@ struct CITSEvents { raw_notify: [u8; io::msg::RawNotify::PROTO_SIZE], #[cfg(feature = "cam")] - #[descriptor(uuid = descriptors::MEASUREMENT_DESCRIPTION, name = "CAM", read, value = "RawMsgRx proto msg", type = &'static str)] + // #[descriptor(uuid = descriptors::MEASUREMENT_DESCRIPTION, name = "CAM", read, value = "RawMsgRx proto msg", type = &'static str)] #[characteristic(uuid = "c0b70001-d4f4-4003-ada8-f99a02ee315c", read, value = [0u8; io::msg::RawMsgRx::PROTO_SIZE])] cam_raw: [u8; io::msg::RawMsgRx::PROTO_SIZE], #[cfg(feature = "cam")] @@ -65,7 +65,7 @@ struct CITSEvents { cam_event: [u8; io::msg::CamEvent::PROTO_SIZE], #[cfg(feature = "denm")] - #[descriptor(uuid = descriptors::MEASUREMENT_DESCRIPTION, name = "DENM", read, value = "RawMsgRx proto msg", type = &'static str)] + // #[descriptor(uuid = descriptors::MEASUREMENT_DESCRIPTION, name = "DENM", read, value = "RawMsgRx proto msg", type = &'static str)] #[characteristic(uuid = "c0b70001-d4f4-4005-ada8-f99a02ee315c", read, value = [0u8; io::msg::RawMsgRx::PROTO_SIZE])] denm_raw: [u8; io::msg::RawMsgRx::PROTO_SIZE], #[cfg(feature = "denm")] @@ -74,11 +74,11 @@ struct CITSEvents { denm_event: [u8; io::msg::DenmEvent::PROTO_SIZE], #[cfg(feature = "spat")] - #[descriptor(uuid = descriptors::MEASUREMENT_DESCRIPTION, name = "MAPEM", read, value = "RawMsgRx proto msg", type = &'static str)] + // #[descriptor(uuid = descriptors::MEASUREMENT_DESCRIPTION, name = "MAPEM", read, value = "RawMsgRx proto msg", type = &'static str)] #[characteristic(uuid = "c0b70001-d4f4-4007-ada8-f99a02ee315c", read, value = [0u8; io::msg::RawMsgRx::PROTO_SIZE])] mapem_raw: [u8; io::msg::RawMsgRx::PROTO_SIZE], #[cfg(feature = "spat")] - #[descriptor(uuid = descriptors::MEASUREMENT_DESCRIPTION, name = "SPATEM", read, value = "RawMsgRx proto msg", type = &'static str)] + // #[descriptor(uuid = descriptors::MEASUREMENT_DESCRIPTION, name = "SPATEM", read, value = "RawMsgRx proto msg", type = &'static str)] #[characteristic(uuid = "c0b70001-d4f4-4008-ada8-f99a02ee315c", read, value = [0u8; io::msg::RawMsgRx::PROTO_SIZE])] spatem_raw: [u8; io::msg::RawMsgRx::PROTO_SIZE], } @@ -143,8 +143,10 @@ pub async fn run( let _ = embassy_futures::join::join(ble_task(runner), async { loop { - match advertise("ESP32 C-ITS", &mut peripheral, &server).await { + match advertise(&mut peripheral, &server).await { Ok(conn) => { + info!("BLE: New client connected"); + // set up tasks when the connection is established to a central, so they don't run when no one is connected. let a = gatt_events_task(&server, &conn); let b = publishing_task(&server, &conn); @@ -158,7 +160,7 @@ pub async fn run( }); } Err(e) => { - panic!("BLE ADV: fatal error {e:?}"); + panic!("BLE ADV: fatal error: {e:?}"); } } } @@ -183,15 +185,18 @@ pub fn update(payload: BleSendable) { /// Create an advertiser to use to connect to a BLE Central, and wait for it to connect. async fn advertise<'values, 'server, C: Controller>( - name: &'values str, peripheral: &mut Peripheral<'values, C, DefaultPacketPool>, server: &'server Server<'values>, ) -> Result, BleHostError> { let mut advertiser_data = [0; 35]; + let service_uuid = Uuid::from(0xc0b7_0000_d4f4_4000_ada8_f99a_02ee_315c_u128) + .as_raw() + .try_into() + .unwrap(); let len = AdStructure::encode_slice( &[ AdStructure::Flags(LE_GENERAL_DISCOVERABLE | BR_EDR_NOT_SUPPORTED), - AdStructure::CompleteLocalName(name.as_bytes()), + AdStructure::ServiceUuids128(&[service_uuid]), ], &mut advertiser_data[..], )?;