feat: Add DTR/RTS control for serial ports with GUI toggles

- Assert DTR/RTS after serial port open to prevent wireless modules
  (HC-12/HC-15/Bluetooth bridges) from entering AT command mode after
  the unavoidable Linux kernel DTR pulse on every open() call.
- Add dtr/rts fields to TransportConfig::Serial (default: false).
- Add set_dtr()/set_rts() on SerialTransport, Connection, SessionHandle.
- Add SessionCmd::SetDtr/SetRts for live toggling via command channel.
- Add DTR/RTS checkboxes to GUI session controls (visible when connected)
  and to config form for persistence.
- Add test_plot_serial.c and test_text_serial.c — standalone C test
  tools for sending MixedTextPlot frames and plain text over serial.
- Update AGENTS.md with build prerequisites, architecture map, testing
  conventions, env vars, and known quirks.
This commit is contained in:
2026-06-11 14:18:39 +08:00
parent 09bee86a08
commit 1fec5f8941
11 changed files with 1154 additions and 20 deletions

View File

@@ -8,4 +8,6 @@ pub enum SessionCmd {
SetAutoReconnect(bool),
Close,
Reconfigure(SessionConfig),
SetDtr(bool),
SetRts(bool),
}

View File

@@ -138,6 +138,22 @@ impl SessionHandle {
.map_err(|_| String::from("session closed"))
}
pub async fn set_dtr(&self, state: bool) -> Result<(), String> {
self.inner
.cmd_tx
.send(SessionCmd::SetDtr(state))
.await
.map_err(|_| String::from("session closed"))
}
pub async fn set_rts(&self, state: bool) -> Result<(), String> {
self.inner
.cmd_tx
.send(SessionCmd::SetRts(state))
.await
.map_err(|_| String::from("session closed"))
}
pub async fn read(&self, timeout_ms: u64) -> Option<DecodedEntry> {
let mut rx = self.inner.event_tx.subscribe();
let deadline = Duration::from_millis(timeout_ms);
@@ -304,6 +320,14 @@ impl Session {
self.handle_reconfigure(config).await;
true
}
Some(SessionCmd::SetDtr(state)) => {
self.handle_set_dtr(state);
true
}
Some(SessionCmd::SetRts(state)) => {
self.handle_set_rts(state);
true
}
None => false,
}
}
@@ -332,6 +356,28 @@ impl Session {
}
}
fn handle_set_dtr(&mut self, state: bool) {
match self.conn.as_mut() {
Some(conn) => {
if let Err(err) = conn.set_dtr(state) {
self.emit_error(format!("set_dtr: {err}"));
}
}
None => self.emit_error(String::from("set_dtr: session not connected")),
}
}
fn handle_set_rts(&mut self, state: bool) {
match self.conn.as_mut() {
Some(conn) => {
if let Err(err) = conn.set_rts(state) {
self.emit_error(format!("set_rts: {err}"));
}
}
None => self.emit_error(String::from("set_rts: session not connected")),
}
}
async fn handle_read_result(
&mut self,
result: Result<Vec<DecodedEntry>, std::io::Error>,

View File

@@ -30,6 +30,14 @@ fn default_serial_flow_control() -> SerialFlowControl {
SerialFlowControl::None
}
fn default_serial_dtr() -> bool {
false
}
fn default_serial_rts() -> bool {
false
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum TransportType {
Serial,
@@ -60,6 +68,10 @@ pub enum TransportConfig {
stop_bits: SerialStopBits,
#[serde(default = "default_serial_flow_control")]
flow_control: SerialFlowControl,
#[serde(default = "default_serial_dtr")]
dtr: bool,
#[serde(default = "default_serial_rts")]
rts: bool,
},
Tcp {
addr: String,
@@ -96,6 +108,8 @@ impl Connection {
parity,
stop_bits,
flow_control,
dtr,
rts,
} => Connection::Serial(SerialTransport::new(
port,
baud_rate,
@@ -103,6 +117,8 @@ impl Connection {
parity,
stop_bits,
flow_control,
dtr,
rts,
)),
TransportConfig::Tcp { addr } => Connection::Tcp(TcpTransport::new(addr)),
TransportConfig::Udp {
@@ -153,6 +169,28 @@ impl Connection {
Connection::Udp(t) => t.disconnect().await,
}
}
pub fn set_dtr(&mut self, state: bool) -> Result<()> {
match self {
Connection::Serial(t) => t.set_dtr(state),
Connection::Tcp(_) | Connection::Udp(_) => {
Err(crate::error::Error::ConnectionFailed(
"DTR only supported on Serial connections".into(),
))
}
}
}
pub fn set_rts(&mut self, state: bool) -> Result<()> {
match self {
Connection::Serial(t) => t.set_rts(state),
Connection::Tcp(_) | Connection::Udp(_) => {
Err(crate::error::Error::ConnectionFailed(
"RTS only supported on Serial connections".into(),
))
}
}
}
}
impl AsyncRead for Connection {
@@ -220,6 +258,8 @@ mod tests {
parity: SerialParity::None,
stop_bits: SerialStopBits::One,
flow_control: SerialFlowControl::None,
dtr: false,
rts: false,
}
}
@@ -312,6 +352,8 @@ mod tests {
parity: SerialParity::None,
stop_bits: SerialStopBits::One,
flow_control: SerialFlowControl::None,
dtr: false,
rts: false,
};
let _ = format!("{:?}", cfg);
@@ -371,6 +413,8 @@ mod tests {
parity,
stop_bits,
flow_control,
dtr,
rts,
} => {
assert_eq!(port, "COM1");
assert_eq!(baud_rate, 9600);
@@ -378,6 +422,8 @@ mod tests {
assert_eq!(parity, SerialParity::None);
assert_eq!(stop_bits, SerialStopBits::One);
assert_eq!(flow_control, SerialFlowControl::None);
assert!(!dtr);
assert!(!rts);
}
_ => panic!("expected Serial variant"),
}
@@ -395,6 +441,8 @@ mod tests {
parity,
stop_bits,
flow_control,
dtr,
rts,
} => {
assert_eq!(port, "COM2");
assert_eq!(baud_rate, 57600);
@@ -402,6 +450,8 @@ mod tests {
assert_eq!(parity, SerialParity::Even);
assert_eq!(stop_bits, SerialStopBits::Two);
assert_eq!(flow_control, SerialFlowControl::Hardware);
assert!(!dtr);
assert!(!rts);
}
_ => panic!("expected Serial variant"),
}

View File

@@ -1,6 +1,7 @@
use async_trait::async_trait;
use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
use tokio_serial::{DataBits, FlowControl, Parity, SerialPortBuilderExt, SerialStream, StopBits};
use serialport::SerialPort;
use tracing::{debug, info, warn};
use super::{Transport, TransportType};
@@ -83,9 +84,12 @@ pub struct SerialTransport {
parity: SerialParity,
stop_bits: SerialStopBits,
flow_control: SerialFlowControl,
dtr: bool,
rts: bool,
}
impl SerialTransport {
#[allow(clippy::too_many_arguments)]
pub fn new(
port_name: String,
baud_rate: u32,
@@ -93,6 +97,8 @@ impl SerialTransport {
parity: SerialParity,
stop_bits: SerialStopBits,
flow_control: SerialFlowControl,
dtr: bool,
rts: bool,
) -> Self {
Self {
port: None,
@@ -102,6 +108,8 @@ impl SerialTransport {
parity,
stop_bits,
flow_control,
dtr,
rts,
}
}
@@ -138,6 +146,36 @@ impl SerialTransport {
pub fn flow_control(&self) -> SerialFlowControl {
self.flow_control
}
pub fn set_dtr(&mut self, state: bool) -> Result<()> {
match &mut self.port {
Some(port) => {
port.write_data_terminal_ready(state)?;
self.dtr = state;
debug!("DTR set to {} on {}", state, self.port_name);
Ok(())
}
None => Err(crate::error::Error::ConnectionFailed(format!(
"port {} not open",
self.port_name
))),
}
}
pub fn set_rts(&mut self, state: bool) -> Result<()> {
match &mut self.port {
Some(port) => {
port.write_request_to_send(state)?;
self.rts = state;
debug!("RTS set to {} on {}", state, self.port_name);
Ok(())
}
None => Err(crate::error::Error::ConnectionFailed(format!(
"port {} not open",
self.port_name
))),
}
}
}
impl AsyncRead for SerialTransport {
@@ -221,7 +259,7 @@ impl Transport for SerialTransport {
self.flow_control
);
let port = tokio_serial::new(&self.port_name, self.baud_rate)
let mut port = tokio_serial::new(&self.port_name, self.baud_rate)
.data_bits(self.data_bits.into())
.parity(self.parity.into())
.stop_bits(self.stop_bits.into())
@@ -234,6 +272,17 @@ impl Transport for SerialTransport {
))
})?;
// Linux kernel toggles DTR/RTS on every open() — restore configured
// state immediately afterward. Many wireless serial modules (HC-12,
// HC-15, HC-05, Bluetooth/UART bridges) need DTR asserted to stay in
// transparent data mode and not fall into AT-command / reset state.
if let Err(e) = port.write_data_terminal_ready(self.dtr) {
warn!("Failed to set DTR({}) on {}: {}", self.dtr, self.port_name, e);
}
if let Err(e) = port.write_request_to_send(self.rts) {
warn!("Failed to set RTS({}) on {}: {}", self.rts, self.port_name, e);
}
debug!("Serial port {} opened successfully", self.port_name);
self.port = Some(port);
Ok(())
@@ -282,6 +331,8 @@ mod tests {
SerialParity::None,
SerialStopBits::One,
SerialFlowControl::None,
true,
true,
);
assert_eq!(transport.port_name(), "COM1");
assert_eq!(transport.baud_rate(), 115200);
@@ -296,6 +347,8 @@ mod tests {
SerialParity::Even,
SerialStopBits::Two,
SerialFlowControl::Hardware,
true,
true,
);
assert_eq!(transport.port_name(), "COM3");
assert_eq!(transport.baud_rate(), 9600);
@@ -314,6 +367,8 @@ mod tests {
SerialParity::None,
SerialStopBits::One,
SerialFlowControl::None,
true,
true,
);
assert_eq!(transport.name(), "COM1");
}
@@ -327,6 +382,8 @@ mod tests {
SerialParity::None,
SerialStopBits::One,
SerialFlowControl::None,
true,
true,
);
assert_eq!(transport.transport_type(), TransportType::Serial);
}
@@ -340,6 +397,8 @@ mod tests {
SerialParity::None,
SerialStopBits::One,
SerialFlowControl::None,
true,
true,
);
assert!(!transport.is_connected());
}
@@ -368,6 +427,8 @@ mod tests {
SerialParity::None,
SerialStopBits::One,
SerialFlowControl::None,
true,
true,
);
let pinned = Pin::new(&mut transport);
let mut buf_data = [0u8; 16];
@@ -391,6 +452,8 @@ mod tests {
SerialParity::None,
SerialStopBits::One,
SerialFlowControl::None,
true,
true,
);
let pinned = Pin::new(&mut transport);
let data = b"hello";
@@ -411,6 +474,8 @@ mod tests {
SerialParity::None,
SerialStopBits::One,
SerialFlowControl::None,
true,
true,
);
let pinned = Pin::new(&mut transport);
let waker = noop_waker();
@@ -430,6 +495,8 @@ mod tests {
SerialParity::None,
SerialStopBits::One,
SerialFlowControl::None,
true,
true,
);
let pinned = Pin::new(&mut transport);
let waker = noop_waker();
@@ -451,6 +518,8 @@ mod tests {
SerialParity::None,
SerialStopBits::One,
SerialFlowControl::None,
true,
true,
);
assert_eq!(transport.name(), "COM1");
}
@@ -464,6 +533,8 @@ mod tests {
SerialParity::None,
SerialStopBits::One,
SerialFlowControl::None,
true,
true,
);
assert_eq!(transport.transport_type(), TransportType::Serial);
}
@@ -477,6 +548,8 @@ mod tests {
SerialParity::None,
SerialStopBits::One,
SerialFlowControl::None,
true,
true,
);
assert!(!transport.is_connected());
}

View File

@@ -688,6 +688,8 @@ async fn connection_transport_type_dispatch() {
parity: SerialParity::None,
stop_bits: SerialStopBits::One,
flow_control: SerialFlowControl::None,
dtr: false,
rts: false,
});
let tcp = Connection::new(TransportConfig::Tcp {
addr: "127.0.0.1:8080".into(),

View File

@@ -64,6 +64,8 @@ pub struct SessionTab {
pub plot_view: plot_view::PlotViewState,
pub view: View,
pub auto_reconnect: bool,
pub dtr: bool,
pub rts: bool,
pub send_input: String,
pub send_mode: SendMode,
pub append_newline: bool,
@@ -183,6 +185,10 @@ impl XserialApp {
fn add_session_tab(&mut self, session_config: SessionConfig) {
let history_limit = session_config.history_limit;
let auto_reconnect = session_config.auto_reconnect;
let (dtr, rts) = match &session_config.transport {
TransportConfig::Serial { dtr, rts, .. } => (*dtr, *rts),
_ => (false, false),
};
let handle = self.manager.create(session_config.clone());
self.tabs.push(SessionTab {
id: handle.id(),
@@ -194,6 +200,8 @@ impl XserialApp {
plot_view: plot_view::PlotViewState::default(),
view: View::Text,
auto_reconnect,
dtr,
rts,
send_input: String::new(),
send_mode: SendMode::Text,
append_newline: true,
@@ -920,6 +928,22 @@ fn render_session_controls(
tab.status = ConnectionStatus::Error(String::from("session not found"));
}
}
if matches!(tab.status, ConnectionStatus::Connected) {
let dtr_changed = ui.checkbox(&mut tab.dtr, "DTR").changed();
let rts_changed = ui.checkbox(&mut tab.rts, "RTS").changed();
if dtr_changed || rts_changed {
if let Some(handle) = manager.get(tab.id) {
let dtr = tab.dtr;
let rts = tab.rts;
tokio::spawn(async move {
if dtr_changed { let _ = handle.set_dtr(dtr).await; }
if rts_changed { let _ = handle.set_rts(rts).await; }
});
} else {
tab.status = ConnectionStatus::Error(String::from("session not found"));
}
}
}
});
auto_reconnect_changed
}
@@ -1062,6 +1086,8 @@ mod tests {
parity: SerialParity::None,
stop_bits: SerialStopBits::One,
flow_control: SerialFlowControl::None,
dtr: false,
rts: false,
}),
"Serial COM7"
);

View File

@@ -17,6 +17,8 @@ pub struct ConfigForm {
pub serial_parity: SerialParity,
pub serial_stop_bits: SerialStopBits,
pub serial_flow_control: SerialFlowControl,
pub serial_dtr: bool,
pub serial_rts: bool,
pub addr: String,
pub udp_bind: String,
pub udp_remote: String,
@@ -116,6 +118,8 @@ impl Default for ConfigForm {
serial_parity: SerialParity::None,
serial_stop_bits: SerialStopBits::One,
serial_flow_control: SerialFlowControl::None,
serial_dtr: false,
serial_rts: false,
addr: String::from("127.0.0.1:8080"),
udp_bind: String::from("0.0.0.0:9000"),
udp_remote: String::new(),
@@ -158,6 +162,14 @@ impl ConfigForm {
TransportConfig::Serial { flow_control, .. } => *flow_control,
_ => SerialFlowControl::None,
},
serial_dtr: match &config.transport {
TransportConfig::Serial { dtr, .. } => *dtr,
_ => false,
},
serial_rts: match &config.transport {
TransportConfig::Serial { rts, .. } => *rts,
_ => false,
},
addr: match &config.transport {
TransportConfig::Tcp { addr } => addr.clone(),
_ => String::from("127.0.0.1:8080"),
@@ -190,6 +202,8 @@ impl ConfigForm {
parity: self.serial_parity,
stop_bits: self.serial_stop_bits,
flow_control: self.serial_flow_control,
dtr: self.serial_dtr,
rts: self.serial_rts,
},
TransportChoice::Tcp => TransportConfig::Tcp {
addr: self.addr.clone(),
@@ -484,6 +498,8 @@ fn render_transport_section(ui: &mut Ui, form: &mut ConfigForm) {
);
});
});
ui.checkbox(&mut form.serial_dtr, "DTR");
ui.checkbox(&mut form.serial_rts, "RTS");
}
TransportChoice::Tcp => {
ui.horizontal(|ui| {