Skip to content

Commit dff42d4

Browse files
committed
RS-485 work
1 parent 8fc779a commit dff42d4

File tree

3 files changed

+271
-5
lines changed

3 files changed

+271
-5
lines changed

Cargo.lock

Lines changed: 29 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/bacnet-transport/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,13 @@ tokio-rustls = { workspace = true, optional = true }
2121
rustls = { workspace = true, optional = true }
2222
futures-util = { workspace = true, optional = true }
2323
tokio-serial = { version = "5", optional = true }
24+
gpiocdev = { version = "0.8", optional = true }
2425
libc = { workspace = true }
2526

2627
[features]
2728
default = []
2829
ipv6 = []
2930
sc-tls = ["tokio-tungstenite", "tokio-rustls", "rustls", "futures-util"]
3031
serial = ["tokio-serial", "tokio/io-util"]
32+
serial-gpio = ["serial", "gpiocdev"]
3133
ethernet = []

crates/bacnet-transport/src/mstp_serial.rs

Lines changed: 240 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,20 @@
1-
//! Real serial port adapter for MS/TP using `tokio-serial`.
1+
//! Real serial port adapters for MS/TP using `tokio-serial`.
22
//!
3-
//! RS-485 is half-duplex — this assumes the hardware (USB-RS485 adapter)
4-
//! handles direction switching automatically (which most modern adapters do).
3+
//! Provides three RS-485 direction control modes:
4+
//!
5+
//! - **Auto** (`TokioSerialPort`): Hardware handles direction switching.
6+
//! Works with USB-RS485 adapters (FTDI, CH340, CP2102) that toggle
7+
//! direction automatically.
8+
//!
9+
//! - **Kernel RS-485** (`TokioSerialPort::enable_kernel_rs485`): Uses the
10+
//! Linux `TIOCSRS485` ioctl so the kernel toggles the UART's RTS pin
11+
//! around each transmission. Zero userspace overhead. Requires DE/RE
12+
//! wired to the UART's RTS pin.
13+
//!
14+
//! - **GPIO** (`GpioDirectionPort`): Toggles an arbitrary GPIO pin for
15+
//! DE/RE control via the Linux GPIO character device (`/dev/gpiochipN`).
16+
//! Use this for RS-485 hats (like the Seeed Studio RS-485 Shield) where
17+
//! DE/RE is wired to a GPIO pin rather than the UART's RTS.
518
619
use tokio::io::{AsyncReadExt, AsyncWriteExt};
720
use tokio::sync::Mutex;
@@ -13,7 +26,7 @@ use crate::mstp::SerialPort;
1326

1427
/// Configuration for a serial port connection.
1528
pub struct SerialConfig {
16-
/// Serial port device name (e.g., "/dev/ttyUSB0" on Linux, "COM3" on Windows).
29+
/// Serial port device name (e.g., "/dev/ttyUSB0" on Linux, "/dev/cu.usbserial-xxx" on macOS).
1730
pub port_name: String,
1831
/// Baud rate. Common MS/TP values: 9600, 19200, 38400, 76800.
1932
pub baud_rate: u32,
@@ -30,7 +43,10 @@ impl Default for SerialConfig {
3043

3144
/// A real serial port implementing the MS/TP [`SerialPort`] trait.
3245
///
33-
/// Wraps `tokio_serial::SerialStream` for async RS-485 I/O.
46+
/// Wraps `tokio_serial::SerialStream` for async RS-485 I/O. By default,
47+
/// assumes the hardware handles direction switching automatically (USB
48+
/// RS-485 adapters). On Linux, call [`enable_kernel_rs485`](Self::enable_kernel_rs485)
49+
/// to use kernel-managed RTS direction control.
3450
pub struct TokioSerialPort {
3551
inner: Mutex<SerialStream>,
3652
}
@@ -45,6 +61,64 @@ impl TokioSerialPort {
4561
inner: Mutex::new(stream),
4662
})
4763
}
64+
65+
/// Enable Linux kernel RS-485 mode via `TIOCSRS485` ioctl.
66+
///
67+
/// The kernel will automatically toggle the UART's RTS pin to control
68+
/// the RS-485 transceiver direction. This is zero-overhead — no
69+
/// userspace GPIO toggling needed. Requires DE/RE wired to the UART's
70+
/// RTS pin (e.g., GPIO17 on Raspberry Pi).
71+
///
72+
/// # Parameters
73+
/// - `invert_rts`: If true, RTS is LOW during transmission (for
74+
/// transceivers with active-low DE).
75+
/// - `delay_before_send_us`: Microseconds to wait after asserting RTS
76+
/// before transmitting. Covers transceiver enable time.
77+
/// - `delay_after_send_us`: Microseconds to wait after the last byte
78+
/// before deasserting RTS. Covers last-byte drain time.
79+
#[cfg(target_os = "linux")]
80+
pub fn enable_kernel_rs485(
81+
&self,
82+
invert_rts: bool,
83+
delay_before_send_us: u32,
84+
delay_after_send_us: u32,
85+
) -> Result<(), Error> {
86+
use std::os::unix::io::AsRawFd;
87+
88+
let stream = self.inner.try_lock().map_err(|_| {
89+
Error::Encoding("Cannot enable RS-485: serial port is in use".to_string())
90+
})?;
91+
92+
// struct serial_rs485 { u32 flags, u32 delay_rts_before_send, u32 delay_rts_after_send, u32[5] padding }
93+
const SER_RS485_ENABLED: u32 = 1;
94+
const SER_RS485_RTS_ON_SEND: u32 = 1 << 1;
95+
const SER_RS485_RTS_AFTER_SEND: u32 = 1 << 2;
96+
97+
let mut flags = SER_RS485_ENABLED;
98+
if invert_rts {
99+
flags |= SER_RS485_RTS_AFTER_SEND;
100+
} else {
101+
flags |= SER_RS485_RTS_ON_SEND;
102+
}
103+
104+
let mut buf = [0u32; 8];
105+
buf[0] = flags;
106+
buf[1] = delay_before_send_us;
107+
buf[2] = delay_after_send_us;
108+
109+
// TIOCSRS485 = 0x542F
110+
let ret =
111+
unsafe { libc::ioctl(stream.as_raw_fd(), 0x542F, buf.as_mut_ptr()) };
112+
if ret < 0 {
113+
return Err(Error::Encoding(format!(
114+
"TIOCSRS485 ioctl failed: {}",
115+
std::io::Error::last_os_error()
116+
)));
117+
}
118+
119+
tracing::info!("Kernel RS-485 mode enabled (invert_rts={invert_rts})");
120+
Ok(())
121+
}
48122
}
49123

50124
impl SerialPort for TokioSerialPort {
@@ -64,3 +138,164 @@ impl SerialPort for TokioSerialPort {
64138
.map_err(|e| Error::Encoding(format!("Serial read failed: {e}")))
65139
}
66140
}
141+
142+
// ---------------------------------------------------------------------------
143+
// GPIO direction control wrapper
144+
// ---------------------------------------------------------------------------
145+
146+
/// RS-485 direction control via a GPIO pin on the Linux GPIO character device.
147+
///
148+
/// Wraps any [`SerialPort`] implementation and toggles a GPIO pin for
149+
/// DE/RE (Driver Enable / Receiver Enable) control around each write.
150+
///
151+
/// # Usage
152+
///
153+
/// ```no_run
154+
/// use bacnet_transport::mstp_serial::{GpioDirectionPort, TokioSerialPort, SerialConfig};
155+
///
156+
/// let serial = TokioSerialPort::open(&SerialConfig {
157+
/// port_name: "/dev/ttyS0".into(),
158+
/// baud_rate: 76800,
159+
/// }).unwrap();
160+
///
161+
/// // Seeed Studio RS-485 Shield: GPIO18 on /dev/gpiochip0, active-high
162+
/// let port = GpioDirectionPort::new(serial, "/dev/gpiochip0", 18, true).unwrap();
163+
/// ```
164+
///
165+
/// The pin is set to receive mode (DE deasserted) on creation and after
166+
/// each write. This ensures the bus defaults to listening.
167+
#[cfg(feature = "serial-gpio")]
168+
pub struct GpioDirectionPort<S: SerialPort> {
169+
inner: S,
170+
gpio: std::sync::Mutex<gpiocdev::Request>,
171+
line: u32,
172+
active_high: bool,
173+
/// Baud-rate-dependent delay after flush to ensure the last byte
174+
/// has left the UART shift register before switching to RX mode.
175+
post_tx_delay_us: u64,
176+
}
177+
178+
#[cfg(feature = "serial-gpio")]
179+
impl<S: SerialPort> GpioDirectionPort<S> {
180+
/// Create a new GPIO direction-controlled serial port.
181+
///
182+
/// # Parameters
183+
/// - `inner`: The underlying serial port for data I/O.
184+
/// - `gpio_chip`: Path to the GPIO chip device (e.g., "/dev/gpiochip0").
185+
/// - `line`: GPIO line number for DE/RE control (e.g., 18).
186+
/// - `active_high`: If true, GPIO HIGH enables the transmitter (most
187+
/// common — MAX485 DE pin is active-high). If false, GPIO LOW enables TX.
188+
pub fn new(
189+
inner: S,
190+
gpio_chip: &str,
191+
line: u32,
192+
active_high: bool,
193+
) -> Result<Self, Error> {
194+
Self::with_post_tx_delay(inner, gpio_chip, line, active_high, 0)
195+
}
196+
197+
/// Create with an explicit post-TX delay in microseconds.
198+
///
199+
/// After flushing the serial port, the wrapper waits this long before
200+
/// switching back to receive mode. This covers the time for the last
201+
/// byte to leave the UART's shift register. At 76800 baud, one byte
202+
/// takes ~130us. A delay of 200-500us is typically safe.
203+
///
204+
/// If set to 0, no additional delay is added (suitable when the UART
205+
/// driver's flush fully drains the hardware FIFO).
206+
pub fn with_post_tx_delay(
207+
inner: S,
208+
gpio_chip: &str,
209+
line: u32,
210+
active_high: bool,
211+
post_tx_delay_us: u64,
212+
) -> Result<Self, Error> {
213+
use gpiocdev::line::Value;
214+
215+
// Start in RX mode (DE deasserted).
216+
let rx_value = if active_high {
217+
Value::Inactive
218+
} else {
219+
Value::Active
220+
};
221+
222+
let request = gpiocdev::Request::builder()
223+
.on_chip(gpio_chip)
224+
.with_line(line)
225+
.as_output(rx_value)
226+
.with_consumer("bacnet-mstp")
227+
.request()
228+
.map_err(|e| {
229+
Error::Encoding(format!(
230+
"GPIO request failed for {gpio_chip} line {line}: {e}"
231+
))
232+
})?;
233+
234+
tracing::info!(
235+
"GPIO direction control: {gpio_chip} line {line} (active_high={active_high})"
236+
);
237+
238+
Ok(Self {
239+
inner,
240+
gpio: std::sync::Mutex::new(request),
241+
line,
242+
active_high,
243+
post_tx_delay_us,
244+
})
245+
}
246+
247+
/// Set the transceiver to transmit mode (DE asserted).
248+
fn set_tx_mode(&self) -> Result<(), Error> {
249+
use gpiocdev::line::Value;
250+
let value = if self.active_high {
251+
Value::Active
252+
} else {
253+
Value::Inactive
254+
};
255+
self.gpio
256+
.lock()
257+
.unwrap_or_else(|e| e.into_inner())
258+
.set_value(self.line, value)
259+
.map_err(|e| Error::Encoding(format!("GPIO set TX mode failed: {e}")))
260+
}
261+
262+
/// Set the transceiver to receive mode (DE deasserted).
263+
fn set_rx_mode(&self) -> Result<(), Error> {
264+
use gpiocdev::line::Value;
265+
let value = if self.active_high {
266+
Value::Inactive
267+
} else {
268+
Value::Active
269+
};
270+
self.gpio
271+
.lock()
272+
.unwrap_or_else(|e| e.into_inner())
273+
.set_value(self.line, value)
274+
.map_err(|e| Error::Encoding(format!("GPIO set RX mode failed: {e}")))
275+
}
276+
}
277+
278+
#[cfg(feature = "serial-gpio")]
279+
impl<S: SerialPort> SerialPort for GpioDirectionPort<S> {
280+
async fn write(&self, data: &[u8]) -> Result<(), Error> {
281+
// Switch to TX mode before writing.
282+
self.set_tx_mode()?;
283+
284+
let result = self.inner.write(data).await;
285+
286+
// Post-TX delay to let the last byte leave the shift register.
287+
if self.post_tx_delay_us > 0 {
288+
tokio::time::sleep(tokio::time::Duration::from_micros(self.post_tx_delay_us)).await;
289+
}
290+
291+
// Always switch back to RX mode, even on write error.
292+
self.set_rx_mode()?;
293+
294+
result
295+
}
296+
297+
async fn read(&self, buf: &mut [u8]) -> Result<usize, Error> {
298+
// Already in RX mode — just delegate.
299+
self.inner.read(buf).await
300+
}
301+
}

0 commit comments

Comments
 (0)