Watch
0
0
Fork
You've already forked esp32-c_its-companion
0

WIP Add battery level readout and publish on BLE

TODO: show on display when low battery
This commit is contained in:
Jannik Beyerstedt 2026-09-28 11:46:09 +02:00
commit 484e2afd77
4 changed files with 136 additions and 6 deletions

View file

@ -1,2 +1,2 @@
stack-size-threshold = 1024
doc-valid-idents = ["WiFi", "GeoNetworking", "QoS", ".."]
doc-valid-idents = ["WiFi", "GeoNetworking", "QoS", "LiPo", ".."]

View file

@ -23,6 +23,8 @@ pub static TX_DATA: Mutex<RefCell<Option<ArrayQueue<BleSendable>>>> =
pub static RX_DATA: Mutex<RefCell<Option<io::msg::PositionState>>> = 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<DefaultPacketPool, CONNECTIONS_MAX, L2CAP_CHANNELS_MAX> =
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<GattConnection<'values, 'server, DefaultPacketPool>, BleHostError<C::Error>> {
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<P: PacketPool>(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) => {

View file

@ -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;
}
}
}

85
src/xiao.rs Normal file
View file

@ -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<IoAdc, AdcPeripheral<'a>, adc::AdcCalCurve<AdcPeripheral<'a>>>,
adc: adc::Adc<'a, AdcPeripheral<'a>, esp_hal::Blocking>,
}
impl<'a, IoAdc> Battery<'a, IoAdc>
where
IoAdc: adc::AdcChannel + gpio::AnalogPin,
{
pub fn new<IoEnable: gpio::OutputPin>(
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
}
}
}