diff --git a/README.md b/README.md index ae905a5..a014578 100644 --- a/README.md +++ b/README.md @@ -55,14 +55,20 @@ cargo run --release -- --list sermonizer [OPTIONS] Options: - -p, --port Serial port path - -b, --baud Baud rate (default: 115200) - --line-ending Line ending: none|nl|cr|crlf (default: nl) - --hex Display data as hex - --log Log received data - --tx-log Log transmitted data - --log-ts Add timestamps to logs - --list List available ports + -p, --port Serial port path + -b, --baud Baud rate (default: 115200) + --line-ending Line ending: none|nl|cr|crlf (default: nl) + --data-bits Data bits: 5|6|7|8 (default: 8) + --parity

Parity: none|odd|even (default: none) + --stop-bits Stop bits: 1|2 (default: 1) + --flow-control Flow control: none|software|hardware (default: none) + --dtr Set the DTR line after opening + --rts Set the RTS line after opening + --hex Display data as hex + --log Log received data + --tx-log Log transmitted data + --log-ts Add timestamps to logs and display + --list List available ports ``` ## Controls diff --git a/src/config.rs b/src/config.rs index d22246c..454159c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,5 +1,5 @@ use clap::ValueEnum; -use serialport::{ClearBuffer, SerialPort}; +use serialport::{ClearBuffer, DataBits, FlowControl, Parity, SerialPort, StopBits}; use std::sync::{Arc, atomic::AtomicBool}; use std::time::Duration; @@ -10,20 +10,170 @@ use crate::serial_io::WriterMsg; pub struct PortSettings { pub name: String, pub baud: u32, + pub data_bits: DataBits, + pub parity: Parity, + pub stop_bits: StopBits, + pub flow_control: FlowControl, + pub dtr: Option, + pub rts: Option, } impl PortSettings { pub fn open(&self) -> serialport::Result> { - let port = serialport::new(&self.name, self.baud) + let mut port = serialport::new(&self.name, self.baud) + .data_bits(self.data_bits) + .parity(self.parity) + .stop_bits(self.stop_bits) + .flow_control(self.flow_control) .timeout(Duration::from_millis(100)) .open()?; + // Control lines matter for boards that wire DTR/RTS to reset or boot + // pins (e.g. ESP32, Arduino); leave them untouched unless requested + if let Some(dtr) = self.dtr { + port.write_data_terminal_ready(dtr)?; + } + if let Some(rts) = self.rts { + port.write_request_to_send(rts)?; + } + // Drop any stale data buffered by the OS port.clear(ClearBuffer::All)?; Ok(port) } } +/// Explicit level for a control line +#[derive(Copy, Clone, Debug, ValueEnum)] +pub enum Toggle { + On, + Off, +} + +impl Toggle { + pub fn as_bool(self) -> bool { + matches!(self, Toggle::On) + } +} + +/// Number of data bits per character +#[derive(Copy, Clone, Debug, ValueEnum)] +pub enum DataBitsArg { + #[value(name = "5")] + Five, + #[value(name = "6")] + Six, + #[value(name = "7")] + Seven, + #[value(name = "8")] + Eight, +} + +impl DataBitsArg { + pub fn label(self) -> &'static str { + match self { + DataBitsArg::Five => "5", + DataBitsArg::Six => "6", + DataBitsArg::Seven => "7", + DataBitsArg::Eight => "8", + } + } +} + +impl From for DataBits { + fn from(value: DataBitsArg) -> Self { + match value { + DataBitsArg::Five => DataBits::Five, + DataBitsArg::Six => DataBits::Six, + DataBitsArg::Seven => DataBits::Seven, + DataBitsArg::Eight => DataBits::Eight, + } + } +} + +/// Parity checking mode +#[derive(Copy, Clone, Debug, ValueEnum)] +pub enum ParityArg { + None, + Odd, + Even, +} + +impl ParityArg { + pub fn label(self) -> &'static str { + match self { + ParityArg::None => "N", + ParityArg::Odd => "O", + ParityArg::Even => "E", + } + } +} + +impl From for Parity { + fn from(value: ParityArg) -> Self { + match value { + ParityArg::None => Parity::None, + ParityArg::Odd => Parity::Odd, + ParityArg::Even => Parity::Even, + } + } +} + +/// Number of stop bits +#[derive(Copy, Clone, Debug, ValueEnum)] +pub enum StopBitsArg { + #[value(name = "1")] + One, + #[value(name = "2")] + Two, +} + +impl StopBitsArg { + pub fn label(self) -> &'static str { + match self { + StopBitsArg::One => "1", + StopBitsArg::Two => "2", + } + } +} + +impl From for StopBits { + fn from(value: StopBitsArg) -> Self { + match value { + StopBitsArg::One => StopBits::One, + StopBitsArg::Two => StopBits::Two, + } + } +} + +/// Flow control mode +#[derive(Copy, Clone, Debug, ValueEnum)] +pub enum FlowControlArg { + None, + Software, + Hardware, +} + +impl FlowControlArg { + pub fn label(self) -> &'static str { + match self { + FlowControlArg::None => "none", + FlowControlArg::Software => "software", + FlowControlArg::Hardware => "hardware", + } + } +} + +impl From for FlowControl { + fn from(value: FlowControlArg) -> Self { + match value { + FlowControlArg::None => FlowControl::None, + FlowControlArg::Software => FlowControl::Software, + FlowControlArg::Hardware => FlowControl::Hardware, + } + } +} + /// Which line ending to send when you press Enter #[derive(Copy, Clone, Debug, ValueEnum)] pub enum LineEnding { diff --git a/src/main.rs b/src/main.rs index 396b86e..9b176b2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,7 +6,9 @@ mod ui; use anyhow::{Context, Result}; use clap::Parser; -use config::{LineEnding, PortSettings, UiConfig}; +use config::{ + DataBitsArg, FlowControlArg, LineEnding, ParityArg, PortSettings, StopBitsArg, Toggle, UiConfig, +}; use crossterm::terminal; use logging::LogSink; use port_discovery::{choose_port_interactive, get_available_ports, print_ports}; @@ -28,14 +30,38 @@ struct Args { #[arg(short, long)] port: Option, - /// Baud rate (default 115200) - #[arg(short = 'b', long)] - baud: Option, + /// Baud rate + #[arg(short = 'b', long, default_value_t = 115_200)] + baud: u32, /// Line ending when you press Enter (none|nl|cr|crlf). Default: nl #[arg(long, value_enum)] line_ending: Option, + /// Data bits per character + #[arg(long, value_enum, default_value = "8")] + data_bits: DataBitsArg, + + /// Parity checking mode + #[arg(long, value_enum, default_value = "none")] + parity: ParityArg, + + /// Stop bits + #[arg(long, value_enum, default_value = "1")] + stop_bits: StopBitsArg, + + /// Flow control mode + #[arg(long, value_enum, default_value = "none")] + flow_control: FlowControlArg, + + /// Set the DTR line after opening (left untouched if omitted) + #[arg(long, value_enum)] + dtr: Option, + + /// Set the RTS line after opening (left untouched if omitted) + #[arg(long, value_enum)] + rts: Option, + /// Log received bytes to this file (appends) #[arg(long)] log: Option, @@ -79,17 +105,15 @@ async fn main() -> Result<()> { }; // Decide on baud - let baud = match args.baud { - Some(b) => { - println!("Baud: {b}"); - b - } - None => { - let b = 115_200u32; - println!("Baud: {b} (default)"); - b - } - }; + let baud = args.baud; + println!("Baud: {baud}"); + println!( + "Framing: {}{}{}, flow control: {}", + args.data_bits.label(), + args.parity.label(), + args.stop_bits.label(), + args.flow_control.label() + ); // Line ending let line_ending = args.line_ending.unwrap_or(LineEnding::Nl); @@ -110,6 +134,12 @@ async fn main() -> Result<()> { let settings = PortSettings { name: port_name.clone(), baud, + data_bits: args.data_bits.into(), + parity: args.parity.into(), + stop_bits: args.stop_bits.into(), + flow_control: args.flow_control.into(), + dtr: args.dtr.map(Toggle::as_bool), + rts: args.rts.map(Toggle::as_bool), }; let port = settings .open() diff --git a/src/port_discovery.rs b/src/port_discovery.rs index 6973694..6957797 100644 --- a/src/port_discovery.rs +++ b/src/port_discovery.rs @@ -3,13 +3,11 @@ use serialport::{SerialPortInfo, SerialPortType}; use std::io::{self, Write}; pub fn get_available_ports() -> Result> { - let all_ports = serialport::available_ports().context("Failed to list serial ports")?; + let mut ports = serialport::available_ports().context("Failed to list serial ports")?; - // Filter for realistic ports (USB ports with VID/PID) - let ports: Vec<_> = all_ports - .into_iter() - .filter(|p| matches!(&p.port_type, SerialPortType::UsbPort(_))) - .collect(); + // USB ports first: they are the most likely embedded targets, but onboard + // UARTs, PCI and Bluetooth ports must stay selectable too + ports.sort_by_key(|p| !matches!(&p.port_type, SerialPortType::UsbPort(_))); Ok(ports) } @@ -44,6 +42,18 @@ pub fn print_ports(ports: &[SerialPortInfo]) { } pub fn choose_port_interactive(ports: &[SerialPortInfo]) -> Result { + // A sole USB port is almost certainly the target device; skip the prompt + // even when onboard UARTs are also present (USB ports are sorted first) + let usb_count = ports + .iter() + .filter(|p| matches!(&p.port_type, SerialPortType::UsbPort(_))) + .count(); + if usb_count == 1 { + let name = ports[0].port_name.clone(); + println!("Auto-selected sole USB port: {name}"); + return Ok(name); + } + match ports.len() { 0 => bail!("No serial ports detected. Plug your device in and try again."), 1 => { @@ -54,27 +64,84 @@ pub fn choose_port_interactive(ports: &[SerialPortInfo]) -> Result { _ => { print_ports(ports); println!(); - // Prompt in cooked mode for a clean input experience - print!("Select port [1-{}] (Enter for 1): ", ports.len()); - let _ = io::stdout().flush(); // Temporarily disable raw mode if it was on (it isn't yet, but be safe) let was_raw = crossterm::terminal::is_raw_mode_enabled().unwrap_or(false); if was_raw { let _ = crossterm::terminal::disable_raw_mode(); } - - let mut line = String::new(); - io::stdin().read_line(&mut line)?; + let selection = prompt_for_selection(ports.len()); if was_raw { let _ = crossterm::terminal::enable_raw_mode(); } - let sel = line.trim().parse::().unwrap_or(1); - let idx = sel.clamp(1, ports.len()) - 1; - let name = ports[idx].port_name.clone(); + let name = ports[selection?].port_name.clone(); println!("Using port: {name}"); Ok(name) } } } + +fn prompt_for_selection(count: usize) -> Result { + let mut line = String::new(); + loop { + // Prompt in cooked mode for a clean input experience + print!("Select port [1-{count}] (Enter for 1, q to quit): "); + let _ = io::stdout().flush(); + + line.clear(); + if io::stdin().read_line(&mut line)? == 0 { + bail!("No port selected (end of input)."); + } + + let input = line.trim(); + if input.eq_ignore_ascii_case("q") { + bail!("No port selected."); + } + match parse_selection(input, count) { + Some(idx) => return Ok(idx), + None => println!("Invalid selection '{input}'. Enter a number between 1 and {count}."), + } + } +} + +/// Parse a 1-based port selection into a 0-based index. Empty input selects +/// the first port; anything invalid or out of range is rejected. +fn parse_selection(input: &str, count: usize) -> Option { + if input.is_empty() { + return Some(0); + } + match input.parse::() { + Ok(n) if (1..=count).contains(&n) => Some(n - 1), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_input_selects_first_port() { + assert_eq!(parse_selection("", 3), Some(0)); + } + + #[test] + fn valid_numbers_map_to_zero_based_index() { + assert_eq!(parse_selection("1", 3), Some(0)); + assert_eq!(parse_selection("3", 3), Some(2)); + } + + #[test] + fn out_of_range_is_rejected() { + assert_eq!(parse_selection("0", 3), None); + assert_eq!(parse_selection("4", 3), None); + } + + #[test] + fn garbage_is_rejected() { + assert_eq!(parse_selection("abc", 3), None); + assert_eq!(parse_selection("-1", 3), None); + assert_eq!(parse_selection("1.5", 3), None); + } +}