Skip to content
Open
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
11 changes: 9 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ include = [
"Cargo.toml",
"LICENSE",
"README.md",
"debug/**",
"src/**",
]

Expand All @@ -33,10 +34,15 @@ name = "autostart"
path = "debug/autostart.rs"
required-features = ["btleplug-support"]

[[bin]]
name = "hardware_usb"
path = "debug/hardware_usb.rs"
required-features = ["usb-support"]

[features]
default = []
btleplug-support = ["dep:btleplug", "dep:tokio"]
usb-support = []
usb-support = ["dep:nusb", "dep:tokio"]

[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = [] }
Expand All @@ -45,6 +51,7 @@ unexpected_cfgs = { level = "warn", check-cfg = [] }
async-trait = "0.1"
btleplug = { version = "0.11.8", optional = true }
futures = "0.3"
nusb = { version = "0.2.4", features = ["tokio"], optional = true }
thiserror = "2"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"], optional = true }
tokio = { version = "1", features = ["io-util", "macros", "rt-multi-thread", "sync", "time"], optional = true }
uuid = { version = "1", features = ["macro-diagnostics"] }
27 changes: 23 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
[![CI](https://github.com/V-VX/iqos/actions/workflows/ci.yml/badge.svg)](https://github.com/V-VX/iqos/actions/workflows/ci.yml)
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue.svg)](LICENSE)

Rust library for controlling IQOS devices over BLE, exposing device internals not accessible through the official IQOS app.
Async Rust library for controlling IQOS devices over BLE or USB, exposing device internals not accessible through the official IQOS app.

> **Early development.** The public API is still taking shape and should be considered unstable.

Expand Down Expand Up @@ -36,9 +36,13 @@ The official IQOS app surfaces only basic status and settings. This library goes
| Transport | Status | Feature flag |
|---|---|---|
| BLE (Bluetooth Low Energy) | ✅ Implemented | `btleplug-support` |
| USB | ⚠️ Not yet implemented | `usb-support` _(reserved)_ |
| USB HID | 🧪 Experimental | `usb-support` |

> ⚠️ **USB is not implemented.** The architecture is designed to support it — the `Transport` trait is transport-agnostic — but no USB backend exists yet.
Both transports carry the same SCP application messages. USB adds 64-byte HID framing around them: report `0x3f` carries an atomic message and report `0x40` carries a segment of a longer message. The USB backend uses `nusb` with Tokio and implements the same async `Transport` trait as BLE.

Ordinary SCP messages use CRC-8 (`0x07`). Length-delimited data messages with opcode `0x10`/`0x90`, including diagnosis telemetry, use CRC-16/OPENSAFETY-B (`0x755b`) stored little-endian. Checksum validation and request/response matching are shared by BLE and USB.

USB discovery, device metadata, serial number, product numbers, firmware versions, battery voltage, brightness, vibration settings, FlexPuff, FlexBattery, AutoStart, and diagnosis reads have been validated on an IQOS ILUMA i and compared with BLE results from the same device. Stateful commands are not yet hardware-validated over USB.

---

Expand All @@ -47,6 +51,7 @@ The official IQOS app surfaces only basic status and settings. This library goes
```text
src/
├── lib.rs # Public facade — Iqos<T> device handle
├── connection.rs # Runtime BLE/USB transport enum
├── error.rs # Error types and Result alias
├── transport.rs # Transport trait shared by BLE/USB backends
├── protocol/ # Command builders, response parsers, typed domain values
Expand All @@ -58,11 +63,12 @@ src/
│ ├── flexpuff.rs
│ ├── gesture.rs
│ ├── lock.rs
│ ├── scp.rs # Shared BLE/USB checksum validation and frame matching
│ ├── types.rs
│ └── vibration.rs
└── transports/
├── ble_btleplug.rs # BLE backend (btleplug-support feature)
└── usb.rs # USB stub (usb-support feature, not yet implemented)
└── usb.rs # USB HID backend (usb-support feature)
```

### Design principles
Expand Down Expand Up @@ -110,6 +116,7 @@ Useful environment variables:

- `IQOS_TEST_NAME_SUBSTRING` — optional device-name filter
- `IQOS_TEST_ALLOW_STATEFUL_WRITES` — enable write operations and verification loops (`1` or `true`)
- `IQOS_TEST_TRACE_DIAGNOSIS` — print raw diagnosis request/response frames
- `IQOS_TEST_VIBRATE_MILLIS` — vibration duration for locate-device steps (default: `500`)

When `IQOS_TEST_ALLOW_STATEFUL_WRITES=1` is set, the binary reads the current setting, writes the opposite value, reads again to verify the change, restores the original value, and reads again to verify restoration for settings that support read-back (`brightness`, `FlexPuff`, `vibration`, `FlexBattery`).
Expand All @@ -118,6 +125,18 @@ When `IQOS_TEST_ALLOW_STATEFUL_WRITES=1` is set, the binary reads the current se

Direct commands such as vibration bursts and lock/unlock do not currently have a matching read-back status in the library. Those steps are still executed, and the binary finishes them in a known end state: vibration stopped and device unlocked.

### USB

The USB probe is read-only:

```bash
cargo run --features usb-support --bin hardware_usb
```

The CLI can exercise the same path with `iqos --usb diagnosis`.

On Linux, access to the USB device may require `sudo` or a udev rule granting the current user access to IQOS USB devices (`2759:0003`). The backend temporarily detaches the kernel HID driver while the interface is open and reattaches it when the handle is dropped normally.

### Focused Feature Debug Binaries

Implementation-local real-device probes should live in a single file directly under `debug/`, not
Expand Down
43 changes: 43 additions & 0 deletions debug/hardware_ble/suites.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
use iqos::protocol::ALL_DIAGNOSIS_COMMANDS;
use iqos::{DeviceCapability, DeviceStatus, Iqos, IqosBle};
use std::fmt::Write as _;

use crate::{TestResult, exercises};

Expand Down Expand Up @@ -72,9 +74,50 @@ pub(crate) async fn snapshot(session: &IqosBle, iqos: &Iqos<IqosBle>) -> TestRes
}
}

if model.supports(DeviceCapability::AutoStart) {
match iqos.read_autostart(model).await {
Ok(enabled) => {
println!(" AutoStart: {}", if enabled { "enabled" } else { "disabled" })
}
Err(e) => println!(" AutoStart: (read failed: {e})"),
}
}

if std::env::var_os("IQOS_TEST_TRACE_DIAGNOSIS").is_some() {
for command in ALL_DIAGNOSIS_COMMANDS {
match session.request(command).await {
Ok(response) => {
println!(" Diagnosis raw: tx={} rx={}", hex(command), hex(&response));
}
Err(error) => {
println!(" Diagnosis raw: tx={} (read failed: {error})", hex(command));
}
}
}
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
match iqos.read_diagnosis().await {
Ok(data) => println!(
" Diagnosis: puffs={:?} days={:?} battery={:?}",
data.total_smoking_count, data.days_used, data.battery_voltage
),
Err(e) => println!(" Diagnosis: (read failed: {e})"),
}

Ok(())
}

fn hex(bytes: &[u8]) -> String {
let mut result = String::with_capacity(bytes.len().saturating_mul(3));
for (index, byte) in bytes.iter().enumerate() {
if index > 0 {
result.push(' ');
}
write!(result, "{byte:02x}").expect("writing to a String cannot fail");
}
result
}

pub(crate) async fn exercise_all(
session: &IqosBle,
iqos: &Iqos<IqosBle>,
Expand Down
24 changes: 24 additions & 0 deletions debug/hardware_usb.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
use iqos::{Iqos, IqosUsb, ProductNumberKind};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let usb = IqosUsb::connect().await?;
println!("Connected: {}", usb.location());
println!("Model: {:?}", usb.model());
println!("USB metadata: {:?}", usb.device_info());

let iqos = Iqos::new(usb);
let product_number = iqos.read_product_number(ProductNumberKind::Stick).await?;
println!("Product number: {product_number}");

let battery_voltage = iqos.read_battery_voltage().await?;
println!("Battery voltage: {battery_voltage:.3} V");

let diagnosis = iqos.read_diagnosis().await?;
println!(
"Diagnosis: puffs={:?} days={:?} battery={:?}",
diagnosis.total_smoking_count, diagnosis.days_used, diagnosis.battery_voltage
);

Ok(())
}
39 changes: 32 additions & 7 deletions src/ble.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,27 @@
//! loading, characteristic discovery, and the low-level request/response path
//! built around the SCP control characteristic.

use std::time::Duration;

use async_trait::async_trait;
use btleplug::api::{Characteristic, Peripheral as _, WriteType};
use btleplug::platform::Peripheral;
use futures::StreamExt;
use tokio::time::timeout;

use crate::{
Error, Result,
protocol::{
BATTERY_CHARACTERISTIC_UUID, DEVICE_INFO_SERVICE_UUID, DeviceInfo, DeviceModel,
MANUFACTURER_NAME_CHAR_UUID_PREFIX, MODEL_NUMBER_CHAR_UUID_PREFIX,
SCP_CONTROL_CHARACTERISTIC_UUID, SERIAL_NUMBER_CHAR_UUID_PREFIX,
SOFTWARE_REVISION_CHAR_UUID_PREFIX,
SOFTWARE_REVISION_CHAR_UUID_PREFIX, scp,
},
transport::{Transport, TransportKind},
};

const SCP_RESPONSE_TIMEOUT: Duration = Duration::from_secs(5);

/// BLE session around a connected IQOS peripheral.
#[derive(Debug, Clone)]
pub struct IqosBle {
Expand Down Expand Up @@ -130,18 +135,38 @@ impl IqosBle {
/// Returns an error if the write fails, notifications cannot be opened, or
/// no response frame arrives.
pub async fn request(&self, command: &[u8]) -> Result<Vec<u8>> {
self.send(command).await?;
if let Some(error) = scp::checksum_error(command) {
return Err(Error::ProtocolEncode(error));
}

let mut notifications = self
.peripheral
.notifications()
.await
.map_err(|error| Error::Transport(error.to_string()))?;

notifications
.next()
.await
.map(|notification| notification.value)
.ok_or_else(|| Error::Transport("no BLE response notification received".to_string()))
timeout(SCP_RESPONSE_TIMEOUT, async {
// Arm the notification stream before writing. IQOS often responds
// immediately, so subscribing after the write races with the response.
self.send(command).await?;

loop {
let notification = notifications.next().await.ok_or_else(|| {
Error::Transport("BLE notification stream ended before an SCP response".into())
})?;
if notification.uuid != self.scp_control_characteristic.uuid
|| !scp::headers_match(command, &notification.value)
{
continue;
}
if let Some(error) = scp::checksum_error(&notification.value) {
return Err(Error::ProtocolDecode(error));
}
return Ok(notification.value);
}
})
.await
.map_err(|_| Error::Transport("timed out waiting for BLE SCP response".to_string()))?
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

Expand Down
114 changes: 114 additions & 0 deletions src/connection.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
//! Runtime-selectable IQOS transport.

use async_trait::async_trait;

use crate::{
Result,
protocol::{DeviceInfo, DeviceModel},
transport::{Transport, TransportKind},
};

#[cfg(feature = "btleplug-support")]
use crate::IqosBle;
#[cfg(feature = "usb-support")]
use crate::{Error, IqosUsb};

/// Runtime transport used by applications that support both BLE and USB.
#[derive(Debug)]
pub enum IqosTransport {
/// Bluetooth Low Energy session.
#[cfg(feature = "btleplug-support")]
Ble(IqosBle),
/// USB HID session.
#[cfg(feature = "usb-support")]
Usb(IqosUsb),
}

impl IqosTransport {
/// Return the detected device model.
#[must_use]
pub const fn model(&self) -> DeviceModel {
match self {
#[cfg(feature = "btleplug-support")]
Self::Ble(transport) => transport.model(),
#[cfg(feature = "usb-support")]
Self::Usb(transport) => transport.model(),
}
}

/// Return metadata collected while opening the transport.
#[must_use]
pub const fn device_info(&self) -> &DeviceInfo {
match self {
#[cfg(feature = "btleplug-support")]
Self::Ble(transport) => transport.device_info(),
#[cfg(feature = "usb-support")]
Self::Usb(transport) => transport.device_info(),
}
}

/// Return the BLE battery percentage when available.
///
/// USB exposes battery voltage through SCP but does not provide the direct
/// GATT percentage characteristic.
///
/// # Errors
///
/// Returns an error when the BLE read fails or when called on USB.
#[cfg_attr(not(feature = "btleplug-support"), allow(clippy::unused_async))]
pub async fn read_battery_level(&self) -> Result<u8> {
match self {
#[cfg(feature = "btleplug-support")]
Self::Ble(transport) => transport.read_battery_level().await,
#[cfg(feature = "usb-support")]
Self::Usb(_) => Err(Error::Unsupported(
"battery percentage is unavailable over USB; read battery voltage instead"
.to_string(),
)),
}
}
}

#[cfg(feature = "btleplug-support")]
impl From<IqosBle> for IqosTransport {
fn from(transport: IqosBle) -> Self {
Self::Ble(transport)
}
}

#[cfg(feature = "usb-support")]
impl From<IqosUsb> for IqosTransport {
fn from(transport: IqosUsb) -> Self {
Self::Usb(transport)
}
}

#[async_trait]
impl Transport for IqosTransport {
fn kind(&self) -> TransportKind {
match self {
#[cfg(feature = "btleplug-support")]
Self::Ble(transport) => transport.kind(),
#[cfg(feature = "usb-support")]
Self::Usb(transport) => transport.kind(),
}
}

async fn request(&self, command: &[u8]) -> Result<Vec<u8>> {
match self {
#[cfg(feature = "btleplug-support")]
Self::Ble(transport) => transport.request(command).await,
#[cfg(feature = "usb-support")]
Self::Usb(transport) => transport.request(command).await,
}
}

async fn send(&self, command: &[u8]) -> Result<()> {
match self {
#[cfg(feature = "btleplug-support")]
Self::Ble(transport) => transport.send(command).await,
#[cfg(feature = "usb-support")]
Self::Usb(transport) => transport.send(command).await,
}
}
}
Loading