Skip to main content

xy_modbus/
transport.rs

1//! Modbus-RTU transport trait and error types.
2//!
3//! Implement [`ModbusTransport`] over your platform's UART. The
4//! [`crate::framing`] module gives you the on-wire codec; a typical
5//! implementation is <100 lines of UART-specific timing on top.
6
7use core::fmt;
8
9// ─── ModbusError ─────────────────────────────────────────────────────────────
10
11/// Protocol-layer error: a frame was received but failed validation.
12#[derive(Copy, Clone, Debug, PartialEq, Eq)]
13#[cfg_attr(feature = "defmt", derive(defmt::Format))]
14pub enum ModbusError {
15    /// Response was shorter than the smallest valid frame for the
16    /// expected reply.
17    ShortResponse(usize),
18    /// Slave address byte didn't match the request.
19    BadSlave(u8),
20    /// Function-code, byte-count, address, or quantity field didn't
21    /// match what was expected.
22    BadHeader,
23    /// CRC-16 mismatch.
24    BadCrc,
25    /// Slave returned a Modbus exception. The byte is the exception
26    /// code (`0x01`–`0x0B` per the spec).
27    Exception(u8),
28}
29
30impl fmt::Display for ModbusError {
31    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
32        match self {
33            Self::ShortResponse(n) => write!(f, "short response ({n} bytes)"),
34            Self::BadSlave(a) => write!(f, "wrong slave id 0x{a:02X}"),
35            Self::BadHeader => write!(f, "malformed header"),
36            Self::BadCrc => write!(f, "CRC mismatch"),
37            Self::Exception(c) => write!(f, "modbus exception 0x{c:02X}"),
38        }
39    }
40}
41
42impl core::error::Error for ModbusError {}
43
44// ─── RtuError ────────────────────────────────────────────────────────────────
45
46/// Unified error returned by the device API: either the transport
47/// (UART layer) failed, or the response was a malformed / exception
48/// Modbus frame.
49#[derive(Copy, Clone, Debug, PartialEq, Eq)]
50#[cfg_attr(feature = "defmt", derive(defmt::Format))]
51pub enum RtuError {
52    /// No (or insufficient) bytes received within the response window.
53    Timeout,
54    /// Underlying UART returned an I/O error on read or write.
55    Io,
56    /// Decoded response was invalid or the slave reported a Modbus
57    /// exception.
58    Modbus(ModbusError),
59}
60
61impl fmt::Display for RtuError {
62    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63        match self {
64            Self::Timeout => f.write_str("UART response timed out"),
65            Self::Io => f.write_str("UART I/O error"),
66            Self::Modbus(e) => fmt::Display::fmt(e, f),
67        }
68    }
69}
70
71impl From<ModbusError> for RtuError {
72    fn from(e: ModbusError) -> Self {
73        Self::Modbus(e)
74    }
75}
76
77impl core::error::Error for RtuError {
78    fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
79        match self {
80            Self::Modbus(e) => Some(e),
81            _ => None,
82        }
83    }
84}
85
86// ─── BlockingRead ────────────────────────────────────────────────────────────
87
88/// Read side of the bundled [`crate::uart::UartTransport`].
89///
90/// Wraps a UART driver that already knows how to block efficiently for
91/// incoming bytes — every kernel-backed HAL exposes one
92/// (`esp_idf_hal::uart::UartDriver::read(buf, ticks)`, embassy with
93/// timeout futures, `serialport-rs::SerialPort::read` after
94/// `set_timeout`, …). Implementing this trait is typically 3 lines that
95/// translate `timeout_ms` into the driver's native timeout type.
96///
97/// Avoiding `embedded_io::ReadReady` here is deliberate: the trait is
98/// optional in the embedded-io ecosystem and many HALs (esp-idf-hal
99/// included) don't impl it, and the busy-poll loop a `ReadReady`-based
100/// implementation needs fights kernel-backed drivers that already block
101/// cheaply.
102pub trait BlockingRead {
103    type Error;
104
105    /// Block for up to `timeout_ms` waiting for at least one byte to
106    /// arrive, then return up to `buf.len()` bytes. `Ok(0)` means the
107    /// timeout elapsed without a byte appearing. `timeout_ms == 0` is a
108    /// non-blocking poll: return whatever is already buffered without
109    /// waiting (used for the pre-TX flush).
110    fn read(&mut self, buf: &mut [u8], timeout_ms: u32) -> Result<usize, Self::Error>;
111}
112
113// ─── Transport trait ─────────────────────────────────────────────────────────
114
115/// Modbus-RTU transport: send a request, validate the response, hand
116/// back the payload (for reads) or just `Ok(())` (for writes).
117///
118/// Implementers handle UART framing timing — the inter-frame gap, the
119/// per-device response timeout, and the post-write quiet gap. The
120/// XY-series wants ~50 ms between frames and ~500 ms response window.
121///
122/// All three function codes are required; the device API uses each
123/// (`0x03` for reads, `0x06` for single setpoint writes, `0x10` for
124/// bulk memory-group writes).
125pub trait ModbusTransport {
126    fn read_holding(&mut self, slave: u8, addr: u16, dst: &mut [u16]) -> Result<(), RtuError>;
127
128    fn write_single_holding(&mut self, slave: u8, addr: u16, value: u16) -> Result<(), RtuError>;
129
130    fn write_multiple_holdings(
131        &mut self,
132        slave: u8,
133        addr: u16,
134        values: &[u16],
135    ) -> Result<(), RtuError>;
136}
137
138#[cfg(test)]
139mod tests {
140    extern crate std;
141    use super::*;
142    use std::format;
143
144    #[test]
145    fn modbus_error_display_strings() {
146        assert_eq!(
147            format!("{}", ModbusError::ShortResponse(3)),
148            "short response (3 bytes)"
149        );
150        assert_eq!(
151            format!("{}", ModbusError::BadSlave(0x02)),
152            "wrong slave id 0x02"
153        );
154        assert_eq!(format!("{}", ModbusError::BadHeader), "malformed header");
155        assert_eq!(format!("{}", ModbusError::BadCrc), "CRC mismatch");
156        assert_eq!(
157            format!("{}", ModbusError::Exception(0x03)),
158            "modbus exception 0x03"
159        );
160    }
161
162    #[test]
163    fn rtu_error_display_strings() {
164        assert_eq!(format!("{}", RtuError::Timeout), "UART response timed out");
165        assert_eq!(format!("{}", RtuError::Io), "UART I/O error");
166        // Modbus variant delegates to inner Display.
167        assert_eq!(
168            format!("{}", RtuError::Modbus(ModbusError::BadCrc)),
169            "CRC mismatch"
170        );
171    }
172
173    /// Modbus-variant `RtuError` exposes the underlying error via `Error::source`.
174    #[test]
175    fn rtu_error_source_chain() {
176        use core::error::Error;
177        let e = RtuError::Modbus(ModbusError::BadCrc);
178        assert!(e.source().is_some());
179        assert!(RtuError::Timeout.source().is_none());
180        assert!(RtuError::Io.source().is_none());
181    }
182}