From cd84754465e52ed2e77b343ecfd4d019efc76b2e Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Wed, 30 Sep 2026 09:31:47 +0200 Subject: [PATCH 1/3] build: Update default configurations --- Readme.md | 3 ++- feat-permut.sh | 15 ++++++++------- run-standalone-ble.sh | 6 ++++++ run-standalone-cam.sh | 2 +- ...andalone-no_cam.sh => run-standalone-simple.sh | 0 5 files changed, 17 insertions(+), 9 deletions(-) create mode 100755 run-standalone-ble.sh rename run-standalone-no_cam.sh => run-standalone-simple.sh (100%) diff --git a/Readme.md b/Readme.md index 97d547d..968afd2 100644 --- a/Readme.md +++ b/Readme.md @@ -98,7 +98,8 @@ Additional features are available through feature flags: 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,ble` ("standalone with BLE") +- `esp,spat,gnss_ubx,screen,cam_tx,ble` ("standalone with CAM") - `esp,spat,gnss_ubx,screen,cam_tx,cam,denm` - `esp,uart,cam,denm` ("smartphone companion with UART") - `esp,ble,cam,denm,cam_tx` ("smartphone companion with BLE") diff --git a/feat-permut.sh b/feat-permut.sh index c8d5646..9cebe55 100755 --- a/feat-permut.sh +++ b/feat-permut.sh @@ -7,15 +7,16 @@ 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 + spat,gnss_ubx,screen # "standalone" simple + spat,gnss_ubx,screen,ble # "standalone" with BLE + spat,gnss_ubx,screen,ble,cam_tx # "standalone" with CAM + spat,gnss_ubx,screen,cam_tx,cam,denm + uart,cam,denm # "smartphone companion with UART" + ble,cam,denm,cam_tx # "smartphone companion with BLE" ) features_more=( - spat,gnss_ubx,screen,ble - spat,gnss_ubx,screen,ble,cam_tx + uart,ble,cam,denm + spat,gnss_ubx,screen,cam_tx spat,gnss_ubx,screen,cam,denm spat,gnss_ubx,screen,cam_tx,cam,denm gnss_ubx,screen,cam_tx,cam,denm diff --git a/run-standalone-ble.sh b/run-standalone-ble.sh new file mode 100755 index 0000000..6957199 --- /dev/null +++ b/run-standalone-ble.sh @@ -0,0 +1,6 @@ +#!/bin/sh + +## Builds common config for standalone device +## -> enable SPAT/ GLOSA with GNSS and a screen + BLE + +cargo run --release -F ble diff --git a/run-standalone-cam.sh b/run-standalone-cam.sh index 9bfafbe..0bc50c2 100755 --- a/run-standalone-cam.sh +++ b/run-standalone-cam.sh @@ -3,4 +3,4 @@ ## Builds common config for standalone device with automatic CAM transmission ## -> enable SPAT/ GLOSA with GNSS and a screen + CAM transmission -cargo run --release -F cam_tx +cargo run --release -F cam_tx,ble diff --git a/run-standalone-no_cam.sh b/run-standalone-simple.sh similarity index 100% rename from run-standalone-no_cam.sh rename to run-standalone-simple.sh From dc54b55230fa2851b9caa2013c08a53b24152c26 Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Tue, 29 Sep 2026 16:38:52 +0200 Subject: [PATCH 2/3] ble: Add device information service --- build.rs | 22 +++++++++++++++ src/ble.rs | 80 +++++++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 13 ++++++++- src/util.rs | 38 +++++++++++++++++++++++++ 4 files changed, 152 insertions(+), 1 deletion(-) diff --git a/build.rs b/build.rs index f376e0e..00deee1 100644 --- a/build.rs +++ b/build.rs @@ -9,6 +9,7 @@ fn main() -> std::io::Result<()> { println!("cargo:rustc-link-arg=-Tlinkall.x"); } + // compile protobuf definition to rust code let descriptor_path = std::path::PathBuf::from(env::var("OUT_DIR").unwrap()).join("proto_descriptor.bin"); @@ -17,6 +18,27 @@ fn main() -> std::io::Result<()> { .compile_well_known_types() .compile_protos(&["docs/c_its-messages.proto"], &["docs/"])?; + // get git revision + println!("cargo:rustc-rerun-if-changed=.git/HEAD"); + + // length: + // - tag name: 6-8 chars (v0.1.0 to v1.23.42) + // - offset+hash: 0-12 chars (up to 99 commits from tag + 8 char hash) + // - SUM: 6-20 + let git_output = std::process::Command::new("git") + .args(["describe", "--tags", "--match", "v*", "--always"]) + .output() + .unwrap(); + assert!( + git_output.stderr.is_empty(), + "{}", + String::from_utf8(git_output.stderr).unwrap() + ); + println!( + "cargo:rustc-env=GIT_REVISION={}", + String::from_utf8(git_output.stdout).unwrap() + ); + Ok(()) } diff --git a/src/ble.rs b/src/ble.rs index f72e972..8ff24c4 100644 --- a/src/ble.rs +++ b/src/ble.rs @@ -39,9 +39,73 @@ pub enum BleSendable { SpatemRaw(io::msg::RawMsgRx), } +pub const DIS_SERIAL_MAX_LEN: usize = 17; +pub const DIS_FW_REVISION_MAX_LEN: usize = 20; // git-describe +pub const DIS_SW_REVISION_MAX_LEN: usize = 66; // max length of get_feature_list, joined with comma + +pub struct DeviceInfo { + /// WiFi MAC + pub serial_number: [u8; DIS_SERIAL_MAX_LEN], + /// git tag/ revision + pub fw_revision: [u8; DIS_FW_REVISION_MAX_LEN], + /// used for feature flags + pub sw_revision: [u8; DIS_SW_REVISION_MAX_LEN], +} + +impl DeviceInfo { + pub fn new(serial_number: &str, fw_revision: &str, sw_revision: &str) -> Self { + Self { + serial_number: string_as_padded_buf(serial_number, DIS_SERIAL_MAX_LEN) + .try_into() + .unwrap(), + fw_revision: string_as_padded_buf(fw_revision, DIS_FW_REVISION_MAX_LEN) + .try_into() + .unwrap(), + sw_revision: string_as_padded_buf(sw_revision, DIS_SW_REVISION_MAX_LEN) + .try_into() + .unwrap(), + } + } +} + +pub fn string_as_padded_buf(value: &str, length: usize) -> alloc::vec::Vec { + let buf = value.as_bytes(); + let buf_len = buf.len(); + + // cut buffer at maximum length or pad with whitespace + if buf_len > length { + buf[..length].to_vec() + } else { + let padding_len = length - buf_len; + let padding = alloc::vec![b' '; padding_len]; + + [buf, &padding].concat() + } +} + #[gatt_server] struct Server { c_its_events: CITSEvents, + device_info_service: DeviceInfoService, +} + +#[gatt_service(uuid = service::DEVICE_INFORMATION)] +struct DeviceInfoService { + /// Manufacturer name + #[characteristic(uuid = characteristic::MANUFACTURER_NAME_STRING, read, value = "jtbx")] + mfg_name: &'static str, + /// Model number + #[characteristic(uuid = characteristic::MODEL_NUMBER_STRING, read, value = "ESP32-C5 C-ITS Companion")] + model_number: &'static str, + // Serial Number String + #[characteristic(uuid = characteristic::SERIAL_NUMBER_STRING, read, value = [0u8; DIS_SERIAL_MAX_LEN])] + serial_number: [u8; DIS_SERIAL_MAX_LEN], + // Firmware Revision String + #[characteristic(uuid = characteristic::FIRMWARE_REVISION_STRING, read, value = [0u8; DIS_FW_REVISION_MAX_LEN])] + fw_revision: [u8; DIS_FW_REVISION_MAX_LEN], + // Software Revision String + #[characteristic(uuid = characteristic::SOFTWARE_REVISION_STRING, read, value = [0u8; DIS_SW_REVISION_MAX_LEN])] + sw_revision: [u8; DIS_SW_REVISION_MAX_LEN], } #[gatt_service(uuid = "c0b70000-d4f4-4000-ada8-f99a02ee315c")] @@ -111,6 +175,7 @@ pub async fn run( >, name: alloc::string::String, short_name: alloc::string::String, + device_info: DeviceInfo, ) { let mut resources: HostResources = HostResources::new(); @@ -132,6 +197,21 @@ pub async fn run( })) .unwrap(); + // set "dynamic" device info data + let _ = server + .device_info_service + .serial_number + .set(&server, &device_info.serial_number); + let _ = server + .device_info_service + .fw_revision + .set(&server, &device_info.fw_revision); + let _ = server + .device_info_service + .sw_revision + .set(&server, &device_info.sw_revision); + + // advertise and run update logic let _ = embassy_futures::join::join(ble_task(runner), async { loop { match advertise(&mut peripheral, &server, &short_name).await { diff --git a/src/main.rs b/src/main.rs index 57a8963..d5956b7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -228,7 +228,18 @@ async fn main(spawner: Spawner) -> ! { serial[2] ); let short_name = alloc::format!("V2X {:02x}{:02x}", serial[1], serial[2]); // max 8 chars! - spawner.spawn(ble::run(controller, name, short_name).expect("Failed to spawn BLE task")); + + let fw_revision = core::env!("GIT_REVISION"); + let sw_revision = util::get_feature_list().join(","); + spawner.spawn( + ble::run( + controller, + name, + short_name, + ble::DeviceInfo::new(&mac.to_string(), fw_revision, &sw_revision), + ) + .expect("Failed to spawn BLE task"), + ); } // Setup GNSS: diff --git a/src/util.rs b/src/util.rs index 4b0dd89..e92a760 100644 --- a/src/util.rs +++ b/src/util.rs @@ -31,3 +31,41 @@ pub(crate) fn get_tx_feature_list() -> Vec<&'static str> { features } + +#[allow(unused)] +pub(crate) fn get_feature_list() -> Vec<&'static str> { + let mut features = alloc::vec![]; + + if cfg!(feature = "gnss_ubx") { + features.push("gnss_ubx"); + } + if cfg!(feature = "gnss_l67k") { + features.push("gnss_l67k"); + } + if cfg!(feature = "cam_tx") { + features.push("cam_tx"); + } + if cfg!(feature = "cam") { + features.push("cam"); + } + if cfg!(feature = "denm") { + features.push("denm"); + } + if cfg!(feature = "spat") { + features.push("spat"); + } + if cfg!(feature = "spat_debug") { + features.push("spat_debug"); + } + if cfg!(feature = "screen") { + features.push("screen"); + } + if cfg!(feature = "uart") { + features.push("uart"); + } + if cfg!(feature = "ble") { + features.push("ble"); + } + + features +} From 0b055900cf30a19019c255bbf9405b752f5b9b8a Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Mon, 28 Sep 2026 11:46:09 +0200 Subject: [PATCH 3/3] WIP Add battery level readout and publish on BLE TODO: show on display when low battery TODO: ADC readout is not plausible --- .clippy.toml | 2 +- src/ble.rs | 74 +++++++++++++++++++++++++++++++++++++++-- src/main.rs | 23 +++++++++++++ src/xiao.rs | 94 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 189 insertions(+), 4 deletions(-) create mode 100644 src/xiao.rs diff --git a/.clippy.toml b/.clippy.toml index 3e969e0..a8f21f2 100644 --- a/.clippy.toml +++ b/.clippy.toml @@ -1,2 +1,2 @@ stack-size-threshold = 1024 -doc-valid-idents = ["WiFi", "GeoNetworking", "QoS", ".."] +doc-valid-idents = ["WiFi", "GeoNetworking", "QoS", "LiPo", ".."] diff --git a/src/ble.rs b/src/ble.rs index 8ff24c4..0d5d85e 100644 --- a/src/ble.rs +++ b/src/ble.rs @@ -23,6 +23,8 @@ pub static TX_DATA: Mutex>>> = pub static RX_DATA: Mutex>> = Mutex::new(RefCell::new(None)); pub enum BleSendable { + BatteryLevel(u8), + #[cfg(feature = "cam")] CamRaw(io::msg::RawMsgRx), #[cfg(feature = "cam")] @@ -87,6 +89,7 @@ pub fn string_as_padded_buf(value: &str, length: usize) -> alloc::vec::Vec { struct Server { c_its_events: CITSEvents, device_info_service: DeviceInfoService, + battery_service: BatteryService, } #[gatt_service(uuid = service::DEVICE_INFORMATION)] @@ -108,6 +111,15 @@ struct DeviceInfoService { sw_revision: [u8; DIS_SW_REVISION_MAX_LEN], } +#[gatt_service(uuid = service::BATTERY)] +struct BatteryService { + /// Battery Level + #[descriptor(uuid = descriptors::VALID_RANGE, read, value = [0, 100])] + #[descriptor(uuid = descriptors::MEASUREMENT_DESCRIPTION, name = "main", read, value = "Battery Level", type = &'static str)] + #[characteristic(uuid = characteristic::BATTERY_LEVEL, read, notify, value = 0)] + level: u8, +} + #[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)] @@ -214,8 +226,11 @@ pub async fn run( // advertise and run update logic let _ = embassy_futures::join::join(ble_task(runner), async { loop { - match advertise(&mut peripheral, &server, &short_name).await { - Ok(conn) => { + // run advertisement and set data until a client connects + let a = advertise(&mut peripheral, &server, &short_name); + let b = data_update_task(&server); + match embassy_futures::select::select(a, b).await { + embassy_futures::select::Either::First(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. @@ -230,15 +245,17 @@ pub async fn run( let _ = value_ref.replace(false); }); } - Err(e) => { + embassy_futures::select::Either::First(Err(e)) => { panic!("BLE ADV: fatal error: {e:?}"); } + _ => {} } } }) .await; } +/// Queues data for BLE notification (only when client is connected) pub fn update(payload: BleSendable) { critical_section::with(|cs| { // only add to queue when someone is connected @@ -254,6 +271,24 @@ pub fn update(payload: BleSendable) { }); } +/// Queues data for characteristic update (may notify a connected client) +/// +/// This can be used instead of `update()`, but will always write the data to the characteristic even when no client is connected. +/// So a newly connected client will be able to read an up to date value at connection and not just when new data was received. +/// +/// This is useful for (device) information which is not time-critical (like received V2X data). +pub fn set(payload: BleSendable) { + critical_section::with(|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 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>( peripheral: &mut Peripheral<'values, C, DefaultPacketPool>, @@ -375,6 +410,12 @@ async fn publishing_task(server: &Server<'_>, conn: &GattConnecti }); match new_data { + Some(BleSendable::BatteryLevel(value)) => { + if let Err(err) = server.battery_service.level.notify(conn, &value).await { + warn!("BLE: failed notifying connection of new battery level: {err:?}"); + } + } + #[cfg(feature = "cam")] Some(BleSendable::Cam(cam_event)) => match cam_event.serialize_ble_proto() { Ok(value) => { @@ -476,6 +517,33 @@ async fn publishing_task(server: &Server<'_>, conn: &GattConnecti } } +async fn data_update_task(server: &Server<'_>) { + loop { + embassy_time::Timer::after(embassy_time::Duration::from_millis(10)).await; + + let mut new_data = None; + critical_section::with(|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(data) = queue.pop() { + new_data = Some(data); + } + }); + + #[allow(clippy::single_match)] + match new_data { + Some(BleSendable::BatteryLevel(value)) => { + if let Err(err) = server.battery_service.level.set(server, &value) { + warn!("BLE: failed setting new battery level: {err:?}"); + } + } + _ => {} + } + } +} + async fn send_raw_notify( server: &Server<'_>, conn: &GattConnection<'_, '_, P>, diff --git a/src/main.rs b/src/main.rs index d5956b7..9be23d6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -59,7 +59,9 @@ mod screen; #[cfg(feature = "gnss_ubx")] mod ubx; mod util; +mod xiao; +const BAT_UPDATE_INTERVAL: esp_hal::time::Duration = esp_hal::time::Duration::from_secs(30); const WIFI_CHANNEL: radio::Channel = 180; const WIFI_TXPOWER: i8 = 10; // dBm, 2..20 #[cfg(feature = "spat")] @@ -144,6 +146,9 @@ async fn main(spawner: Spawner) -> ! { info!("Embassy initialized!"); + // Setup battery voltage readout (GPIO6 as input and GPIO26 as enable) + let mut battery = xiao::Battery::new(peripherals.ADC1, peripherals.GPIO6, peripherals.GPIO26); + // Setup ST7789 screen // SCL: D8/ GPIO8 (SPI_SCK) // SDA: D10/ GPIO10 (SPI_MOSI) @@ -284,6 +289,13 @@ async fn main(spawner: Spawner) -> ! { #[cfg(feature = "_gnss")] info!("GNSS task started, waiting for GNSS fix..."); + // Setup main loop + let (bat_percent, bat_millivolt) = battery.read(); + log::info!("Battery: {bat_millivolt} mV, {bat_percent} %"); + #[cfg(feature = "ble")] + ble::set(ble::BleSendable::BatteryLevel(bat_percent)); + let mut last_bat_update = esp_hal::time::Instant::now(); + #[cfg(any(feature = "cam_tx", feature = "uart"))] let (mut seq_no, mac_bytes) = { // unwrap is fine since [`esp_hal::efuse::MacAddress`] is just an `[u8; 6]` @@ -626,6 +638,17 @@ async fn main(spawner: Spawner) -> ! { // maintenance state.prune(); + + let cpu_now = esp_hal::time::Instant::now(); + if last_bat_update + BAT_UPDATE_INTERVAL < cpu_now { + let (bat_percent, bat_millivolt) = battery.read(); + log::info!("Battery: {bat_millivolt} mV, {bat_percent} %"); + + #[cfg(feature = "ble")] + ble::set(ble::BleSendable::BatteryLevel(bat_percent)); + + last_bat_update = cpu_now; + } } } diff --git a/src/xiao.rs b/src/xiao.rs new file mode 100644 index 0000000..8e6d8be --- /dev/null +++ b/src/xiao.rs @@ -0,0 +1,94 @@ +//! XIAO ESP32-C5 Helpers + +use esp_hal::analog::adc; +use esp_hal::gpio; +use log::debug; + +type AdcPeripheral<'a> = esp_hal::peripherals::ADC1<'a>; + +pub struct Battery<'a, IoAdc> +where + IoAdc: adc::AdcChannel + gpio::AnalogPin, +{ + // using `AdcCalCurve` here gives readings in millivolts + adc_pin: adc::AdcPin, adc::AdcCalCurve>>, + adc: adc::Adc<'a, AdcPeripheral<'a>, esp_hal::Blocking>, +} + +impl<'a, IoAdc> Battery<'a, IoAdc> +where + IoAdc: adc::AdcChannel + gpio::AnalogPin, +{ + pub fn new( + adc_periph: AdcPeripheral<'a>, + adc_pin: IoAdc, + enable_pin: IoEnable, + ) -> Self { + // enable battery voltage readout + let _ = gpio::Output::new(enable_pin, gpio::Level::High, gpio::OutputConfig::default()); + + // configure ADC + let mut adc_config = adc::AdcConfig::new(); + let adc_pin = adc_config.enable_pin_with_cal(adc_pin, adc::Attenuation::_11dB); + let adc = adc::Adc::new(adc_periph, adc_config); + + Self { adc_pin, adc } + } + + /// Reads from ADC and returns battery voltage in millivolts + pub fn read_millivolts(&mut self) -> u16 { + // average over 16 samples + let mut accu = 0u16; + for _ in 0..16 { + let val = loop { + match self.adc.read_oneshot(&mut self.adc_pin) { + Ok(value) => break value, + Err(err) => { + debug!("Failed to read from ADC: {err:?}"); + } + } + }; + + accu += val; + } + let adc_val = accu / 16; + + // convert to battery voltage: 1:2 voltage divider + adc_val * 2 + } + + /// Returns estimated battery charge state in percent + #[allow(unused)] + pub fn read_percent(&mut self) -> u8 { + Self::millivolts_to_percent(self.read_millivolts()) + } + + /// Returns estimated battery charge state in percent and millivolts + pub fn read(&mut self) -> (u8, u16) { + let millivolt = self.read_millivolts(); + let percent = Self::millivolts_to_percent(millivolt); + + (percent, millivolt) + } + + /// Converts single cell LiPo battery voltage to charge state in percent + /// + /// Note: The implementation is a drastically simplified linear estimation. + /// This is known to be insufficient for an actual state of charge, but should be fine for now. + pub fn millivolts_to_percent(millivolt: u16) -> u8 { + let (full_mv, empty_mv) = (4200, 3270); // 100% to 0% SoC + + if millivolt < empty_mv { + 0 + } else if millivolt >= full_mv { + 100 + } else { + let percent = i32::from(millivolt - empty_mv) * 100 / i32::from(full_mv - empty_mv); + + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + let result = percent as u8; + + result + } + } +}