diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 836c3f1..42c9023 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -21,20 +21,24 @@ jobs: archive_name: mcumgr-client-windows-x86 os: ubuntu-latest file_extension: .exe - - target: x86_64-unknown-linux-musl + - target: x86_64-unknown-linux-gnu archive_name: mcumgr-client-linux-x86 os: ubuntu-latest - - target: aarch64-unknown-linux-musl + - target: aarch64-unknown-linux-gnu archive_name: mcumgr-client-linux-aarch64 os: ubuntu-24.04-arm steps: - uses: actions/checkout@v4 - - name: Install dependencies + - name: Install windows dependencies if: matrix.target == 'x86_64-pc-windows-gnu' run: sudo apt-get install mingw-w64 + - name: Install linux dependencies + if: matrix.target == 'x86_64-unknown-linux-gnu' || matrix.target == 'aarch64-unknown-linux-gnu' + run: sudo apt-get update && sudo apt-get install -y libdbus-1-dev pkg-config + - name: Install Rust uses: dtolnay/rust-toolchain@stable with: diff --git a/Cargo.lock b/Cargo.lock index a42f78a..e713f63 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -368,9 +368,11 @@ dependencies = [ "anyhow", "base64", "bincode", + "btleplug", "byteorder", "clap", "crc16", + "futures", "hex", "hex-buffer-serde", "humantime", @@ -389,6 +391,8 @@ dependencies = [ "serialport", "sha2", "simplelog", + "tokio", + "uuid", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index cdfc226..0f5af0e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,10 @@ include = [ [dependencies] anyhow = "1.0" +btleplug = "0.11" +futures = "0.3" +tokio = { version = "1", features = ["rt", "rt-multi-thread", "time"] } +uuid = "1" base64 = "0.21" bincode = "1.3" byteorder = "1.4" diff --git a/src/ble.rs b/src/ble.rs new file mode 100644 index 0000000..a57c3b2 --- /dev/null +++ b/src/ble.rs @@ -0,0 +1,374 @@ +use anyhow::{bail, Context, Error, Result}; +use btleplug::api::{ + Central, CentralEvent, Characteristic, Manager as _, Peripheral as _, ScanFilter, + ValueNotification, WriteType, +}; +use btleplug::platform::{Manager, Peripheral}; +use futures::StreamExt; +use log::debug; +use std::pin::Pin; +use tokio::time::{timeout, Duration}; +use uuid::{uuid, Uuid}; + +use crate::nmp_hdr::{NmpGroup, NmpHdr, NmpOp}; +use crate::transfer::Transport; + +const SMP_SERVICE_UUID: Uuid = uuid!("8D53DC1D-1DB7-4CD3-868B-8A527460AA84"); +const SMP_CHAR_UUID: Uuid = uuid!("DA2E7828-FBCE-4E01-AE9E-261174997C48"); + +#[derive(Debug, Clone)] +pub struct BleSpecs { + pub address: Option, + pub name: Option, + pub scan_timeout_s: u32, + pub timeout_s: u32, + pub mtu: usize, +} + +impl Default for BleSpecs { + fn default() -> Self { + BleSpecs { + address: None, + name: None, + scan_timeout_s: 10, + timeout_s: 10, + mtu: 244, + } + } +} + +// Groups async state so that transceive() can borrow it separately from the runtime. +struct BleConnection { + peripheral: Peripheral, + smp_char: Characteristic, + notifications: Pin + Send>>, +} + +pub struct BleTransport { + rt: tokio::runtime::Runtime, + // Wrapped in Option so Drop can take it and destroy it inside block_on, + // which is required because MessageStream::drop calls Handle::current(). + conn: Option, + seq: u8, + timeout_ms: u32, + mtu: usize, +} + +impl Drop for BleTransport { + fn drop(&mut self) { + if let Some(conn) = self.conn.take() { + self.rt.block_on(async move { drop(conn) }); + } + } +} + +impl BleTransport { + pub fn new(specs: &BleSpecs) -> Result { + if specs.address.is_none() && specs.name.is_none() { + bail!("Either --ble-address or --ble-name must be provided"); + } + + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .context("Failed to build tokio runtime")?; + + let conn = rt.block_on(Self::connect_async(specs))?; + + Ok(BleTransport { + rt, + conn: Some(conn), + seq: 0, + timeout_ms: specs.timeout_s * 1000, + mtu: specs.mtu, + }) + } + + async fn connect_async(specs: &BleSpecs) -> Result { + let manager = Manager::new().await.context("Failed to initialize BLE manager")?; + let adapters = manager.adapters().await.context("Failed to list BLE adapters")?; + let adapter = adapters + .into_iter() + .next() + .ok_or_else(|| anyhow::anyhow!("No Bluetooth adapter found"))?; + + let target_addr = specs.address.clone(); + let target_name = specs.name.clone(); + let scan_timeout = specs.scan_timeout_s; + + debug!("Starting BLE device discovery (timeout: {}s)...", scan_timeout); + + adapter + .start_scan(ScanFilter::default()) + .await + .context("Failed to start BLE scan")?; + + let mut events = adapter + .events() + .await + .context("Failed to subscribe to adapter events")?; + + let peripheral = timeout( + Duration::from_secs(scan_timeout as u64), + async { + while let Some(event) = events.next().await { + // DeviceUpdated fires when advertisement data (e.g. name) arrives after discovery + let id = match event { + CentralEvent::DeviceDiscovered(id) | CentralEvent::DeviceUpdated(id) => id, + _ => continue, + }; + + let Ok(p) = adapter.peripheral(&id).await else { + continue; + }; + + // MAC address matching — not available on macOS (CoreBluetooth hides MACs) + #[cfg(not(target_os = "macos"))] + if let Some(ref addr) = target_addr { + if p.address().to_string().eq_ignore_ascii_case(addr) { + debug!("Found target BLE device at {}", p.address()); + return Some(p); + } + } + + if let Some(ref name) = target_name { + if let Ok(Some(props)) = p.properties().await { + if let Some(ref n) = props.local_name { + if n.contains(name.as_str()) { + debug!("Found BLE device '{}' at {}", n, p.address()); + return Some(p); + } + } + } + } + } + None + }, + ) + .await + .ok() + .flatten() + .ok_or_else(|| { + anyhow::anyhow!( + "BLE device not found within {}s — ensure the device is advertising", + scan_timeout + ) + })?; + + adapter.stop_scan().await.ok(); + + if !peripheral.is_connected().await.unwrap_or(false) { + debug!("Connecting to {}...", peripheral.address()); + peripheral + .connect() + .await + .with_context(|| format!("Failed to connect to {}", peripheral.address()))?; + debug!("Connected to {}", peripheral.address()); + } else { + debug!("Already connected to {}", peripheral.address()); + } + + peripheral + .discover_services() + .await + .context("Failed to discover GATT services")?; + + let smp_char = peripheral + .services() + .into_iter() + .find(|s| s.uuid == SMP_SERVICE_UUID) + .and_then(|s| s.characteristics.into_iter().find(|c| c.uuid == SMP_CHAR_UUID)) + .ok_or_else(|| { + anyhow::anyhow!( + "SMP characteristic not found — ensure the device has BLE SMP service enabled" + ) + })?; + + debug!("Found SMP characteristic"); + + // Subscribe once; the resulting stream is reused across all transceive() calls. + peripheral + .subscribe(&smp_char) + .await + .context("Failed to subscribe to SMP characteristic notifications")?; + + let notifications = peripheral + .notifications() + .await + .context("Failed to open BLE notification stream")?; + + Ok(BleConnection { + peripheral, + smp_char, + notifications, + }) + } + + fn next_seq(&mut self) -> u8 { + let seq = self.seq; + self.seq = self.seq.wrapping_add(1); + seq + } + + fn encode_smp_header(op: NmpOp, group: NmpGroup, id: u8, len: u16, seq: u8) -> [u8; 8] { + let version: u8 = 1; // SMP v2 + let byte0 = ((version & 0x03) << 3) | (op as u8 & 0x07); + [ + byte0, + 0, + (len >> 8) as u8, + (len & 0xFF) as u8, + (group.0 >> 8) as u8, + (group.0 & 0xFF) as u8, + seq, + id, + ] + } + + fn decode_smp_header(data: &[u8]) -> Result { + if data.len() < 8 { + bail!("BLE response too short: {} bytes", data.len()); + } + let op_val = data[0] & 0x07; + let len = ((data[2] as u16) << 8) | (data[3] as u16); + let group_val = ((data[4] as u16) << 8) | (data[5] as u16); + let seq = data[6]; + let id = data[7]; + let op = match op_val { + 0 => NmpOp::Read, + 1 => NmpOp::ReadRsp, + 2 => NmpOp::Write, + 3 => NmpOp::WriteRsp, + _ => bail!("Unknown SMP op: {}", op_val), + }; + Ok(NmpHdr { + op, + flags: 0, + len, + group: NmpGroup(group_val), + seq, + id, + }) + } +} + +impl Transport for BleTransport { + fn transceive( + &mut self, + op: NmpOp, + group: NmpGroup, + id: u8, + body: &[u8], + ) -> Result<(NmpHdr, serde_cbor::Value), Error> { + let seq = self.next_seq(); + let header = Self::encode_smp_header(op, group, id, body.len() as u16, seq); + let mut packet = Vec::with_capacity(8 + body.len()); + packet.extend_from_slice(&header); + packet.extend_from_slice(body); + + debug!("BLE TX: {} bytes", packet.len()); + + let timeout_ms = self.timeout_ms; + // Borrow rt and conn as disjoint fields so the async block can capture conn. + let rt = &self.rt; + let conn = self.conn.as_mut().unwrap(); + + let response = rt.block_on(async { + conn.peripheral + .write(&conn.smp_char, &packet, WriteType::WithoutResponse) + .await + .context("Failed to write to SMP characteristic")?; + + // Collect the first notification that carries an SMP response. + // The stream may deliver notifications for other characteristics if any were + // subscribed by the platform; filter by UUID to be safe. + let first = timeout( + Duration::from_millis(timeout_ms as u64), + async { + loop { + match conn.notifications.next().await { + Some(n) if n.uuid == SMP_CHAR_UUID => break Some(n.value), + Some(_) => continue, + None => break None, + } + } + }, + ) + .await + .map_err(|_| anyhow::anyhow!("BLE response timeout after {}ms", timeout_ms))? + .ok_or_else(|| anyhow::anyhow!("BLE notification stream closed"))?; + + if first.len() < 8 { + bail!("BLE first notification too short: {} bytes", first.len()); + } + + let total_payload = ((first[2] as usize) << 8) | (first[3] as usize); + let mut payload = first[8..].to_vec(); + + // Accumulate additional notifications if the SMP payload is fragmented across ATT MTU + while payload.len() < total_payload { + let fragment = timeout( + Duration::from_millis(timeout_ms as u64), + async { + loop { + match conn.notifications.next().await { + Some(n) if n.uuid == SMP_CHAR_UUID => break Some(n.value), + Some(_) => continue, + None => break None, + } + } + }, + ) + .await + .map_err(|_| anyhow::anyhow!("BLE fragment timeout after {}ms", timeout_ms))? + .ok_or_else(|| anyhow::anyhow!("BLE notification stream closed mid-packet"))?; + + payload.extend_from_slice(&fragment); + } + + let mut full = first[..8].to_vec(); + full.extend_from_slice(&payload[..total_payload]); + Ok::, Error>(full) + })?; + + debug!("BLE RX: {} bytes", response.len()); + + let response_header = Self::decode_smp_header(&response)?; + + if response_header.seq != seq { + bail!("Sequence mismatch: expected {}, got {}", seq, response_header.seq); + } + + let expected_op = match op { + NmpOp::Read => NmpOp::ReadRsp, + NmpOp::Write => NmpOp::WriteRsp, + _ => bail!("Unexpected request op type"), + }; + + if response_header.op != expected_op || response_header.group != group { + bail!("Wrong response type"); + } + + let cbor_data = &response[8..]; + let body: serde_cbor::Value = if cbor_data.is_empty() { + serde_cbor::Value::Map(std::collections::BTreeMap::new()) + } else { + serde_cbor::from_slice(cbor_data).context("Failed to parse CBOR response")? + }; + + Ok((response_header, body)) + } + + fn set_timeout(&mut self, timeout_ms: u32) -> Result<(), Error> { + self.timeout_ms = timeout_ms; + Ok(()) + } + + fn mtu(&self) -> usize { + self.mtu + } + + fn linelength(&self) -> usize { + self.mtu + } +} diff --git a/src/lib.rs b/src/lib.rs index 03e87df..cdd47b1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,4 @@ +mod ble; mod default; mod fs; mod image; @@ -24,4 +25,5 @@ pub use crate::settings::{ }; pub use crate::shell::shell_exec; pub use crate::stat::{stat_list, stat_read}; +pub use crate::ble::{BleSpecs, BleTransport}; pub use crate::transfer::{ConnSpec, SerialSpecs, SerialTransport, Transport, UdpSpecs, UdpTransport}; diff --git a/src/main.rs b/src/main.rs index ca7bf88..1a2a3c6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -68,6 +68,22 @@ struct Cli { #[arg(short, long, default_value_t = 115_200)] baudrate: u32, + /// BLE device address (e.g. AA:BB:CC:DD:EE:FF) + #[arg(long)] + ble_address: Option, + + /// BLE device name to scan for + #[arg(long)] + ble_name: Option, + + /// BLE scan/connect timeout in seconds + #[arg(long, default_value_t = 10)] + ble_timeout: u32, + + /// BLE ATT MTU payload size in bytes + #[arg(long, default_value_t = 244)] + ble_mtu: usize, + #[command(subcommand)] command: Commands, } @@ -86,6 +102,20 @@ impl From<&Cli> for SerialSpecs { } impl Cli { + fn is_ble(&self) -> bool { + self.ble_address.is_some() || self.ble_name.is_some() + } + + fn ble_specs(&self) -> BleSpecs { + BleSpecs { + address: self.ble_address.clone(), + name: self.ble_name.clone(), + scan_timeout_s: self.ble_timeout, + timeout_s: self.initial_timeout_s, + mtu: self.ble_mtu, + } + } + fn is_udp(&self) -> bool { self.host.is_some() } @@ -299,7 +329,15 @@ fn main() { .unwrap_or_else(|_| SimpleLogger::init(LevelFilter::Info, Default::default()).unwrap()); // Build transport connection - let conn = if cli.is_udp() { + let conn = if cli.is_ble() { + let ble_specs = cli.ble_specs(); + if let Some(ref addr) = ble_specs.address { + info!("Using BLE transport: address {}", addr); + } else if let Some(ref name) = ble_specs.name { + info!("Using BLE transport: scanning for '{}'", name); + } + ConnSpec::Ble(ble_specs) + } else if cli.is_udp() { let udp_specs = cli.udp_specs(); info!("Using UDP transport: {}:{}", udp_specs.host, udp_specs.port); ConnSpec::Udp(udp_specs) diff --git a/src/transfer.rs b/src/transfer.rs index e5f3393..15d5955 100644 --- a/src/transfer.rs +++ b/src/transfer.rs @@ -14,6 +14,7 @@ use std::net::{SocketAddr, ToSocketAddrs, UdpSocket}; use std::sync::atomic::{AtomicU8, Ordering}; use std::time::Duration; +use crate::ble::{BleSpecs, BleTransport}; use crate::nmp_hdr::*; use crate::test_serial_port::TestSerialPort; @@ -38,11 +39,12 @@ pub trait Transport { fn linelength(&self) -> usize; } -/// Connection specification - either serial or UDP +/// Connection specification - serial, UDP, or BLE #[derive(Debug, Clone)] pub enum ConnSpec { Serial(SerialSpecs), Udp(UdpSpecs), + Ble(BleSpecs), } impl ConnSpec { @@ -57,6 +59,10 @@ impl ConnSpec { let transport = UdpTransport::new(specs)?; Ok(Box::new(transport)) } + ConnSpec::Ble(specs) => { + let transport = BleTransport::new(specs)?; + Ok(Box::new(transport)) + } } } }