From fb339fbe4abdf00ad9011db28430466d724f8e94 Mon Sep 17 00:00:00 2001 From: okhsunrog Date: Tue, 14 Jul 2026 23:16:13 +0300 Subject: [PATCH 1/4] feat(cli): add USB transport selection --- Cargo.lock | 81 ++++++++++++++++++++++++++-- Cargo.toml | 2 +- src/cli.rs | 22 +++++++- src/loader/cmds/autostart.rs | 4 +- src/loader/cmds/battery.rs | 16 ++++-- src/loader/cmds/brightness.rs | 4 +- src/loader/cmds/command.rs | 4 +- src/loader/cmds/diagnosis.rs | 4 +- src/loader/cmds/findmyiqos.rs | 4 +- src/loader/cmds/flexbattery.rs | 4 +- src/loader/cmds/flexpuff.rs | 4 +- src/loader/cmds/help.rs | 4 +- src/loader/cmds/info.rs | 4 +- src/loader/cmds/lock.rs | 4 +- src/loader/cmds/smartgesture.rs | 4 +- src/loader/cmds/unlock.rs | 4 +- src/loader/cmds/version.rs | 4 +- src/loader/cmds/vibration.rs | 4 +- src/loader/parser.rs | 17 +++--- src/main.rs | 93 ++++++++++++++++++++++++++++----- 20 files changed, 228 insertions(+), 59 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 785c721..7265135 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -253,6 +253,22 @@ dependencies = [ "memchr", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -397,7 +413,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef033ed5e9bad94e55838ca0ca906db0e043f517adda0c8b79c7a8c66c93c1b5" dependencies = [ "cfg-if", - "rustix", + "rustix 0.38.44", "windows-sys 0.48.0", ] @@ -529,15 +545,25 @@ dependencies = [ "hashbrown 0.17.0", ] +[[package]] +name = "io-kit-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06d3a048d09fbb6597dbf7c69f40d14df4a49487db1487191618c893fc3b1c26" +dependencies = [ + "core-foundation-sys", + "mach2", +] + [[package]] name = "iqos" version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d642c991ed9c909971bbc6e0679ae20ba5f9c063e92aa0b1227a657860db51f" +source = "git+https://github.com/okhsunrog/iqos?branch=agent%2Fusb-transport#d8464713a0f25b84cd5e1dd66f8604592789cbd8" dependencies = [ "async-trait", "btleplug", "futures", + "nusb", "thiserror 2.0.12", "tokio", "uuid", @@ -651,6 +677,12 @@ version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "lock_api" version = "0.4.12" @@ -667,6 +699,15 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "mach2" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1b95cd5421ec55b445b5ae102f5ea0e768de1f82bd3001e11f426c269c3aea" +dependencies = [ + "libc", +] + [[package]] name = "memchr" version = "2.7.4" @@ -704,6 +745,25 @@ dependencies = [ "libc", ] +[[package]] +name = "nusb" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "215a49c10e8cff30a0da93b258b21846c4e9242f7683748cfa8474fdc2e4d22b" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "futures-core", + "io-kit-sys", + "linux-raw-sys 0.12.1", + "log", + "once_cell", + "rustix 1.1.4", + "slab", + "tokio", + "windows-sys 0.61.2", +] + [[package]] name = "objc-sys" version = "0.3.5" @@ -865,7 +925,20 @@ dependencies = [ "bitflags 2.9.0", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.9.0", + "errno", + "libc", + "linux-raw-sys 0.12.1", "windows-sys 0.59.0", ] diff --git a/Cargo.toml b/Cargo.toml index 9116578..a513e61 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ dirs = "5" btleplug = "0.11" colored = "3.0" futures = "0.3" -iqos = { version = "1.1.1", features = ["btleplug-support"] } +iqos = { git = "https://github.com/okhsunrog/iqos", branch = "agent/usb-transport", features = ["btleplug-support", "usb-support"] } rustyline = "11.0" serde = { version = "1.0", features = ["derive"] } tokio = { version = "1.0", features = ["full"] } diff --git a/src/cli.rs b/src/cli.rs index 96938c4..a189c81 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -7,7 +7,7 @@ pub const VERSION: &str = env!("CARGO_PKG_VERSION"); #[derive(Debug, Parser)] #[command( name = "iqos", - about = "Control IQOS devices over BLE", + about = "Control IQOS devices over BLE or USB", disable_version_flag = true )] pub struct Cli { @@ -23,6 +23,10 @@ pub struct Cli { #[arg(long, value_name = "secs")] pub timeout: Option, + /// Connect through USB HID instead of scanning over BLE. + #[arg(long, action = ArgAction::SetTrue)] + pub usb: bool, + #[command(subcommand)] pub command: Option, } @@ -236,6 +240,11 @@ pub fn normalize_global_options(args: Vec) -> Vec { continue; } + if arg == "--usb" { + global_options.push(arg.clone()); + continue; + } + remaining.push(arg.clone()); } @@ -332,6 +341,17 @@ mod tests { assert!(matches!(cli.command, Some(CliCommand::Battery))); } + #[test] + fn normalizes_usb_after_command() { + let args = normalize_global_options(strings(["iqos", "info", "--usb"])); + + assert_eq!(args, strings(["iqos", "--usb", "info"])); + + let cli = Cli::try_parse_from(args).unwrap(); + assert!(cli.usb); + assert!(matches!(cli.command, Some(CliCommand::Info))); + } + #[test] fn normalizes_global_options_between_command_args() { let args = normalize_global_options(strings([ diff --git a/src/loader/cmds/autostart.rs b/src/loader/cmds/autostart.rs index 55af460..ec0593c 100644 --- a/src/loader/cmds/autostart.rs +++ b/src/loader/cmds/autostart.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use anyhow::Result; -use iqos::{DeviceCapability, Iqos, IqosBle}; +use iqos::{DeviceCapability, Iqos, IqosTransport}; use tokio::sync::Mutex; use crate::loader::parser::{invalid_arguments, IQOSConsole}; @@ -20,7 +20,7 @@ enum AutostartAction { Status, } -async fn execute(iqos: Arc>>, args: Vec) -> Result<()> { +async fn execute(iqos: Arc>>, args: Vec) -> Result<()> { let action = parse_action(&args)?; let iqos = iqos.lock().await; let model = iqos.transport().model(); diff --git a/src/loader/cmds/battery.rs b/src/loader/cmds/battery.rs index 4ead46d..741d88c 100644 --- a/src/loader/cmds/battery.rs +++ b/src/loader/cmds/battery.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use anyhow::Result; -use iqos::{Iqos, IqosBle}; +use iqos::{Iqos, IqosTransport}; use tokio::sync::Mutex; use crate::loader::parser::IQOSConsole; @@ -13,9 +13,17 @@ pub fn register_command(console: &mut IQOSConsole) { ); } -async fn execute(iqos: Arc>>) -> Result<()> { +async fn execute(iqos: Arc>>) -> Result<()> { let iqos = iqos.lock().await; - let level = iqos.transport().read_battery_level().await?; - println!("Battery: {level}%"); + match iqos.transport() { + IqosTransport::Ble(transport) => { + let level = transport.read_battery_level().await?; + println!("Battery: {level}%"); + } + IqosTransport::Usb(_) => { + let voltage = iqos.read_battery_voltage().await?; + println!("Battery voltage: {voltage:.3}V"); + } + } Ok(()) } diff --git a/src/loader/cmds/brightness.rs b/src/loader/cmds/brightness.rs index f84d4d8..2ab600b 100644 --- a/src/loader/cmds/brightness.rs +++ b/src/loader/cmds/brightness.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use anyhow::Result; -use iqos::{BrightnessLevel, DeviceCapability, Iqos, IqosBle}; +use iqos::{BrightnessLevel, DeviceCapability, Iqos, IqosTransport}; use tokio::sync::Mutex; use crate::loader::parser::{invalid_arguments, IQOSConsole}; @@ -13,7 +13,7 @@ pub fn register_command(console: &mut IQOSConsole) { ); } -async fn execute(iqos: Arc>>, args: Vec) -> Result<()> { +async fn execute(iqos: Arc>>, args: Vec) -> Result<()> { let iqos = iqos.lock().await; if !iqos diff --git a/src/loader/cmds/command.rs b/src/loader/cmds/command.rs index 68641da..2991c89 100644 --- a/src/loader/cmds/command.rs +++ b/src/loader/cmds/command.rs @@ -4,12 +4,12 @@ use std::pin::Pin; use std::sync::Arc; use anyhow::Result; -use iqos::{Iqos, IqosBle}; +use iqos::{Iqos, IqosTransport}; use tokio::sync::Mutex; pub type CommandFn = Box< dyn Fn( - Arc>>, + Arc>>, Vec, ) -> Pin> + Send>> + Send diff --git a/src/loader/cmds/diagnosis.rs b/src/loader/cmds/diagnosis.rs index f5064cd..21caeb8 100644 --- a/src/loader/cmds/diagnosis.rs +++ b/src/loader/cmds/diagnosis.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use anyhow::Result; -use iqos::{Iqos, IqosBle}; +use iqos::{Iqos, IqosTransport}; use tokio::sync::Mutex; use crate::loader::parser::IQOSConsole; @@ -13,7 +13,7 @@ pub fn register_command(console: &mut IQOSConsole) { ); } -async fn execute(iqos: Arc>>, _args: Vec) -> Result<()> { +async fn execute(iqos: Arc>>, _args: Vec) -> Result<()> { let iqos = iqos.lock().await; let data = iqos.read_diagnosis().await?; println!("Diagnosis:"); diff --git a/src/loader/cmds/findmyiqos.rs b/src/loader/cmds/findmyiqos.rs index d37f74a..d315e4b 100644 --- a/src/loader/cmds/findmyiqos.rs +++ b/src/loader/cmds/findmyiqos.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use anyhow::Result; -use iqos::{Iqos, IqosBle}; +use iqos::{Iqos, IqosTransport}; use rustyline::DefaultEditor; use tokio::sync::Mutex; @@ -14,7 +14,7 @@ pub fn register_command(console: &mut IQOSConsole) { ); } -async fn execute(iqos: Arc>>) -> Result<()> { +async fn execute(iqos: Arc>>) -> Result<()> { println!("Starting Find My IQOS..."); { diff --git a/src/loader/cmds/flexbattery.rs b/src/loader/cmds/flexbattery.rs index 0dea919..9657109 100644 --- a/src/loader/cmds/flexbattery.rs +++ b/src/loader/cmds/flexbattery.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use anyhow::Result; -use iqos::{DeviceCapability, FlexBatteryMode, FlexBatterySettings, Iqos, IqosBle}; +use iqos::{DeviceCapability, FlexBatteryMode, FlexBatterySettings, Iqos, IqosTransport}; use tokio::sync::Mutex; use crate::loader::parser::{invalid_arguments, IQOSConsole}; @@ -13,7 +13,7 @@ pub fn register_command(console: &mut IQOSConsole) { ); } -async fn execute(iqos: Arc>>, args: Vec) -> Result<()> { +async fn execute(iqos: Arc>>, args: Vec) -> Result<()> { let iqos = iqos.lock().await; let model = iqos.transport().model(); diff --git a/src/loader/cmds/flexpuff.rs b/src/loader/cmds/flexpuff.rs index b871ef1..6b388d4 100644 --- a/src/loader/cmds/flexpuff.rs +++ b/src/loader/cmds/flexpuff.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use anyhow::Result; -use iqos::{DeviceCapability, FlexPuffSetting, Iqos, IqosBle}; +use iqos::{DeviceCapability, FlexPuffSetting, Iqos, IqosTransport}; use tokio::sync::Mutex; use crate::loader::parser::{invalid_arguments, IQOSConsole}; @@ -20,7 +20,7 @@ enum FlexPuffAction { Status, } -async fn execute(iqos: Arc>>, args: Vec) -> Result<()> { +async fn execute(iqos: Arc>>, args: Vec) -> Result<()> { let action = parse_action(&args)?; let iqos = iqos.lock().await; let model = iqos.transport().model(); diff --git a/src/loader/cmds/help.rs b/src/loader/cmds/help.rs index 7c5d330..e00c889 100644 --- a/src/loader/cmds/help.rs +++ b/src/loader/cmds/help.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use anyhow::Result; -use iqos::{DeviceCapability, Iqos, IqosBle}; +use iqos::{DeviceCapability, Iqos, IqosTransport}; use tokio::sync::Mutex; use crate::loader::parser::IQOSConsole; @@ -13,7 +13,7 @@ pub fn register_command(console: &mut IQOSConsole) { ); } -async fn execute(iqos: Arc>>) -> Result<()> { +async fn execute(iqos: Arc>>) -> Result<()> { let iqos = iqos.lock().await; let model = iqos.transport().model(); println!("Available commands:"); diff --git a/src/loader/cmds/info.rs b/src/loader/cmds/info.rs index 0ae5622..fcd93ee 100644 --- a/src/loader/cmds/info.rs +++ b/src/loader/cmds/info.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use anyhow::Result; -use iqos::{DeviceStatus, Iqos, IqosBle}; +use iqos::{DeviceStatus, Iqos, IqosTransport}; use tokio::sync::Mutex; use crate::loader::parser::{invalid_arguments, IQOSConsole}; @@ -13,7 +13,7 @@ pub fn register_command(console: &mut IQOSConsole) { ); } -async fn execute(iqos: Arc>>, args: Vec) -> Result<()> { +async fn execute(iqos: Arc>>, args: Vec) -> Result<()> { if args.len() != 1 { return Err(invalid_arguments("Usage: info")); } diff --git a/src/loader/cmds/lock.rs b/src/loader/cmds/lock.rs index 5c08e15..8665a6e 100644 --- a/src/loader/cmds/lock.rs +++ b/src/loader/cmds/lock.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use anyhow::Result; -use iqos::{Iqos, IqosBle}; +use iqos::{Iqos, IqosTransport}; use tokio::sync::Mutex; use crate::loader::parser::IQOSConsole; @@ -13,7 +13,7 @@ pub fn register_command(console: &mut IQOSConsole) { ); } -async fn execute(iqos: Arc>>) -> Result<()> { +async fn execute(iqos: Arc>>) -> Result<()> { let iqos = iqos.lock().await; iqos.lock(iqos.transport().model()).await?; println!("Device locked"); diff --git a/src/loader/cmds/smartgesture.rs b/src/loader/cmds/smartgesture.rs index f1ab66f..b66241e 100644 --- a/src/loader/cmds/smartgesture.rs +++ b/src/loader/cmds/smartgesture.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use anyhow::Result; -use iqos::{DeviceCapability, Iqos, IqosBle}; +use iqos::{DeviceCapability, Iqos, IqosTransport}; use tokio::sync::Mutex; use crate::loader::parser::{invalid_arguments, IQOSConsole}; @@ -19,7 +19,7 @@ enum SmartGestureAction { Disable, } -async fn execute(iqos: Arc>>, args: Vec) -> Result<()> { +async fn execute(iqos: Arc>>, args: Vec) -> Result<()> { let action = parse_action(&args)?; let iqos = iqos.lock().await; let model = iqos.transport().model(); diff --git a/src/loader/cmds/unlock.rs b/src/loader/cmds/unlock.rs index 3c61fb9..34ad974 100644 --- a/src/loader/cmds/unlock.rs +++ b/src/loader/cmds/unlock.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use anyhow::Result; -use iqos::{Iqos, IqosBle}; +use iqos::{Iqos, IqosTransport}; use tokio::sync::Mutex; use crate::loader::parser::IQOSConsole; @@ -13,7 +13,7 @@ pub fn register_command(console: &mut IQOSConsole) { ); } -async fn execute(iqos: Arc>>) -> Result<()> { +async fn execute(iqos: Arc>>) -> Result<()> { let iqos = iqos.lock().await; iqos.unlock(iqos.transport().model()).await?; println!("Device unlocked"); diff --git a/src/loader/cmds/version.rs b/src/loader/cmds/version.rs index 6354005..10a2daf 100644 --- a/src/loader/cmds/version.rs +++ b/src/loader/cmds/version.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use anyhow::Result; -use iqos::{Iqos, IqosBle}; +use iqos::{Iqos, IqosTransport}; use tokio::sync::Mutex; use crate::cli::print_version; @@ -14,7 +14,7 @@ pub fn register_command(console: &mut IQOSConsole) { ); } -async fn execute(_iqos: Arc>>, args: Vec) -> Result<()> { +async fn execute(_iqos: Arc>>, args: Vec) -> Result<()> { if args.len() != 1 { return Err(invalid_arguments("Usage: version")); } diff --git a/src/loader/cmds/vibration.rs b/src/loader/cmds/vibration.rs index 9c594bd..2162279 100644 --- a/src/loader/cmds/vibration.rs +++ b/src/loader/cmds/vibration.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use anyhow::Result; -use iqos::{Iqos, IqosBle, VibrationSettings}; +use iqos::{Iqos, IqosTransport, VibrationSettings}; use tokio::sync::Mutex; use crate::loader::parser::{invalid_arguments, IQOSConsole}; @@ -13,7 +13,7 @@ pub fn register_command(console: &mut IQOSConsole) { ); } -async fn execute(iqos: Arc>>, args: Vec) -> Result<()> { +async fn execute(iqos: Arc>>, args: Vec) -> Result<()> { let iqos = iqos.lock().await; let model = iqos.transport().model(); let str_args: Vec<&str> = args.iter().map(String::as_str).collect(); diff --git a/src/loader/parser.rs b/src/loader/parser.rs index 7a77fcf..d1239c3 100644 --- a/src/loader/parser.rs +++ b/src/loader/parser.rs @@ -4,7 +4,7 @@ use std::path::PathBuf; use std::sync::Arc; use anyhow::Result; -use iqos::{Iqos, IqosBle}; +use iqos::{Iqos, IqosTransport}; use rustyline::error::ReadlineError; use rustyline::{Config, Editor}; use tokio::sync::Mutex; @@ -63,17 +63,17 @@ pub fn is_invalid_argument_message(message: &str) -> bool { pub struct IQOSConsole { commands: CommandRegistry, - iqos: Arc>>, + iqos: Arc>>, connected_device: Option, } impl IQOSConsole { - pub fn new(iqos: Iqos) -> Self { + pub fn new(iqos: Iqos) -> Self { Self::with_connected_device(iqos, None) } pub fn with_connected_device( - iqos: Iqos, + iqos: Iqos, connected_device: Option, ) -> Self { Self { @@ -151,20 +151,23 @@ impl IQOSConsole { } #[allow(dead_code)] -pub async fn run_console(iqos: Iqos) -> Result<()> { +pub async fn run_console(iqos: Iqos) -> Result<()> { let mut console = IQOSConsole::new(iqos); register_all_commands(&mut console); console.run().await } -pub async fn run_console_with_device(iqos: Iqos, device: ConnectedDevice) -> Result<()> { +pub async fn run_console_with_device( + iqos: Iqos, + device: ConnectedDevice, +) -> Result<()> { let mut console = IQOSConsole::with_connected_device(iqos, Some(device)); register_all_commands(&mut console); console.run().await } pub async fn run_registered_command( - iqos: Iqos, + iqos: Iqos, command: &str, args: Vec, ) -> Result<()> { diff --git a/src/main.rs b/src/main.rs index 936f9fd..df4f69b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,7 +10,7 @@ use btleplug::platform::{Adapter, Manager, Peripheral, PeripheralId}; use clap::Parser; use colored::Colorize; use futures::stream::StreamExt; -use iqos::{DeviceModel, Iqos, IqosBle}; +use iqos::{DeviceModel, Iqos, IqosBle, IqosTransport, IqosUsb}; mod cli; mod config; @@ -47,6 +47,7 @@ impl ExitError { #[derive(Debug, Clone)] enum ScanTarget { + Usb, Model(DeviceModel), Address { label: Option, @@ -154,7 +155,8 @@ async fn run_cli(mut args: Vec) -> i32 { } let Some(command) = cli.command else { - return match run_auto_connected_console(cli.model, scan_timeout(cli.timeout)).await { + return match run_auto_connected_console(cli.model, scan_timeout(cli.timeout), cli.usb).await + { Ok(()) => 0, Err(error) => { eprintln!("Error: {:#}", error.error); @@ -166,6 +168,7 @@ async fn run_cli(mut args: Vec) -> i32 { match run_one_shot( cli.model, scan_timeout(cli.timeout), + cli.usb, command.into_one_shot(), ) .await @@ -181,6 +184,7 @@ async fn run_cli(mut args: Vec) -> i32 { async fn run_auto_connected_console( model_arg: Option, timeout: Duration, + use_usb: bool, ) -> std::result::Result<(), ExitError> { print_ascii_art(); @@ -188,8 +192,8 @@ async fn run_auto_connected_console( mut config, target, should_save_memory, - } = load_config_and_resolve_target(model_arg.as_deref(), true)?; - let (iqos, device) = connect_target(&target, timeout).await?; + } = load_config_and_resolve_target(model_arg.as_deref(), true, use_usb)?; + let (iqos, device) = connect_target(&target, timeout, use_usb).await?; apply_connection_memory(&mut config, &target, &device); save_connection_memory(&config, &target, should_save_memory, true)?; @@ -202,6 +206,7 @@ async fn run_auto_connected_console( async fn run_one_shot( model_arg: Option, timeout: Duration, + use_usb: bool, command: OneShotCommand, ) -> std::result::Result<(), ExitError> { match command { @@ -233,12 +238,18 @@ async fn run_one_shot( } } OneShotCommand::DeviceSave { label } => { + if use_usb { + return Err(ExitError::new( + EXIT_INVALID_ARGUMENTS, + anyhow!("Saving USB devices is not supported yet"), + )); + } let label = validate_device_label(&label) .map_err(|error| ExitError::new(EXIT_INVALID_ARGUMENTS, error))?; let ResolvedTarget { mut config, target, .. - } = load_config_and_resolve_target(model_arg.as_deref(), false)?; - let (iqos, device) = connect_target(&target, timeout).await?; + } = load_config_and_resolve_target(model_arg.as_deref(), false, false)?; + let (iqos, device) = connect_target(&target, timeout, false).await?; apply_connection_memory(&mut config, &target, &device); config .save_device(label.clone(), &device) @@ -255,8 +266,8 @@ async fn run_one_shot( config: mut command_config, target, should_save_memory, - } = load_config_and_resolve_target(model_arg.as_deref(), true)?; - let (iqos, device) = connect_target(&target, timeout).await?; + } = load_config_and_resolve_target(model_arg.as_deref(), true, use_usb)?; + let (iqos, device) = connect_target(&target, timeout, use_usb).await?; apply_connection_memory(&mut command_config, &target, &device); save_connection_memory(&command_config, &target, should_save_memory, true)?; @@ -270,7 +281,26 @@ async fn run_one_shot( fn load_config_and_resolve_target( model_arg: Option<&str>, allow_model_config_failure: bool, + use_usb: bool, ) -> std::result::Result { + if use_usb { + let (config, _) = load_memory_config(true)?; + let target = match model_arg { + Some(value) => ScanTarget::Model(parse_device_model(value).ok_or_else(|| { + ExitError::new( + EXIT_INVALID_ARGUMENTS, + anyhow!("USB mode accepts a device model, not a saved BLE label: {value}"), + ) + })?), + None => ScanTarget::Usb, + }; + return Ok(ResolvedTarget { + config, + target, + should_save_memory: false, + }); + } + if let Some(value) = model_arg { if let Some(model) = parse_device_model(value) { let (config, should_save_memory) = load_memory_config(allow_model_config_failure)?; @@ -368,7 +398,12 @@ fn save_connection_memory( async fn connect_target( target: &ScanTarget, timeout: Duration, -) -> std::result::Result<(IqosBle, ConnectedDevice), ExitError> { + use_usb: bool, +) -> std::result::Result<(IqosTransport, ConnectedDevice), ExitError> { + if use_usb { + return connect_usb(target).await; + } + let manager = Manager::new() .await .map_err(|error| ExitError::new(EXIT_CONNECTION_FAILED, error))?; @@ -381,10 +416,38 @@ async fn connect_target( let ble = IqosBle::connect_and_discover(peripheral) .await .map_err(|error| ExitError::new(EXIT_CONNECTION_FAILED, error))?; - let device = connected_device(&ble, discovered); + let device = connected_ble_device(&ble, discovered); warn_serial_mismatch(target, &device); - Ok((ble, device)) + Ok((ble.into(), device)) +} + +async fn connect_usb( + target: &ScanTarget, +) -> std::result::Result<(IqosTransport, ConnectedDevice), ExitError> { + let usb = IqosUsb::connect() + .await + .map_err(|error| ExitError::new(EXIT_CONNECTION_FAILED, error))?; + let device = ConnectedDevice { + address: usb.location().to_string(), + local_name: usb.device_info().model_number.clone(), + model: usb.model(), + serial_number: usb.device_info().serial_number.clone(), + }; + + if let ScanTarget::Model(expected) = target { + if *expected != device.model { + return Err(ExitError::new( + EXIT_CONNECTION_FAILED, + anyhow!( + "Connected USB device is {:?}, requested {expected:?}", + device.model + ), + )); + } + } + + Ok((usb.into(), device)) } async fn find_matching_peripheral( @@ -483,9 +546,9 @@ async fn run_interactive() -> Result<()> { if prompt_for_connection(&name, &discovered.address).await? { println!("Connecting..."); let ble = IqosBle::connect_and_discover(peripheral).await?; - let device = connected_device(&ble, discovered); + let device = connected_ble_device(&ble, discovered); remember_connected_device(&device); - let iqos = Iqos::new(ble); + let iqos = Iqos::new(IqosTransport::Ble(ble)); central.stop_scan().await?; run_console_with_device(iqos, device).await?; return Ok(()); @@ -537,7 +600,7 @@ fn discovered_device( } } -fn connected_device(ble: &IqosBle, discovered: DiscoveredDevice) -> ConnectedDevice { +fn connected_ble_device(ble: &IqosBle, discovered: DiscoveredDevice) -> ConnectedDevice { ConnectedDevice { address: discovered.address, local_name: discovered.local_name, @@ -548,6 +611,7 @@ fn connected_device(ble: &IqosBle, discovered: DiscoveredDevice) -> ConnectedDev fn target_matches(target: &ScanTarget, discovered: &DiscoveredDevice) -> bool { match target { + ScanTarget::Usb => false, ScanTarget::Model(model) => { discovered .local_name @@ -609,6 +673,7 @@ fn classify_command_error(error: &anyhow::Error) -> i32 { fn describe_target(target: &ScanTarget) -> String { match target { + ScanTarget::Usb => "USB IQOS".to_string(), ScanTarget::Model(model) => format!("{model:?}"), ScanTarget::Address { label: Some(label), From 651e8a820408d6e61fc6a026bdd94f7d95b88bc1 Mon Sep 17 00:00:00 2001 From: okhsunrog Date: Tue, 14 Jul 2026 23:16:17 +0300 Subject: [PATCH 2/4] fix(cli): trim trailing GATT NUL bytes --- src/loader/cmds/info.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/loader/cmds/info.rs b/src/loader/cmds/info.rs index fcd93ee..7dab54c 100644 --- a/src/loader/cmds/info.rs +++ b/src/loader/cmds/info.rs @@ -65,7 +65,7 @@ fn print_status(status: &DeviceStatus) { } fn field_or_missing(value: Option<&str>) -> &str { - value.unwrap_or("N/A") + value.map_or("N/A", |field| field.trim_end_matches('\0')) } #[cfg(test)] @@ -81,4 +81,9 @@ mod tests { fn present_field_is_preserved() { assert_eq!(field_or_missing(Some("value")), "value"); } + + #[test] + fn trailing_gatt_nul_is_not_printed() { + assert_eq!(field_or_missing(Some("value\0")), "value"); + } } From 7d9f12a4a79f0b1f2c8fff018f3481af29ce3cf7 Mon Sep 17 00:00:00 2001 From: okhsunrog Date: Tue, 14 Jul 2026 23:16:22 +0300 Subject: [PATCH 3/4] docs: document USB transport usage --- README.md | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 8f7a0c2..9b63ab3 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@
-**A command-line interface for controlling IQOS devices via Bluetooth Low Energy, built on [V-VX/iqos](https://github.com/V-VX/iqos)** +**A command-line interface for controlling IQOS devices over Bluetooth Low Energy or USB, built on [V-VX/iqos](https://github.com/V-VX/iqos)** [![Rust](https://img.shields.io/badge/rust-1.92%2B-orange.svg?style=flat-square&logo=rust)](https://www.rust-lang.org/) [![License](https://img.shields.io/badge/license-GPL--3.0-blue.svg?style=flat-square)](LICENSE) @@ -32,15 +32,16 @@ ## Overview -IQOS CLI is a Rust-based command-line tool for controlling IQOS devices over Bluetooth Low Energy, built on top of [V-VX/iqos](https://github.com/V-VX/iqos). It supports both an interactive REPL and one-shot command execution, so you can either connect once and work from the `iqos>` prompt or run a single command directly from your shell. +IQOS CLI is a Rust-based command-line tool for controlling IQOS devices over Bluetooth Low Energy or USB, built on top of [V-VX/iqos](https://github.com/V-VX/iqos). It supports both an interactive REPL and one-shot command execution, so you can either connect once and work from the `iqos>` prompt or run a single command directly from your shell. ## Architecture -All device protocol logic — BLE framing, capability negotiation, command encoding, response parsing — lives in the [iqos crate (V-VX/iqos)](https://github.com/V-VX/iqos). This repository is a thin CLI layer: it handles device discovery, user interaction, and argument parsing, then delegates every device operation to the crate's high-level API. +All device protocol logic — BLE/USB framing, capability negotiation, command encoding, and response parsing — lives in the [iqos crate (V-VX/iqos)](https://github.com/V-VX/iqos). This repository is a thin CLI layer: it handles device discovery, user interaction, and argument parsing, then delegates every device operation to the crate's high-level API. ## Features - **Automatic Device Discovery** — Scans and connects to IQOS devices via Bluetooth +- **Direct USB Connection** — Select the attached IQOS with `--usb` without BLE scanning - **Interactive Console** — REPL with command history (`iqos>` prompt) - **One-Shot CLI Commands** — Run device commands directly, for example `iqos --model iluma battery` - **Saved Device Labels** — Remember a connected device and target it later with `--model