#[cfg_attr(not(any(feature = "btleplug-support", test)), allow(dead_code))]
#[derive(Debug, Clone, PartialEq, Eq)]
struct CliArgs {
name_filter: Option<String>,
command: Command,
}
#[cfg_attr(not(any(feature = "btleplug-support", test)), allow(dead_code))]
#[derive(Debug, Clone, PartialEq, Eq)]
enum Command {
Inspect,
Probe(ProbeCommand),
}
#[cfg_attr(not(any(feature = "btleplug-support", test)), allow(dead_code))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ProbeCommand {
Brightness,
FirmwareStick,
FirmwareHolder,
Battery,
}
fn usage() -> &'static str {
"Usage: cargo run --features btleplug-support -- <inspect|probe> [subcommand] [--name <substring>]
Commands:
inspect
probe brightness
probe firmware-stick
probe firmware-holder
probe battery"
}
#[cfg_attr(not(any(feature = "btleplug-support", test)), allow(dead_code))]
fn parse_args(args: impl IntoIterator<Item = String>) -> Result<CliArgs, String> {
let mut args = args.into_iter();
let _binary = args.next();
let mut name_filter = None;
let mut positional = Vec::new();
while let Some(arg) = args.next() {
if arg == "--name" {
let value = args.next().ok_or_else(|| "missing value for --name".to_string())?;
name_filter = Some(value);
} else if let Some(value) = arg.strip_prefix("--name=") {
if value.is_empty() {
return Err("missing value for --name".to_string());
}
name_filter = Some(value.to_string());
} else if arg.starts_with("--") {
return Err(format!("unknown option: {arg}"));
} else {
positional.push(arg);
}
}
let command = match positional.as_slice() {
[command] if command == "inspect" => Command::Inspect,
[command, probe] if command == "probe" => Command::Probe(
parse_probe_command(probe)
.ok_or_else(|| format!("unknown probe subcommand: {probe}"))?,
),
[] => return Err("missing command".to_string()),
_ => return Err("invalid command shape".to_string()),
};
Ok(CliArgs { name_filter, command })
}
#[cfg_attr(not(any(feature = "btleplug-support", test)), allow(dead_code))]
fn parse_probe_command(value: &str) -> Option<ProbeCommand> {
match value {
"brightness" => Some(ProbeCommand::Brightness),
"firmware-stick" => Some(ProbeCommand::FirmwareStick),
"firmware-holder" => Some(ProbeCommand::FirmwareHolder),
"battery" => Some(ProbeCommand::Battery),
_ => None,
}
}
#[cfg(feature = "btleplug-support")]
#[tokio::main]
async fn main() {
if let Err(error) = run().await {
eprintln!("error: {error}");
eprintln!();
eprintln!("{}", usage());
std::process::exit(1);
}
}
#[cfg(not(feature = "btleplug-support"))]
fn main() {
eprintln!("the debug CLI requires the `btleplug-support` feature");
eprintln!();
eprintln!("{}", usage());
std::process::exit(1);
}
#[cfg(feature = "btleplug-support")]
async fn run() -> Result<(), Box<dyn std::error::Error>> {
use std::io;
let args = parse_args(std::env::args()).map_err(io::Error::other)?;
match args.command {
Command::Inspect => inspect_device(args.name_filter.as_deref()).await,
Command::Probe(command) => probe_device(command, args.name_filter.as_deref()).await,
}
}
#[cfg(feature = "btleplug-support")]
async fn inspect_device(name_filter: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
use iqos::{Iqos, IqosBle};
let (peripheral, selected_name) = select_peripheral(name_filter).await?;
println!("Selected device: {selected_name}");
ensure_connected_and_discovered(&peripheral).await?;
print_service_summary(&peripheral);
let session = IqosBle::connect_and_discover(peripheral).await?;
let model = session.model();
let device_info = session.device_info().clone();
let battery_level = session.read_battery_level().await;
let iqos = Iqos::new(session);
match iqos.read_device_status(model, device_info).await {
Ok(status) => {
print_device_status(&status);
}
Err(error) => println!("Device status: read failed ({error})"),
}
println!("{}", battery_level_line(&battery_level));
Ok(())
}
#[cfg(feature = "btleplug-support")]
async fn probe_device(
command: ProbeCommand,
name_filter: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
use iqos::{FirmwareKind, Iqos, IqosBle};
let (peripheral, selected_name) = select_peripheral(name_filter).await?;
println!("Selected device: {selected_name}");
let session = IqosBle::connect_and_discover(peripheral).await?;
let iqos = Iqos::new(session.clone());
match command {
ProbeCommand::Brightness => {
let brightness = iqos.read_brightness().await?;
println!("Brightness: {brightness}");
}
ProbeCommand::FirmwareStick => {
let firmware = iqos.read_firmware_version(FirmwareKind::Stick).await?;
println!("Stick firmware: {firmware}");
}
ProbeCommand::FirmwareHolder => {
let firmware = iqos.read_firmware_version(FirmwareKind::Holder).await?;
println!("Holder firmware: {firmware}");
}
ProbeCommand::Battery => {
let level = session.read_battery_level().await?;
println!("Battery level: {level}%");
}
}
Ok(())
}
#[cfg(feature = "btleplug-support")]
async fn select_peripheral(
name_filter: Option<&str>,
) -> Result<(btleplug::platform::Peripheral, String), Box<dyn std::error::Error>> {
use std::io;
use btleplug::api::{Central, Manager as _, Peripheral as _, ScanFilter};
use btleplug::platform::Manager;
use tokio::time::{Duration, sleep};
let manager = Manager::new().await?;
let adapter = manager
.adapters()
.await?
.into_iter()
.next()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no Bluetooth adapter found"))?;
println!("Scanning for IQOS devices...");
adapter.start_scan(ScanFilter::default()).await?;
sleep(Duration::from_secs(3)).await;
adapter.stop_scan().await?;
let normalized_filter = name_filter.map(|value| value.to_ascii_lowercase());
let mut candidates = Vec::new();
for peripheral in adapter.peripherals().await? {
let Some(properties) = peripheral.properties().await? else {
continue;
};
let Some(name) = properties.local_name else {
continue;
};
if !name.to_ascii_uppercase().contains("IQOS") {
continue;
}
if let Some(filter) = &normalized_filter
&& !name.to_ascii_lowercase().contains(filter)
{
continue;
}
candidates.push((name, peripheral));
}
candidates.sort_by(|left, right| left.0.cmp(&right.0));
candidates.into_iter().next().map(|(name, peripheral)| (peripheral, name)).ok_or_else(|| {
io::Error::new(io::ErrorKind::NotFound, "no matching IQOS device found").into()
})
}
#[cfg(feature = "btleplug-support")]
async fn ensure_connected_and_discovered(
peripheral: &btleplug::platform::Peripheral,
) -> Result<(), Box<dyn std::error::Error>> {
use btleplug::api::Peripheral as _;
if !peripheral.is_connected().await? {
peripheral.connect().await?;
}
peripheral.discover_services().await?;
Ok(())
}
#[cfg(feature = "btleplug-support")]
fn print_service_summary(peripheral: &btleplug::platform::Peripheral) {
use btleplug::api::Peripheral as _;
let mut services: Vec<_> = peripheral.services().into_iter().collect();
services.sort_by(|left, right| left.uuid.as_hyphenated().cmp(right.uuid.as_hyphenated()));
println!("Services:");
if services.is_empty() {
println!(" (none)");
return;
}
for service in services {
println!(" {} [{}]", service.uuid, if service.primary { "primary" } else { "secondary" },);
let mut characteristics: Vec<_> = service.characteristics.into_iter().collect();
characteristics
.sort_by(|left, right| left.uuid.as_hyphenated().cmp(right.uuid.as_hyphenated()));
if characteristics.is_empty() {
println!(" (no characteristics)");
continue;
}
for characteristic in characteristics {
println!(" {} {:?}", characteristic.uuid, characteristic.properties);
}
}
}
#[cfg(feature = "btleplug-support")]
fn print_device_status(status: &iqos::DeviceStatus) {
let info = &status.device_info;
let na = "(missing)";
println!("Model: {:?}", status.model);
println!("Model Number: {}", info.model_number.as_deref().unwrap_or(na));
println!("Serial Number: {}", info.serial_number.as_deref().unwrap_or(na));
println!("Manufacturer: {}", info.manufacturer_name.as_deref().unwrap_or(na));
if let Some(holder_firmware) = &status.holder_firmware {
println!("Firmware: {}", status.stick_firmware);
println!();
println!("Stick:");
println!(" Product Number: {}", status.product_number);
println!(" Software Revision: {}", info.software_revision.as_deref().unwrap_or(na));
println!("Holder:");
println!(" Product Number: {}", status.holder_product_number.as_deref().unwrap_or(na));
println!(" Firmware: {holder_firmware}");
} else {
println!("Software Rev: {}", info.software_revision.as_deref().unwrap_or(na));
println!("Firmware: {}", status.stick_firmware);
println!("Product Number: {}", status.product_number);
}
match status.battery_voltage {
Some(v) => println!("Battery: {v:.3} V"),
None => println!("Battery: read failed"),
}
}
#[cfg_attr(not(any(feature = "btleplug-support", test)), allow(dead_code))]
fn battery_level_line(result: &iqos::Result<u8>) -> String {
match result {
Ok(level) => format!("Battery level (GATT): {level}%"),
Err(error) => format!("Battery level (GATT): read failed ({error})"),
}
}
#[cfg(test)]
mod tests {
use super::{CliArgs, Command, ProbeCommand, battery_level_line, parse_args};
fn parse(arguments: &[&str]) -> Result<CliArgs, String> {
parse_args(arguments.iter().map(|value| (*value).to_string()))
}
#[test]
fn parses_inspect_command_without_filter() {
let args = parse(&["iqos", "inspect"]).expect("inspect should parse");
assert_eq!(args, CliArgs { name_filter: None, command: Command::Inspect });
}
#[test]
fn parses_probe_command_with_name_filter() {
let args = parse(&["iqos", "--name", "prime", "probe", "firmware-stick"])
.expect("probe command should parse");
assert_eq!(
args,
CliArgs {
name_filter: Some("prime".to_string()),
command: Command::Probe(ProbeCommand::FirmwareStick),
}
);
}
#[test]
fn rejects_unknown_probe_subcommand() {
let error = parse(&["iqos", "probe", "unknown"]).expect_err("unknown probe should fail");
assert!(error.contains("unknown probe subcommand"));
}
#[test]
fn rejects_missing_name_value() {
let error = parse(&["iqos", "--name"]).expect_err("missing filter should fail");
assert_eq!(error, "missing value for --name");
}
#[test]
fn rejects_invalid_command_shape() {
let error = parse(&["iqos", "inspect", "extra"]).expect_err("extra args should fail");
assert_eq!(error, "invalid command shape");
}
#[test]
fn formats_gatt_battery_level_result() {
assert_eq!(battery_level_line(&Ok(87)), "Battery level (GATT): 87%");
assert_eq!(
battery_level_line(&Err(iqos::Error::Transport("read failed".to_string()))),
"Battery level (GATT): read failed (transport error: read failed)"
);
}
}