Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 77 additions & 4 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
27 changes: 21 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

<div align="center">

**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)
Expand Down Expand Up @@ -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 <label>`
Expand Down Expand Up @@ -76,7 +77,8 @@ All device protocol logic — BLE framing, capability negotiation, command encod

## Prerequisites

- **Bluetooth adapter** — A working Bluetooth adapter on your system
- **BLE use** — A working Bluetooth adapter
- **USB use** — Access to an attached IQOS USB device (Linux may require a udev rule or `sudo`)
- **Platform-specific dependencies**:

**Linux:**
Expand Down Expand Up @@ -172,6 +174,16 @@ iqos brightness high --model iluma
iqos vibration heating on --model iluma-i --timeout 5
```

Use the experimental USB transport instead of scanning over BLE:

```bash
iqos --usb info
iqos --usb battery
iqos --usb diagnosis
```

These commands, plus read-only brightness, vibration, FlexPuff, FlexBattery, and AutoStart queries, have been validated on an IQOS ILUMA i and compared with BLE results from the same device. The shared library validates both ordinary SCP CRC-8 frames and the CRC-16 format used by diagnosis telemetry. Stateful USB commands are not yet hardware-validated.

`--model` accepts either a built-in model selector or a saved device label. Global options can be placed before the command, after the command, or between command arguments; the CLI normalizes them before execution.

If you pass only a target option and no subcommand, IQOS CLI connects to that target and then starts interactive mode:
Expand All @@ -195,6 +207,7 @@ This is useful after saving a device label, because it skips the manual "Connect
| `iqos --model <model-or-label>` | Connect to a built-in model selector or saved label, then open interactive mode |
| `iqos --model <model-or-label> <command>` | Connect to the selected target and run one command |
| `iqos <command> --model <model-or-label>` | Same as above; global options may be placed after the command |
| `iqos --usb [command]` | Connect to the attached IQOS over USB, optionally run one command |
| `iqos --timeout <secs> ...` | Override the BLE scan timeout |

Built-in model selectors include `iluma`, `iluma-one`, `iluma-prime`, `iluma-i`, `iluma-i-one`, and `iluma-i-prime`. Saved labels are managed with the `device` command.
Expand Down Expand Up @@ -336,6 +349,8 @@ sudo usermod -a -G bluetooth $USER
# Log out and log back in
```

For USB, run the command once with `sudo` to confirm that permissions are the issue, then add a udev rule for vendor/product `2759:0003` rather than routinely running the CLI as root.

### Command Not Available

- Check the compatibility table — some features are model-specific
Expand All @@ -348,7 +363,7 @@ sudo usermod -a -G bluetooth $USER
```
iqos_cli/
├── src/
│ ├── main.rs # Entry point and BLE device discovery
│ ├── main.rs # Entry point and BLE/USB connection setup
│ └── loader/ # CLI interface
│ ├── mod.rs # Console runner (run_console)
│ ├── parser.rs # IQOSConsole REPL and command dispatch
Expand Down Expand Up @@ -417,7 +432,7 @@ GNU General Public License v3.0 — see [LICENSE](LICENSE) for details.

## Acknowledgments

Built with [btleplug](https://github.com/deviceplug/btleplug) for Bluetooth Low Energy support.
Built with [btleplug](https://github.com/deviceplug/btleplug) for Bluetooth Low Energy and [nusb](https://github.com/kevinmehall/nusb) for USB support.

---

Expand Down
22 changes: 21 additions & 1 deletion src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -23,6 +23,10 @@ pub struct Cli {
#[arg(long, value_name = "secs")]
pub timeout: Option<u64>,

/// Connect through USB HID instead of scanning over BLE.
#[arg(long, action = ArgAction::SetTrue)]
pub usb: bool,

#[command(subcommand)]
pub command: Option<CliCommand>,
}
Expand Down Expand Up @@ -236,6 +240,11 @@ pub fn normalize_global_options(args: Vec<String>) -> Vec<String> {
continue;
}

if arg == "--usb" {
global_options.push(arg.clone());
continue;
}

remaining.push(arg.clone());
}

Expand Down Expand Up @@ -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([
Expand Down
4 changes: 2 additions & 2 deletions src/loader/cmds/autostart.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand All @@ -20,7 +20,7 @@ enum AutostartAction {
Status,
}

async fn execute(iqos: Arc<Mutex<Iqos<IqosBle>>>, args: Vec<String>) -> Result<()> {
async fn execute(iqos: Arc<Mutex<Iqos<IqosTransport>>>, args: Vec<String>) -> Result<()> {
let action = parse_action(&args)?;
let iqos = iqos.lock().await;
let model = iqos.transport().model();
Expand Down
16 changes: 12 additions & 4 deletions src/loader/cmds/battery.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -13,9 +13,17 @@ pub fn register_command(console: &mut IQOSConsole) {
);
}

async fn execute(iqos: Arc<Mutex<Iqos<IqosBle>>>) -> Result<()> {
async fn execute(iqos: Arc<Mutex<Iqos<IqosTransport>>>) -> 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(())
}
4 changes: 2 additions & 2 deletions src/loader/cmds/brightness.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand All @@ -13,7 +13,7 @@ pub fn register_command(console: &mut IQOSConsole) {
);
}

async fn execute(iqos: Arc<Mutex<Iqos<IqosBle>>>, args: Vec<String>) -> Result<()> {
async fn execute(iqos: Arc<Mutex<Iqos<IqosTransport>>>, args: Vec<String>) -> Result<()> {
let iqos = iqos.lock().await;

if !iqos
Expand Down
4 changes: 2 additions & 2 deletions src/loader/cmds/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Mutex<Iqos<IqosBle>>>,
Arc<Mutex<Iqos<IqosTransport>>>,
Vec<String>,
) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>
+ Send
Expand Down
4 changes: 2 additions & 2 deletions src/loader/cmds/diagnosis.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -13,7 +13,7 @@ pub fn register_command(console: &mut IQOSConsole) {
);
}

async fn execute(iqos: Arc<Mutex<Iqos<IqosBle>>>, _args: Vec<String>) -> Result<()> {
async fn execute(iqos: Arc<Mutex<Iqos<IqosTransport>>>, _args: Vec<String>) -> Result<()> {
let iqos = iqos.lock().await;
let data = iqos.read_diagnosis().await?;
println!("Diagnosis:");
Expand Down
Loading