use std::collections::HashMap;
use std::time::{Duration, Instant};
use bluetooth_core::{permission, BleEvent, BleSession};
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let command = args.first().map(String::as_str).unwrap_or("scan");
let code = match command {
"permission" => cmd_permission(),
"scan" => cmd_scan(seconds_arg(&args, 1, 5)),
"info" => match args.get(1) {
Some(id) => cmd_info(id, seconds_arg(&args, 2, 10)),
None => {
eprintln!("usage: info <id> [seconds]");
2
}
},
"-h" | "--help" | "help" => {
print_usage();
0
}
other => {
eprintln!("unknown command: {other}");
print_usage();
2
}
};
std::process::exit(code);
}
fn print_usage() {
eprintln!(
"bluetooth_cli <command>\n\
\n\
commands:\n \
permission print Bluetooth authorization status (requesting it)\n \
scan [seconds] scan for nearby BLE devices (default 5)\n \
info <id> [seconds] scan, connect to <id>, print its services"
);
}
fn seconds_arg(args: &[String], index: usize, default: u64) -> u64 {
args.get(index)
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(default)
}
fn cmd_permission() -> i32 {
let status = permission::request();
println!("Bluetooth permission: {}", describe_status(status));
if status == permission::STATUS_DENIED {
eprintln!("Denied - enable it in System Settings > Privacy & Security > Bluetooth.");
1
} else {
0
}
}
fn describe_status(status: i32) -> &'static str {
match status {
x if x == permission::STATUS_NOT_DETERMINED => "not determined",
x if x == permission::STATUS_GRANTED => "granted",
x if x == permission::STATUS_DENIED => "denied",
x if x == permission::STATUS_RESTRICTED => "restricted",
x if x == permission::STATUS_UNSUPPORTED => "no app-level permission on this platform",
_ => "unknown",
}
}
fn cmd_scan(seconds: u64) -> i32 {
let session = match open_session() {
Some(s) => s,
None => return 1,
};
if let Err(e) = session.start_scan(None) {
eprintln!("could not start scan: {e}");
return 1;
}
println!("Scanning for {seconds}s...");
let mut seen: HashMap<String, (Option<String>, Option<i16>)> = HashMap::new();
let deadline = Instant::now() + Duration::from_secs(seconds);
while Instant::now() < deadline {
while let Some(event) = session.poll_event() {
if let BleEvent::Device { device } = event {
let entry = seen
.entry(device.id.clone())
.or_insert((device.name.clone(), device.rssi));
if entry.0.is_none() {
entry.0 = device.name.clone();
}
entry.1 = device.rssi.or(entry.1);
}
}
std::thread::sleep(Duration::from_millis(100));
}
let _ = session.stop_scan();
let mut devices: Vec<_> = seen.into_iter().collect();
devices.sort_by(|a, b| b.1 .1.unwrap_or(i16::MIN).cmp(&a.1 .1.unwrap_or(i16::MIN)));
println!("\nFound {} device(s):", devices.len());
for (id, (name, rssi)) in devices {
println!(
" {:>4} dBm {} {}",
rssi.map(|r| r.to_string()).unwrap_or_else(|| "?".into()),
id,
name.unwrap_or_else(|| "(unknown)".into()),
);
}
0
}
fn cmd_info(id: &str, seconds: u64) -> i32 {
let session = match open_session() {
Some(s) => s,
None => return 1,
};
if let Err(e) = session.start_scan(None) {
eprintln!("could not start scan: {e}");
return 1;
}
println!("Looking for {id} (up to {seconds}s)...");
let deadline = Instant::now() + Duration::from_secs(seconds);
let mut found = false;
while Instant::now() < deadline && !found {
while let Some(event) = session.poll_event() {
if let BleEvent::Device { device } = event {
if device.id == id {
found = true;
break;
}
}
}
std::thread::sleep(Duration::from_millis(100));
}
let _ = session.stop_scan();
if !found {
eprintln!("did not see {id} while scanning");
return 1;
}
println!("Connecting...");
if let Err(e) = session.connect(id) {
eprintln!("connect failed: {e}");
return 1;
}
let result = match session.discover_services(id) {
Ok(services) => {
println!("Services ({}):", services.len());
for s in services {
println!(" service {}", s.uuid);
for c in s.characteristics {
println!(" char {} [{}]", c.uuid, c.properties.join(", "));
}
}
0
}
Err(e) => {
eprintln!("discover services failed: {e}");
1
}
};
let _ = session.disconnect(id);
result
}
fn open_session() -> Option<BleSession> {
let status = permission::request();
if status == permission::STATUS_DENIED || status == permission::STATUS_RESTRICTED {
eprintln!("Bluetooth permission {} - cannot scan.", describe_status(status));
return None;
}
match BleSession::new() {
Ok(s) => Some(s),
Err(e) => {
eprintln!("could not open BLE session: {e}");
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn seconds_arg_valid_number() {
let args = vec!["scan".into(), "12".into()];
assert_eq!(seconds_arg(&args, 1, 5), 12);
}
#[test]
fn seconds_arg_invalid_string() {
let args = vec!["scan".into(), "notanumber".into()];
assert_eq!(seconds_arg(&args, 1, 5), 5);
}
#[test]
fn seconds_arg_missing_index() {
let args: Vec<String> = vec!["scan".into()];
assert_eq!(seconds_arg(&args, 1, 7), 7);
}
#[test]
fn seconds_arg_default_fallback_on_empty() {
let args: Vec<String> = vec![];
assert_eq!(seconds_arg(&args, 0, 10), 10);
}
#[test]
fn describe_status_not_determined() {
assert_eq!(describe_status(permission::STATUS_NOT_DETERMINED), "not determined");
}
#[test]
fn describe_status_granted() {
assert_eq!(describe_status(permission::STATUS_GRANTED), "granted");
}
#[test]
fn describe_status_denied() {
assert_eq!(describe_status(permission::STATUS_DENIED), "denied");
}
#[test]
fn describe_status_restricted() {
assert_eq!(describe_status(permission::STATUS_RESTRICTED), "restricted");
}
#[test]
fn describe_status_unsupported() {
assert_eq!(
describe_status(permission::STATUS_UNSUPPORTED),
"no app-level permission on this platform",
);
}
#[test]
fn describe_status_unknown_code() {
assert_eq!(describe_status(999), "unknown");
}
#[test]
fn print_usage_does_not_panic() {
print_usage();
}
}