bluetooth_cli 0.0.1

Command-line tool for driving bluetooth_core against real hardware.
//! `bluetooth_cli` - drive `bluetooth_core` against real hardware.
//!
//! Subcommands:
//!   permission            Print Bluetooth authorization status (requesting it).
//!   scan `seconds`        Scan for nearby BLE devices (default 5s).
//!   info `id` `seconds`   Scan to find `id`, connect, and print its services.
//!
//! Run from the repo root, e.g.:
//!   cargo run --manifest-path native/bluetooth_cli/Cargo.toml -- scan 8

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"
    );
}

/// Parses the arg at `index` as a u64 seconds value, falling back to `default`.
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...");
    // Track the latest record per id so the summary is deduplicated.
    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));
                // Keep a name once we learn it, and the most recent 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;
    }

    // Scan until we have seen the target id (so the session has cached its
    // handle), or the deadline passes.
    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
}

/// Opens a session, reporting permission/adapter problems helpfully.
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();
    }
}