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 a460d4e..8fd2e65 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")] @@ -42,6 +44,16 @@ pub enum BleSendable { #[gatt_server] struct Server { c_its_events: CITSEvents, + battery_service: BatteryService, +} + +#[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")] @@ -110,6 +122,7 @@ pub async fn run( CONTROLLER_SLOTS, >, name: alloc::string::String, + short_name: alloc::string::String, ) { let mut resources: HostResources = HostResources::new(); @@ -120,8 +133,9 @@ pub async fn run( .. } = stack.build(); + info!("BLE name: {name} (short: {short_name})"); info!( - "Starting advertising and GATT service for {}", + "Starting advertising and GATT service for {} as '{name}'/ '{short_name}'", crate::util::get_rx_feature_list().join(",") ); let server = Server::new_with_config(GapConfig::Peripheral(PeripheralConfig { @@ -132,7 +146,7 @@ pub async fn run( let _ = embassy_futures::join::join(ble_task(runner), async { loop { - match advertise(&mut peripheral, &server).await { + match advertise(&mut peripheral, &server, &short_name).await { Ok(conn) => { info!("BLE: New client connected"); @@ -176,6 +190,8 @@ pub fn update(payload: BleSendable) { async fn advertise<'values, 'server, C: Controller>( peripheral: &mut Peripheral<'values, C, DefaultPacketPool>, server: &'server Server<'values>, + // name in advertisement, max. 12 characters + short_name: &'values str, ) -> Result, BleHostError> { let mut advertiser_data = [0; 35]; let service_uuid = Uuid::from(0xc0b7_0000_d4f4_4000_ada8_f99a_02ee_315c_u128) @@ -184,8 +200,9 @@ async fn advertise<'values, 'server, C: Controller>( .unwrap(); let len = AdStructure::encode_slice( &[ - AdStructure::Flags(LE_GENERAL_DISCOVERABLE | BR_EDR_NOT_SUPPORTED), - AdStructure::ServiceUuids128(&[service_uuid]), + AdStructure::Flags(LE_GENERAL_DISCOVERABLE | BR_EDR_NOT_SUPPORTED), // 2+ 1 byte + AdStructure::ServiceUuids128(&[service_uuid]), // 2+16 byte = 21 + AdStructure::CompleteLocalName(short_name.as_bytes()), // 2+ 12 byte = 35 ], &mut advertiser_data[..], )?; @@ -286,6 +303,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) => { diff --git a/src/main.rs b/src/main.rs index e00e282..e99012e 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,10 @@ 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); + // TODO: expose via BLE as battery service! + // Setup ST7789 screen // SCL: D8/ GPIO8 (SPI_SCK) // SDA: D10/ GPIO10 (SPI_MOSI) @@ -227,7 +233,8 @@ async fn main(spawner: Spawner) -> ! { serial[1], serial[2] ); - spawner.spawn(ble::run(controller, name).expect("Failed to spawn BLE task")); + let short_name = alloc::format!("V2X {:02x}{:02x}{:02x}", serial[0], serial[1], serial[2]); // max 8 chars! -> 10 chars + spawner.spawn(ble::run(controller, name, short_name).expect("Failed to spawn BLE task")); } // Setup GNSS: @@ -272,6 +279,10 @@ async fn main(spawner: Spawner) -> ! { #[cfg(feature = "_gnss")] info!("GNSS task started, waiting for GNSS fix..."); + // Setup main loop + info!("Battery: {} %", battery.read_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]` @@ -631,6 +642,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 = battery.read_percent(); + info!("Battery: {bat_percent} %"); + + #[cfg(feature = "ble")] + ble::update(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..6cbff88 --- /dev/null +++ b/src/xiao.rs @@ -0,0 +1,85 @@ +//! 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 + pub fn read_percent(&mut self) -> u8 { + Self::millivolts_to_percent(self.read_millivolts()) + } + + /// 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 + } + } +}