From ee06457b5da31921ee835d7f391154c78580b6d2 Mon Sep 17 00:00:00 2001 From: okhsunrog Date: Tue, 14 Jul 2026 23:14:15 +0300 Subject: [PATCH 1/5] fix(protocol): validate SCP responses across BLE --- src/ble.rs | 39 ++++++++-- src/protocol/diagnosis.rs | 14 ++++ src/protocol/mod.rs | 2 + src/protocol/scp.rs | 147 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 195 insertions(+), 7 deletions(-) create mode 100644 src/protocol/scp.rs diff --git a/src/ble.rs b/src/ble.rs index c32ce7a..3e539c4 100644 --- a/src/ble.rs +++ b/src/ble.rs @@ -5,10 +5,13 @@ //! 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, @@ -16,11 +19,13 @@ use crate::{ 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 { @@ -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> { - 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())) + // Arm the notification stream before writing. IQOS often responds + // immediately, so subscribing after the write races with the response. + self.send(command).await?; + + timeout(SCP_RESPONSE_TIMEOUT, async { + 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, ¬ification.value) + { + continue; + } + if let Some(error) = scp::checksum_error(¬ification.value) { + return Err(Error::ProtocolDecode(error)); + } + return Ok(notification.value); + } + }) + .await + .map_err(|_| Error::Transport("timed out waiting for BLE SCP response".to_string()))? } } diff --git a/src/protocol/diagnosis.rs b/src/protocol/diagnosis.rs index 31c2b49..0487d67 100644 --- a/src/protocol/diagnosis.rs +++ b/src/protocol/diagnosis.rs @@ -187,6 +187,20 @@ mod tests { assert_eq!(ALL_DIAGNOSIS_COMMANDS.len(), 4); } + #[test] + fn parses_captured_crc16_telemetry_frame() { + let bytes = [ + 0x00, 0x08, 0x90, 0x22, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x0d, 0x05, 0x00, 0x8e, + 0x01, 0x00, 0x00, 0x00, 0x6c, 0x04, 0x00, 0x20, 0x02, 0x00, 0x00, 0x00, 0x32, 0x04, + 0x00, 0x17, 0x03, 0x00, 0x00, 0x00, 0x18, 0x00, 0x00, 0x18, 0xb2, 0x28, + ]; + + let result = DiagnosticDataBuilder::default().parse(&bytes).unwrap().build(); + + assert_eq!(result.total_smoking_count, Some(1293)); + assert_eq!(result.days_used, Some(1074)); + } + #[test] fn parses_battery_voltage_frame() { // header [0x88, 0x21] at bytes[2..4], raw voltage at bytes[5..7] diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs index 40a00a8..6de6281 100644 --- a/src/protocol/mod.rs +++ b/src/protocol/mod.rs @@ -10,6 +10,8 @@ mod flexpuff; mod gesture; mod lock; mod product; +#[cfg(any(feature = "btleplug-support", feature = "usb-support"))] +pub(crate) mod scp; mod status; mod types; mod vibration; diff --git a/src/protocol/scp.rs b/src/protocol/scp.rs new file mode 100644 index 0000000..1bf0253 --- /dev/null +++ b/src/protocol/scp.rs @@ -0,0 +1,147 @@ +//! Shared SCP frame validation and request/response matching. + +const CRC8_POLYNOMIAL: u8 = 0x07; +const CRC16_OPENSAFETY_B_POLYNOMIAL: u16 = 0x755b; +const CRC16_OPCODE: u8 = 0x10; + +/// Return whether an SCP frame uses the length-delimited CRC-16 format. +/// +/// Ordinary SCP messages end in CRC-8. Data/telemetry messages use opcode +/// `0x10` (or response opcode `0x90`), carry an explicit payload length, and +/// end in little-endian CRC-16/OPENSAFETY-B. +pub(crate) fn uses_crc16(frame: &[u8]) -> bool { + frame.get(2).is_some_and(|opcode| opcode & 0x7f == CRC16_OPCODE) +} + +/// Return a checksum/length error, or `None` for a valid SCP frame. +pub(crate) fn checksum_error(frame: &[u8]) -> Option { + if uses_crc16(frame) { + return crc16_error(frame); + } + + if frame.len() < 5 { + return Some("SCP frame is too short for CRC-8".to_string()); + } + + let expected = frame[frame.len() - 1]; + let actual = crc8(&frame[2..frame.len() - 1]); + (actual != expected).then(|| { + format!( + "invalid SCP CRC-8: expected 0x{expected:02x}, calculated 0x{actual:02x}, frame {frame:02x?}" + ) + }) +} + +fn crc16_error(frame: &[u8]) -> Option { + if frame.len() < 6 { + return Some("SCP CRC-16 frame is too short".to_string()); + } + + let payload_len = usize::from(frame[3]); + let checksum_offset = 4 + payload_len; + let expected_len = checksum_offset + 2; + if frame.len() != expected_len { + return Some(format!( + "invalid SCP CRC-16 frame length: header declares {payload_len} payload bytes, expected {expected_len} total bytes, got {}", + frame.len() + )); + } + + let expected = u16::from_le_bytes([frame[checksum_offset], frame[checksum_offset + 1]]); + let actual = crc16_opensafety_b(&frame[2..checksum_offset]); + (actual != expected).then(|| { + format!( + "invalid SCP CRC-16: expected 0x{expected:04x}, calculated 0x{actual:04x}, frame {frame:02x?}" + ) + }) +} + +/// Compare the command identity shared by an SCP request and response. +pub(crate) fn headers_match(command: &[u8], response: &[u8]) -> bool { + if uses_crc16(command) { + return uses_crc16(response) + && command + .get(2) + .zip(response.get(2)) + .is_some_and(|(request, reply)| *reply == (*request | 0x80)); + } + + let header = |frame: &[u8]| { + (frame.len() >= 4).then(|| u16::from_be_bytes([frame[2], frame[3]]) & 0x03ff) + }; + header(command).is_some_and(|request| header(response) == Some(request)) +} + +pub(crate) fn crc8(bytes: &[u8]) -> u8 { + bytes.iter().fold(0_u8, |mut crc, byte| { + crc ^= byte; + for _ in 0..8 { + crc = if crc & 0x80 == 0 { crc << 1 } else { (crc << 1) ^ CRC8_POLYNOMIAL }; + } + crc + }) +} + +fn crc16_opensafety_b(bytes: &[u8]) -> u16 { + bytes.iter().fold(0_u16, |mut crc, byte| { + crc ^= u16::from(*byte) << 8; + for _ in 0..8 { + crc = if crc & 0x8000 == 0 { + crc << 1 + } else { + (crc << 1) ^ CRC16_OPENSAFETY_B_POLYNOMIAL + }; + } + crc + }) +} + +#[cfg(test)] +mod tests { + use super::{checksum_error, crc8, crc16_opensafety_b, headers_match, uses_crc16}; + + const TELEMETRY_REQUEST: [u8; 8] = [0x00, 0xc9, 0x10, 0x02, 0x01, 0x01, 0x75, 0xd6]; + const TELEMETRY_RESPONSE: [u8; 40] = [ + 0x00, 0x08, 0x90, 0x22, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x0d, 0x05, 0x00, 0x8e, 0x01, + 0x00, 0x00, 0x00, 0x6c, 0x04, 0x00, 0x20, 0x02, 0x00, 0x00, 0x00, 0x32, 0x04, 0x00, 0x17, + 0x03, 0x00, 0x00, 0x00, 0x18, 0x00, 0x00, 0x18, 0xb2, 0x28, + ]; + const TIMESTAMP_REQUEST: [u8; 8] = [0x00, 0xc0, 0x10, 0x02, 0x00, 0x04, 0x38, 0xef]; + const TIMESTAMP_RESPONSE: [u8; 16] = [ + 0x00, 0xc0, 0x90, 0x0a, 0x00, 0x04, 0xda, 0x07, 0x01, 0x01, 0x78, 0x53, 0x19, 0x1f, 0xa0, + 0x35, + ]; + + #[test] + fn validates_known_crc8_frames() { + assert_eq!(crc8(&[0x00, 0x03]), 0x09); + assert_eq!(crc8(&[0x01, 0x00]), 0x15); + assert!(checksum_error(&[0x00, 0xc0, 0x00, 0x21, 0xe7]).is_none()); + } + + #[test] + fn validates_ble_captured_crc16_frames() { + assert!(uses_crc16(&TELEMETRY_REQUEST)); + assert_eq!(crc16_opensafety_b(&TELEMETRY_REQUEST[2..6]), 0xd675); + assert!(checksum_error(&TELEMETRY_REQUEST).is_none()); + assert_eq!(crc16_opensafety_b(&TELEMETRY_RESPONSE[2..38]), 0x28b2); + assert!(checksum_error(&TELEMETRY_RESPONSE).is_none()); + assert_eq!(crc16_opensafety_b(&TIMESTAMP_REQUEST[2..6]), 0xef38); + assert!(checksum_error(&TIMESTAMP_REQUEST).is_none()); + assert_eq!(crc16_opensafety_b(&TIMESTAMP_RESPONSE[2..14]), 0x35a0); + assert!(checksum_error(&TIMESTAMP_RESPONSE).is_none()); + } + + #[test] + fn matches_crc16_request_to_response_opcode() { + assert!(headers_match(&TELEMETRY_REQUEST, &TELEMETRY_RESPONSE)); + } + + #[test] + fn rejects_corrupted_crc16_frame() { + let mut response = TELEMETRY_RESPONSE; + response[10] ^= 0x01; + + assert!(checksum_error(&response).is_some()); + } +} From ed85be5d47a457317d4cfec46396e4880d19045d Mon Sep 17 00:00:00 2001 From: okhsunrog Date: Tue, 14 Jul 2026 23:14:36 +0300 Subject: [PATCH 2/5] fix(protocol): strip holder product CRC --- src/lib.rs | 16 +++++++++------- src/protocol/product.rs | 34 ++++++++++------------------------ 2 files changed, 19 insertions(+), 31 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index c8e5ec9..1a15cdc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -616,10 +616,12 @@ mod tests { #[test] fn read_product_number_uses_expected_request_and_parses_holder_response() { - let transport = - MockTransport::with_responses([Ok( - [&[0x00, 0x08, 0x88, 0x03][..], b"HOLDER123456"].concat() - )]); + let transport = MockTransport::with_responses([Ok([ + &[0x00, 0x08, 0x88, 0x03][..], + b"HOLDER123456", + &[0xAA], + ] + .concat())]); let iqos = Iqos::new(transport); let product_number = block_on(iqos.read_product_number(ProductNumberKind::Holder)) @@ -1245,7 +1247,7 @@ mod tests { let transport = MockTransport::with_responses([ Ok([&[0x00, 0xC0, 0x88, 0x03][..], b"STICK12345", &[0xAA]].concat()), Ok(vec![0x00, 0xC0, 0x88, 0x00, 0x00, 0x00, 0x02, 0x05, 0x07, 0x18]), - Ok([&[0x00, 0x08, 0x88, 0x03][..], b"HOLDER12345"].concat()), + Ok([&[0x00, 0x08, 0x88, 0x03][..], b"HOLDER12345", &[0xAA]].concat()), Ok(vec![0x00, 0x08, 0x88, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x19]), Ok(vec![0x00, 0x08, 0x88, 0x21, 0x00, 0xA8, 0x10, 0x00, 0x00]), ]); @@ -1282,7 +1284,7 @@ mod tests { let transport = MockTransport::with_responses([ Ok([&[0x00, 0xC0, 0x88, 0x03][..], b"STICK12345", &[0xAA]].concat()), Ok(vec![0x00, 0xC0, 0x88, 0x00, 0x00, 0x00, 0x02, 0x05, 0x07, 0x18]), - Ok([&[0x00, 0x08, 0x88, 0x03][..], b"HOLDER12345"].concat()), + Ok([&[0x00, 0x08, 0x88, 0x03][..], b"HOLDER12345", &[0xAA]].concat()), Ok(vec![0x00, 0x08, 0x88, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x19]), Ok(vec![0x00, 0x08, 0x88, 0x21, 0x00, 0xA8, 0x10, 0x00, 0x00]), ]); @@ -1330,7 +1332,7 @@ mod tests { let transport = MockTransport::with_responses([ Ok([&[0x00, 0xC0, 0x88, 0x03][..], b"STICK12345", &[0xAA]].concat()), Ok(vec![0x00, 0xC0, 0x88, 0x00, 0x00, 0x00, 0x02, 0x05, 0x07, 0x18]), - Ok([&[0x00, 0x08, 0x88, 0x03][..], b"HOLDER12345"].concat()), + Ok([&[0x00, 0x08, 0x88, 0x03][..], b"HOLDER12345", &[0xAA]].concat()), Err(Error::Transport("holder read failed".to_string())), ]); let iqos = Iqos::new(transport); diff --git a/src/protocol/product.rs b/src/protocol/product.rs index 14fc9d1..70ba9f8 100644 --- a/src/protocol/product.rs +++ b/src/protocol/product.rs @@ -35,9 +35,7 @@ impl ProductNumberKind { /// Parse a product-number response into a printable ASCII string. /// -/// The legacy CLI slices stick responses as bytes `4..len - 1`, treating the -/// final byte as a trailing checksum/status byte. Holder responses are sliced -/// from byte 4 to the end. +/// The final byte is the SCP CRC and is excluded for both targets. /// /// # Errors /// @@ -57,24 +55,12 @@ pub fn product_number_from_response(bytes: &[u8], kind: ProductNumberKind) -> Re )); } - let payload = match kind { - ProductNumberKind::Stick => { - if bytes.len() <= prefix.len() + 1 { - return Err(Error::ProtocolDecode( - "invalid product number response: missing stick payload".to_string(), - )); - } - &bytes[prefix.len()..bytes.len() - 1] - } - ProductNumberKind::Holder => { - if bytes.len() <= prefix.len() { - return Err(Error::ProtocolDecode( - "invalid product number response: missing holder payload".to_string(), - )); - } - &bytes[prefix.len()..] - } - }; + if bytes.len() <= prefix.len() + 1 { + return Err(Error::ProtocolDecode( + "invalid product number response: missing payload".to_string(), + )); + } + let payload = &bytes[prefix.len()..bytes.len() - 1]; Ok(payload .iter() @@ -100,8 +86,8 @@ mod tests { } #[test] - fn parses_holder_product_number_to_end_of_frame() { - let bytes = [&[0x00, 0x08, 0x88, 0x03][..], b"HOLDER123456"].concat(); + fn parses_holder_product_number_without_trailing_crc() { + let bytes = [&[0x00, 0x08, 0x88, 0x03][..], b"HOLDER123456", &[0xA5]].concat(); let product_number = product_number_from_response(&bytes, ProductNumberKind::Holder) .expect("holder product number should parse"); @@ -112,7 +98,7 @@ mod tests { #[test] fn replaces_non_printable_payload_bytes_with_dots() { let product_number = product_number_from_response( - &[0x00, 0x08, 0x88, 0x03, b'A', 0x00, 0xFF], + &[0x00, 0x08, 0x88, 0x03, b'A', 0x00, 0xFF, 0xA5], ProductNumberKind::Holder, ) .expect("holder product number should parse"); From b4839e27261fafaeb790d9d797a34690c4c670a5 Mon Sep 17 00:00:00 2001 From: okhsunrog Date: Tue, 14 Jul 2026 23:14:41 +0300 Subject: [PATCH 3/5] feat(usb): add async nusb HID transport --- Cargo.toml | 11 +- debug/hardware_usb.rs | 24 ++ src/connection.rs | 114 +++++++++ src/lib.rs | 18 +- src/transports/mod.rs | 4 +- src/transports/usb.rs | 537 +++++++++++++++++++++++++++++++++++++++++- 6 files changed, 695 insertions(+), 13 deletions(-) create mode 100644 debug/hardware_usb.rs create mode 100644 src/connection.rs diff --git a/Cargo.toml b/Cargo.toml index e373888..477657a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ include = [ "Cargo.toml", "LICENSE", "README.md", + "debug/**", "src/**", ] @@ -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 = [] } @@ -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"] } diff --git a/debug/hardware_usb.rs b/debug/hardware_usb.rs new file mode 100644 index 0000000..270bfc9 --- /dev/null +++ b/debug/hardware_usb.rs @@ -0,0 +1,24 @@ +use iqos::{Iqos, IqosUsb, ProductNumberKind}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + 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(()) +} diff --git a/src/connection.rs b/src/connection.rs new file mode 100644 index 0000000..6a8899d --- /dev/null +++ b/src/connection.rs @@ -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 { + 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 for IqosTransport { + fn from(transport: IqosBle) -> Self { + Self::Ble(transport) + } +} + +#[cfg(feature = "usb-support")] +impl From 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> { + 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, + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 1a15cdc..3b67d77 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,18 +1,19 @@ //! # iqos //! -//! Rust library for controlling IQOS devices over BLE, exposing diagnostic -//! telemetry and device controls not available through the official IQOS app. +//! Async Rust library for controlling IQOS devices over BLE or USB, exposing +//! diagnostic telemetry and device controls not available through the official +//! IQOS app. //! //! ## Layers //! //! 1. [`protocol`] — command builders, response parsers, and typed domain values -//! 2. [`transport`] — transport contract shared by BLE and future USB backends -//! 3. [`transports`] — backend implementations (BLE via btleplug, USB reserved) +//! 2. [`transport`] — transport contract shared by BLE and USB backends +//! 3. [`transports`] — backend implementations //! //! ## Features //! //! - `btleplug-support`: enables the BLE backend via btleplug -//! - `usb-support`: reserved for future USB transport (not yet implemented) +//! - `usb-support`: enables the USB HID backend via `nusb` #![warn(missing_docs)] #![warn(clippy::all)] @@ -22,6 +23,9 @@ /// BLE-specific IQOS session and metadata logic. #[cfg(feature = "btleplug-support")] pub mod ble; +/// Runtime-selectable BLE/USB transport. +#[cfg(any(feature = "btleplug-support", feature = "usb-support"))] +pub mod connection; /// Error types and the crate-wide `Result` alias. pub mod error; /// Protocol-layer command, response, and domain types. @@ -41,6 +45,10 @@ pub use transport::{Transport, TransportKind}; #[cfg(feature = "btleplug-support")] pub use ble::IqosBle; +#[cfg(any(feature = "btleplug-support", feature = "usb-support"))] +pub use connection::IqosTransport; +#[cfg(feature = "usb-support")] +pub use transports::usb::IqosUsb; /// Library facade placeholder for future extracted IQOS session/device API. /// diff --git a/src/transports/mod.rs b/src/transports/mod.rs index 542a383..3bedec9 100644 --- a/src/transports/mod.rs +++ b/src/transports/mod.rs @@ -1,8 +1,6 @@ //! Backend transport implementations. //! -//! This module contains transport-specific adapters. The initial extraction work -//! will focus on a BLE backend built around `btleplug`, while reserving a clean -//! path for future USB support. +//! This module contains transport-specific adapters. #[cfg(feature = "btleplug-support")] pub mod ble_btleplug; diff --git a/src/transports/usb.rs b/src/transports/usb.rs index 6d1a1d5..d2f9c28 100644 --- a/src/transports/usb.rs +++ b/src/transports/usb.rs @@ -1,4 +1,535 @@ -//! USB backend scaffold. +//! IQOS USB HID transport implemented with `nusb`. //! -//! USB support is planned but intentionally not implemented during the initial -//! library extraction phase. +//! IQOS uses the same SCP application frames over BLE and USB. USB wraps the +//! SCP bytes in 64-byte HID reports: report `0x3f` carries an atomic frame, +//! while report `0x40` carries one segment of a longer frame. + +use core::fmt::{self, Write as _}; +use std::time::Duration; + +use async_trait::async_trait; +use nusb::io::{EndpointRead, EndpointWrite}; +use nusb::{Interface, transfer::Interrupt}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + sync::Mutex, + time::{Instant, timeout_at}, +}; + +use crate::{ + Error, Result, + protocol::{DeviceInfo, DeviceModel, scp}, + transport::{Transport, TransportKind}, +}; + +/// Philip Morris Products USB vendor identifier. +pub const IQOS_USB_VENDOR_ID: u16 = 0x2759; +/// USB product identifier shared by supported IQOS pocket chargers. +pub const IQOS_USB_PRODUCT_ID: u16 = 0x0003; + +const HID_INTERFACE_CLASS: u8 = 0x03; +const ENDPOINT_OUT: u8 = 0x01; +const ENDPOINT_IN: u8 = 0x81; +const REPORT_SIZE: usize = 64; +const ATOMIC_REPORT_ID: u8 = 0x3f; +const SEGMENTED_REPORT_ID: u8 = 0x40; +const ATOMIC_PAYLOAD_SIZE: usize = REPORT_SIZE - 2; +const SEGMENT_PAYLOAD_SIZE: usize = REPORT_SIZE - 3; +const RESPONSE_TIMEOUT: Duration = Duration::from_secs(2); +const SCP_TRANSPORT_PREFIX: u8 = 0x00; +const SERIAL_NUMBER_COMMAND: [u8; 5] = [0x00, 0xc0, 0x01, 0x00, 0x15]; + +struct UsbIo { + // Endpoints retain their own reference to the interface. Keeping this field + // makes the claim lifetime explicit and lets nusb reattach usbhid on drop. + _interface: Interface, + reader: EndpointRead, + writer: EndpointWrite, +} + +/// Connected IQOS USB HID session. +pub struct IqosUsb { + io: Mutex, + model: DeviceModel, + device_info: DeviceInfo, + location: String, +} + +impl fmt::Debug for IqosUsb { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("IqosUsb") + .field("model", &self.model) + .field("device_info", &self.device_info) + .field("location", &self.location) + .finish_non_exhaustive() + } +} + +impl IqosUsb { + /// Find the first IQOS USB device, detach the Linux HID driver while the + /// session is alive, and claim its interrupt interface. + /// + /// `nusb` automatically reattaches the detached kernel driver when the + /// interface is dropped. + /// + /// # Errors + /// + /// Returns an error when no matching device is connected, the process lacks + /// USB permissions, or the HID interface/endpoints cannot be opened. + pub async fn connect() -> Result { + let device_info = nusb::list_devices() + .await + .map_err(transport_error)? + .find(|device| { + device.vendor_id() == IQOS_USB_VENDOR_ID + && device.product_id() == IQOS_USB_PRODUCT_ID + }) + .ok_or_else(|| { + Error::Transport(format!( + "IQOS USB device {IQOS_USB_VENDOR_ID:04x}:{IQOS_USB_PRODUCT_ID:04x} not found" + )) + })?; + + let interface_number = device_info + .interfaces() + .find(|interface| interface.class() == HID_INTERFACE_CLASS) + .map(nusb::InterfaceInfo::interface_number) + .ok_or_else(|| Error::Transport("IQOS USB HID interface not found".to_string()))?; + + let product_name = device_info.product_string().map(str::to_owned); + let model = + product_name.as_deref().map_or(DeviceModel::Unknown, DeviceModel::from_local_name); + let metadata = DeviceInfo { + model_number: product_name, + serial_number: device_info.serial_number().map(str::to_owned), + software_revision: Some(format_bcd_version(device_info.device_version())), + manufacturer_name: device_info.manufacturer_string().map(str::to_owned), + }; + let location = format!("usb:{}:{}", device_info.bus_id(), device_info.device_address()); + + let device = device_info.open().await.map_err(transport_error)?; + let interface = + device.detach_and_claim_interface(interface_number).await.map_err(|error| { + Error::Transport(format!( + "failed to claim IQOS USB interface {interface_number}: {error}" + )) + })?; + + let reader = interface + .endpoint::(ENDPOINT_IN) + .map_err(transport_error)? + .reader(REPORT_SIZE) + .with_num_transfers(1); + let writer = interface + .endpoint::(ENDPOINT_OUT) + .map_err(transport_error)? + .writer(REPORT_SIZE) + .with_num_transfers(1); + + let mut session = Self { + io: Mutex::new(UsbIo { _interface: interface, reader, writer }), + model, + device_info: metadata, + location, + }; + + if let Ok(response) = session.transact(&SERIAL_NUMBER_COMMAND).await { + session.device_info.serial_number = parse_serial_number(&response); + } + + Ok(session) + } + + /// Return the model inferred from the USB product string. + #[must_use] + pub const fn model(&self) -> DeviceModel { + self.model + } + + /// Return metadata obtained from USB descriptors. + #[must_use] + pub const fn device_info(&self) -> &DeviceInfo { + &self.device_info + } + + /// Return a bus/address identifier for the connected USB device. + #[must_use] + pub fn location(&self) -> &str { + &self.location + } + + async fn transact(&self, command: &[u8]) -> Result> { + let reports = encode_reports(command)?; + let mut io = self.io.lock().await; + let deadline = Instant::now() + RESPONSE_TIMEOUT; + let UsbIo { reader, writer, .. } = &mut *io; + + // Poll IN first and keep a transfer armed while OUT is pending. IQOS + // may NAK an OUT report until it has room to publish the response; the + // operating-system HID driver also keeps an IN transfer active. + let (response, write_result) = tokio::join!( + biased; + read_matching_response(reader, command, deadline), + write_reports(writer, &reports, deadline), + ); + write_result?; + response + } +} + +async fn write_reports( + writer: &mut EndpointWrite, + reports: &[[u8; REPORT_SIZE]], + deadline: Instant, +) -> Result<()> { + for report in reports { + timeout_at(deadline, writer.write_all(report)) + .await + .map_err(|_| Error::Transport("timed out writing IQOS USB report".to_string()))? + .map_err(transport_error)?; + timeout_at(deadline, writer.flush()) + .await + .map_err(|_| Error::Transport("timed out flushing IQOS USB report".to_string()))? + .map_err(transport_error)?; + } + Ok(()) +} + +async fn read_matching_response( + reader: &mut EndpointRead, + command: &[u8], + deadline: Instant, +) -> Result> { + let mut segments = SegmentCollector::default(); + + loop { + let mut report = [0_u8; REPORT_SIZE]; + let bytes_read = timeout_at(deadline, reader.read(&mut report)) + .await + .map_err(|_| Error::Transport("timed out waiting for IQOS USB response".to_string()))? + .map_err(transport_error)?; + + if bytes_read == 0 { + return Err(Error::Transport("IQOS USB endpoint returned zero bytes".to_string())); + } + + let response = match report[0] { + ATOMIC_REPORT_ID => decode_atomic_report(&report[..bytes_read])?, + SEGMENTED_REPORT_ID => segments.push(&report[..bytes_read])?, + _ => None, + }; + + let Some(response) = response else { + continue; + }; + + if scp::headers_match(command, &response) { + validate_crc(&response)?; + return Ok(response); + } + } +} + +#[async_trait] +impl Transport for IqosUsb { + fn kind(&self) -> TransportKind { + TransportKind::Usb + } + + async fn request(&self, command: &[u8]) -> Result> { + self.transact(command).await + } + + async fn send(&self, command: &[u8]) -> Result<()> { + // USB responses must be consumed even when the caller does not need + // their payload; otherwise the next request could observe a stale frame. + self.transact(command).await.map(|_| ()) + } +} + +fn encode_reports(command: &[u8]) -> Result> { + let Some((&prefix, scp_frame)) = command.split_first() else { + return Err(Error::ProtocolEncode("empty SCP command".to_string())); + }; + if prefix != SCP_TRANSPORT_PREFIX { + return Err(Error::ProtocolEncode(format!( + "unsupported SCP transport prefix 0x{prefix:02x}; USB currently accepts standard 0x00 frames" + ))); + } + if scp_frame.len() < 4 { + return Err(Error::ProtocolEncode("SCP command is too short".to_string())); + } + if scp_frame.len() > u8::MAX as usize { + return Err(Error::ProtocolEncode("SCP command exceeds USB framing limit".to_string())); + } + + validate_outbound_crc(command)?; + + if scp_frame.len() <= ATOMIC_PAYLOAD_SIZE { + let mut report = [0_u8; REPORT_SIZE]; + report[0] = ATOMIC_REPORT_ID; + report[1] = u8::try_from(scp_frame.len()) + .map_err(|_| Error::ProtocolEncode("invalid USB atomic frame length".to_string()))?; + report[2..2 + scp_frame.len()].copy_from_slice(scp_frame); + return Ok(vec![report]); + } + + let chunks = scp_frame.chunks(SEGMENT_PAYLOAD_SIZE).collect::>(); + if chunks.len() > 128 { + return Err(Error::ProtocolEncode("SCP command needs too many USB segments".to_string())); + } + let last_segment_id = u8::try_from(chunks.len() - 1) + .map_err(|_| Error::ProtocolEncode("invalid USB segment count".to_string()))?; + + Ok(chunks + .into_iter() + .enumerate() + .map(|(index, chunk)| { + let mut report = [0_u8; REPORT_SIZE]; + let remaining = last_segment_id - u8::try_from(index).expect("segment index fits u8"); + report[0] = SEGMENTED_REPORT_ID; + report[1] = u8::try_from(chunk.len() + 1).expect("USB segment length always fits u8"); + report[2] = if index == 0 { remaining | 0x80 } else { remaining }; + report[3..3 + chunk.len()].copy_from_slice(chunk); + report + }) + .collect()) +} + +fn decode_atomic_report(report: &[u8]) -> Result>> { + if report.len() < 3 { + return Err(Error::ProtocolDecode("USB HID report is too short".to_string())); + } + let frame_len = usize::from(report[1]); + if frame_len == 1 && report[2] == 0 { + return Ok(None); + } + if frame_len < 4 || frame_len + 2 > report.len() { + return Err(Error::ProtocolDecode(format!("invalid USB atomic frame length {frame_len}"))); + } + + let mut response = Vec::with_capacity(frame_len + 1); + response.push(SCP_TRANSPORT_PREFIX); + response.extend_from_slice(&report[2..2 + frame_len]); + Ok(Some(response)) +} + +#[derive(Default)] +struct SegmentCollector { + data: Vec, + next_id: Option, +} + +impl SegmentCollector { + fn push(&mut self, report: &[u8]) -> Result>> { + if report.len() < 4 { + return Err(Error::ProtocolDecode("USB segmented report is too short".to_string())); + } + let frame_len = usize::from(report[1]); + if frame_len < 2 || frame_len + 2 > report.len() { + return Err(Error::ProtocolDecode(format!("invalid USB segment length {frame_len}"))); + } + + let raw_id = report[2]; + let is_initial = raw_id & 0x80 != 0; + let segment_id = raw_id & 0x7f; + + if is_initial { + self.data.clear(); + self.next_id = Some(segment_id); + } + + if self.next_id != Some(segment_id) { + return Err(Error::ProtocolDecode(format!( + "unexpected USB segment id {segment_id}, expected {:?}", + self.next_id + ))); + } + + self.data.extend_from_slice(&report[3..frame_len + 2]); + if segment_id == 0 { + self.next_id = None; + let mut response = Vec::with_capacity(self.data.len() + 1); + response.push(SCP_TRANSPORT_PREFIX); + response.append(&mut self.data); + return Ok(Some(response)); + } + + self.next_id = Some(segment_id - 1); + Ok(None) + } +} + +fn validate_crc(frame: &[u8]) -> Result<()> { + scp::checksum_error(frame).map_or(Ok(()), |error| Err(Error::ProtocolDecode(error))) +} + +fn validate_outbound_crc(frame: &[u8]) -> Result<()> { + scp::checksum_error(frame).map_or(Ok(()), |error| Err(Error::ProtocolEncode(error))) +} + +fn parse_serial_number(response: &[u8]) -> Option { + const RESPONSE_HEADER: [u8; 4] = [0x00, 0xc0, 0x89, 0x00]; + if response.len() < 15 || response[..RESPONSE_HEADER.len()] != RESPONSE_HEADER { + return None; + } + + let payload = &response[RESPONSE_HEADER.len()..response.len() - 1]; + let mut serial_bytes = Vec::::with_capacity(10); + serial_bytes.extend(payload[0..2].iter().rev().copied()); + serial_bytes.extend(payload[2..4].iter().rev().copied()); + serial_bytes.extend(payload[4..6].iter().rev().copied()); + serial_bytes.extend(payload[6..10].iter().rev().copied()); + + let mut serial = String::from("0x"); + for byte in serial_bytes { + write!(&mut serial, "{byte:02x}").expect("writing to a String cannot fail"); + } + + Some(serial) +} + +fn format_bcd_version(version: u16) -> String { + format!("{:x}.{:02x}", version >> 8, version & 0xff) +} + +fn transport_error(error: impl fmt::Display) -> Error { + Error::Transport(error.to_string()) +} + +#[cfg(test)] +mod tests { + use crate::{ + Error, + protocol::{LOAD_TELEMETRY_COMMAND, scp}, + }; + + use super::{ + ATOMIC_REPORT_ID, REPORT_SIZE, SEGMENTED_REPORT_ID, SegmentCollector, decode_atomic_report, + encode_reports, parse_serial_number, + }; + + #[test] + fn wraps_ble_scp_command_in_atomic_hid_report() { + let reports = encode_reports(&[0x00, 0xc0, 0x00, 0x21, 0xe7]).unwrap(); + + assert_eq!(reports.len(), 1); + assert_eq!(&reports[0][..6], &[ATOMIC_REPORT_ID, 0x04, 0xc0, 0x00, 0x21, 0xe7]); + assert!(reports[0][6..].iter().all(|byte| *byte == 0)); + } + + #[test] + fn rejects_invalid_outbound_crc_as_encode_error() { + let error = encode_reports(&[0x00, 0xc0, 0x00, 0x21, 0x00]).unwrap_err(); + + assert!( + matches!(error, Error::ProtocolEncode(message) if message.contains("invalid SCP CRC")) + ); + } + + #[test] + fn wraps_crc16_telemetry_command_without_rewriting_ble_bytes() { + let reports = encode_reports(&LOAD_TELEMETRY_COMMAND).unwrap(); + + assert_eq!(reports.len(), 1); + assert_eq!(&reports[0][..9], &[0x3f, 0x07, 0xc9, 0x10, 0x02, 0x01, 0x01, 0x75, 0xd6]); + } + + #[test] + fn decodes_atomic_report_back_to_ble_shape() { + let mut report = [0_u8; REPORT_SIZE]; + report[..10].copy_from_slice(&[ + ATOMIC_REPORT_ID, + 0x08, + 0xc0, + 0x88, + 0x21, + 0x00, + 0x00, + 0x10, + 0x68, + 0x00, + ]); + let crc = scp::crc8(&report[3..9]); + report[9] = crc; + + let response = decode_atomic_report(&report).unwrap().unwrap(); + + assert_eq!(response, [0x00, 0xc0, 0x88, 0x21, 0x00, 0x00, 0x10, 0x68, crc]); + } + + #[test] + fn decodes_usb_telemetry_to_the_same_frame_observed_over_ble() { + let ble_frame = [ + 0x00, 0x08, 0x90, 0x22, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x0d, 0x05, 0x00, 0x8e, + 0x01, 0x00, 0x00, 0x00, 0x6c, 0x04, 0x00, 0x20, 0x02, 0x00, 0x00, 0x00, 0x32, 0x04, + 0x00, 0x17, 0x03, 0x00, 0x00, 0x00, 0x18, 0x00, 0x00, 0x18, 0xb2, 0x28, + ]; + let mut report = [0_u8; REPORT_SIZE]; + report[0] = ATOMIC_REPORT_ID; + report[1] = u8::try_from(ble_frame.len() - 1).unwrap(); + report[2..2 + ble_frame.len() - 1].copy_from_slice(&ble_frame[1..]); + + let usb_frame = decode_atomic_report(&report).unwrap().unwrap(); + + assert_eq!(usb_frame, ble_frame); + assert!(scp::checksum_error(&usb_frame).is_none()); + } + + #[test] + fn ignores_idle_report() { + let mut report = [0_u8; REPORT_SIZE]; + report[..3].copy_from_slice(&[ATOMIC_REPORT_ID, 0x01, 0x00]); + + assert!(decode_atomic_report(&report).unwrap().is_none()); + } + + #[test] + fn segments_and_reassembles_long_scp_frame() { + let mut command = vec![0x00, 0xc0, 0x44, 0x02]; + command.extend(0_u8..=119); + let checksum = scp::crc8(&command[2..]); + command.push(checksum); + + let reports = encode_reports(&command).unwrap(); + assert_eq!(reports.len(), 3); + assert!(reports.iter().all(|report| report[0] == SEGMENTED_REPORT_ID)); + assert_eq!(reports[0][2], 0x82); + assert_eq!(reports[1][2], 0x01); + assert_eq!(reports[2][2], 0x00); + + let mut collector = SegmentCollector::default(); + let mut response = None; + for report in reports { + response = collector.push(&report).unwrap().or(response); + } + assert_eq!(response.unwrap(), command); + } + + #[test] + fn compares_the_ten_bit_scp_command_header() { + assert!(scp::headers_match( + &[0x00, 0xc0, 0x02, 0x23, 0xc3], + &[0x00, 0xc0, 0x86, 0x23, 0x64, 0x00, 0x00, 0x00, 0x00] + )); + } + + #[test] + fn crc_matches_known_scp_commands() { + assert_eq!(scp::crc8(&[0x00, 0x03]), 0x09); + assert_eq!(scp::crc8(&[0x01, 0x00]), 0x15); + assert_eq!(scp::crc8(&[0x00, 0x21]), 0xe7); + assert_eq!(scp::crc8(&[0x02, 0x23]), 0xc3); + } + + #[test] + fn parses_usb_device_serial_response() { + let response = [ + 0x00, 0xc0, 0x89, 0x00, 0x33, 0x00, 0x6a, 0x00, 0x02, 0x00, 0x59, 0x02, 0x23, 0x00, + 0x05, 0x00, 0x00, 0x00, 0x5c, + ]; + + assert_eq!(parse_serial_number(&response).as_deref(), Some("0x0033006a000200230259")); + } +} From d8464713a0f25b84cd5e1dd66f8604592789cbd8 Mon Sep 17 00:00:00 2001 From: okhsunrog Date: Tue, 14 Jul 2026 23:14:48 +0300 Subject: [PATCH 4/5] test: add BLE and USB hardware validation --- README.md | 27 ++++++++++++++++++++++---- debug/hardware_ble/suites.rs | 37 ++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 165f454..129d7cf 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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. --- @@ -47,6 +51,7 @@ The official IQOS app surfaces only basic status and settings. This library goes ```text src/ ├── lib.rs # Public facade — Iqos 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 @@ -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 @@ -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`). @@ -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 diff --git a/debug/hardware_ble/suites.rs b/debug/hardware_ble/suites.rs index ac563b3..0ff3147 100644 --- a/debug/hardware_ble/suites.rs +++ b/debug/hardware_ble/suites.rs @@ -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}; @@ -72,9 +74,44 @@ pub(crate) async fn snapshot(session: &IqosBle, iqos: &Iqos) -> 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 { + let response = session.request(command).await?; + println!(" Diagnosis raw: tx={} rx={}", hex(command), hex(&response)); + } + } + + 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, From e02a9991992eded1b4256aa3d4a06bf8ee842631 Mon Sep 17 00:00:00 2001 From: okhsunrog Date: Wed, 15 Jul 2026 00:38:12 +0300 Subject: [PATCH 5/5] fix: address transport review feedback --- debug/hardware_ble/suites.rs | 10 +++++++-- src/ble.rs | 8 ++++---- src/transport.rs | 6 +++++- src/transports/usb.rs | 40 ++++++++++++++++++++++++++++++++---- 4 files changed, 53 insertions(+), 11 deletions(-) diff --git a/debug/hardware_ble/suites.rs b/debug/hardware_ble/suites.rs index 0ff3147..536c6f5 100644 --- a/debug/hardware_ble/suites.rs +++ b/debug/hardware_ble/suites.rs @@ -85,8 +85,14 @@ pub(crate) async fn snapshot(session: &IqosBle, iqos: &Iqos) -> TestRes if std::env::var_os("IQOS_TEST_TRACE_DIAGNOSIS").is_some() { for command in ALL_DIAGNOSIS_COMMANDS { - let response = session.request(command).await?; - println!(" Diagnosis raw: tx={} rx={}", hex(command), hex(&response)); + 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)); + } + } } } diff --git a/src/ble.rs b/src/ble.rs index 3e539c4..d29c053 100644 --- a/src/ble.rs +++ b/src/ble.rs @@ -145,11 +145,11 @@ impl IqosBle { .await .map_err(|error| Error::Transport(error.to_string()))?; - // Arm the notification stream before writing. IQOS often responds - // immediately, so subscribing after the write races with the response. - self.send(command).await?; - 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()) diff --git a/src/transport.rs b/src/transport.rs index a3cfc56..625165b 100644 --- a/src/transport.rs +++ b/src/transport.rs @@ -25,6 +25,10 @@ pub trait Transport: Send + Sync { /// Send a command that expects a single response frame. async fn request(&self, command: &[u8]) -> Result>; - /// Send a command that does not require an immediate response frame. + /// Send a command without returning a response frame. + /// + /// Completion means the backend has finished any synchronization required + /// by its wire protocol. A backend may therefore wait for and discard a + /// matching device response even though no payload is returned. async fn send(&self, command: &[u8]) -> Result<()>; } diff --git a/src/transports/usb.rs b/src/transports/usb.rs index d2f9c28..1f23a44 100644 --- a/src/transports/usb.rs +++ b/src/transports/usb.rs @@ -168,13 +168,12 @@ impl IqosUsb { // Poll IN first and keep a transfer armed while OUT is pending. IQOS // may NAK an OUT report until it has room to publish the response; the // operating-system HID driver also keeps an IN transfer active. - let (response, write_result) = tokio::join!( + let (response, ()) = tokio::try_join!( biased; read_matching_response(reader, command, deadline), write_reports(writer, &reports, deadline), - ); - write_result?; - response + )?; + Ok(response) } } @@ -335,6 +334,10 @@ impl SegmentCollector { let is_initial = raw_id & 0x80 != 0; let segment_id = raw_id & 0x7f; + if !is_initial && self.next_id.is_none() { + return Ok(None); + } + if is_initial { self.data.clear(); self.next_id = Some(segment_id); @@ -507,6 +510,35 @@ mod tests { assert_eq!(response.unwrap(), command); } + #[test] + fn ignores_stray_continuation_without_changing_collector_state() { + let mut report = [0_u8; REPORT_SIZE]; + report[..5].copy_from_slice(&[SEGMENTED_REPORT_ID, 0x03, 0x01, 0xaa, 0xbb]); + let mut collector = SegmentCollector::default(); + + assert!(collector.push(&report).unwrap().is_none()); + assert!(collector.data.is_empty()); + assert_eq!(collector.next_id, None); + } + + #[test] + fn rejects_mismatched_continuation_while_collecting_segments() { + let mut initial = [0_u8; REPORT_SIZE]; + initial[..5].copy_from_slice(&[SEGMENTED_REPORT_ID, 0x03, 0x82, 0xaa, 0xbb]); + let mut mismatched = [0_u8; REPORT_SIZE]; + mismatched[..5].copy_from_slice(&[SEGMENTED_REPORT_ID, 0x03, 0x00, 0xcc, 0xdd]); + let mut collector = SegmentCollector::default(); + + assert!(collector.push(&initial).unwrap().is_none()); + let error = collector.push(&mismatched).unwrap_err(); + + assert!( + matches!(error, Error::ProtocolDecode(message) if message.contains("unexpected USB segment id 0")) + ); + assert_eq!(collector.data, [0xaa, 0xbb]); + assert_eq!(collector.next_id, Some(1)); + } + #[test] fn compares_the_ten_bit_scp_command_header() { assert!(scp::headers_match(