Skip to main content

modbus_bridge/
lib.rs

1//! Portable `no_std` Modbus RTU/TCP bridge — async and blocking.
2//!
3//! Accepts Modbus TCP connections and transparently forwards each request to a
4//! Modbus RTU device over a serial port, then returns the response to the TCP
5//! client. No heap allocation is required: all internal buffers use
6//! fixed-capacity [`heapless`] collections.
7//!
8//! # When to use this crate
9//!
10//! Use this crate when you need to:
11//!
12//! - Bridge legacy RS-485/Modbus RTU sensors or PLCs onto a Wi-Fi or Ethernet
13//!   network.
14//! - Act as a Modbus TCP gateway (port 502) for a home-automation hub, SCADA
15//!   system, or any Modbus TCP client.
16//! - Run on a microcontroller such as an ESP32, STM32, or RP2040 without an
17//!   operating system.
18//!
19//! # Adding to your project
20//!
21//! ```toml
22//! [dependencies]
23//! # Async — Embassy, smoltcp, and other async runtimes (enabled by default)
24//! modbus-bridge = { version = "0.2", features = ["async", "defmt"] }
25//!
26//! # Blocking — esp-idf-hal, FreeRTOS tasks, bare-metal loops
27//! modbus-bridge = { version = "0.2", default-features = false, features = ["sync", "log"] }
28//! ```
29//!
30//! `async` and `sync` are mutually exclusive — enable exactly one.
31//!
32//! # Quick start — Embassy + embassy-net
33//!
34//! This example shows a complete Modbus TCP gateway task for any Embassy target
35//! (ESP32, STM32, RP2040, …). The UART and TCP socket are represented by the
36//! `embedded_io_async` traits, so the code is portable across HALs.
37//!
38//! ```rust,ignore
39//! use modbus_bridge::{Bridge, BridgeError, BridgeEvent};
40//!
41//! #[embassy_executor::task]
42//! async fn modbus_gateway(
43//!     stack: embassy_net::Stack<'static>,
44//!     // Any UART implementing embedded_io_async, e.g. from esp-hal or embassy-stm32.
45//!     uart: impl embedded_io_async::Read + embedded_io_async::Write + 'static,
46//!     // RS-485 direction-control pin. Pass `modbus_bridge::NoPin` if not needed.
47//!     tx_en: impl embedded_hal::digital::OutputPin + 'static,
48//! ) {
49//!     let mut bridge = Bridge::builder()
50//!         .rtu(uart, tx_en)
51//!         .build();
52//!
53//!     // Allocate the TCP socket using the exported buffer-size constants.
54//!     let mut rx_buf = [0u8; modbus_bridge::TCP_SOCKET_RX_BUF];
55//!     let mut tx_buf = [0u8; modbus_bridge::TCP_SOCKET_TX_BUF];
56//!     let mut socket = embassy_net::tcp::TcpSocket::new(stack, &mut rx_buf, &mut tx_buf);
57//!
58//!     loop {
59//!         // Wait for a Modbus TCP client to connect on the standard port 502.
60//!         if socket.accept(502).await.is_err() {
61//!             socket.abort();
62//!             continue;
63//!         }
64//!
65//!         // `accept` borrows `bridge` for the lifetime of the connection and
66//!         // takes ownership of the socket.
67//!         let mut conn = bridge.accept(socket);
68//!
69//!         loop {
70//!             match conn.next().await {
71//!                 // A complete request/response cycle finished successfully.
72//!                 Ok(BridgeEvent::Transaction(t)) => defmt::info!("modbus: {}", t),
73//!                 // Non-fatal anomaly (e.g. transaction ID mismatch) — still running.
74//!                 Ok(BridgeEvent::Warning(w))     => defmt::warn!("modbus: {}", w),
75//!                 // TCP client disconnected cleanly — break and accept next client.
76//!                 Err(BridgeError::TcpClosed)     => break,
77//!                 // Hard error — log it and terminate the connection.
78//!                 Err(e) => {
79//!                     defmt::error!("modbus error: {}", e);
80//!                     break;
81//!                 }
82//!             }
83//!         }
84//!
85//!         // Recover the socket so it can accept the next client.
86//!         socket = conn.into_stream();
87//!         socket.close();
88//!     }
89//! }
90//! ```
91//!
92//! ## Hardware without an RS-485 TX-enable pin
93//!
94//! Many USB-to-RS-485 adapters and UART peripherals with automatic direction
95//! control do not need an explicit TX-enable signal. Use
96//! [`BridgeBuilder::rtu_no_pin`] as a shorthand, or pass [`NoPin`] explicitly:
97//!
98//! ```rust,ignore
99//! // Shorthand
100//! let mut bridge = Bridge::builder().rtu_no_pin(uart).build();
101//!
102//! // Equivalent explicit form
103//! let mut bridge = Bridge::builder().rtu(uart, modbus_bridge::NoPin).build();
104//! ```
105//!
106//! # Blocking (sync) usage
107//!
108//! Compile with `default-features = false, features = ["sync"]`. The API is
109//! identical: every `.next().await` becomes `.next()` and there is no executor
110//! or async runtime required.
111//!
112//! ```rust,ignore
113//! use modbus_bridge::{Bridge, BridgeError, BridgeEvent};
114//!
115//! let mut bridge = Bridge::builder().rtu(uart, tx_en).build();
116//!
117//! loop {
118//!     // Accept a connection from your blocking TCP stack.
119//!     let stream = tcp_listener.accept().unwrap();
120//!     let mut conn = bridge.accept(stream);
121//!
122//!     loop {
123//!         match conn.next() {
124//!             Ok(BridgeEvent::Transaction(t)) => log::info!("modbus: {t}"),
125//!             Ok(BridgeEvent::Warning(w))     => log::warn!("modbus: {w}"),
126//!             Err(BridgeError::TcpClosed)     => break,
127//!             Err(e) => { log::error!("modbus error: {e}"); break; }
128//!         }
129//!     }
130//! }
131//! ```
132//!
133//! # Feature flags
134//!
135//! | Feature | Default | Description |
136//! |---------|---------|-------------|
137//! | `async` | yes | Async transport via [`embedded_io_async`]. Mutually exclusive with `sync`. |
138//! | `sync`  | no  | Blocking transport via [`embedded_io`]. Mutually exclusive with `async`. |
139//! | `defmt` | no  | Structured logging via [`defmt`] over RTT. Recommended for bare-metal targets. |
140//! | `log`   | no  | Logging via the [`log`] facade. Suitable for Linux, esp-idf, and RTOS targets. |
141//!
142//! # Logging
143//!
144//! Enable `defmt` (bare-metal / probe-rs RTT) or `log` (standard logger) to
145//! receive `info`-level messages for each RTU and TCP frame, and `error`-level
146//! messages on I/O failures. Without either feature the crate produces no
147//! output at all.
148//!
149//! # TCP socket buffer sizing
150//!
151//! When allocating a TCP socket for `embassy-net` or `smoltcp`, pass
152//! [`TCP_SOCKET_RX_BUF`] and [`TCP_SOCKET_TX_BUF`] (512 bytes each) as the
153//! socket's internal buffer sizes. They are sized to hold one maximum-length
154//! Modbus TCP frame (261 bytes) with headroom for TCP ACK latency and a
155//! pipelined follow-on request.
156//!
157//! For computing Modbus *frame* sizes at compile time, see the [`capacity`]
158//! module.
159
160#![no_std]
161
162// ── Feature guards ────────────────────────────────────────────────────────────
163
164#[cfg(all(feature = "sync", feature = "async"))]
165compile_error!("Features `sync` and `async` are mutually exclusive — enable exactly one.");
166
167#[cfg(not(any(feature = "sync", feature = "async")))]
168compile_error!("Exactly one of `sync` or `async` must be enabled.");
169
170// ── Private modules ───────────────────────────────────────────────────────────
171
172mod error;
173mod frame;
174mod rtu;
175mod tcp;
176
177// ── Public modules ────────────────────────────────────────────────────────────
178
179pub mod bridge;
180pub mod builder;
181pub mod capacity;
182pub mod client;
183pub mod client_builder;
184pub mod client_session;
185pub mod connection;
186pub mod event;
187
188// ── Top-level re-exports ──────────────────────────────────────────────────────
189
190pub use bridge::Bridge;
191pub use builder::BridgeBuilder;
192pub use client::Client;
193pub use client_builder::ClientBuilder;
194pub use client_session::ClientSession;
195pub use connection::Connection;
196pub use event::{BridgeError, BridgeEvent, FunctionCode, Transaction, Warning};
197
198// ── No-op TX-enable pin ───────────────────────────────────────────────────────
199
200/// No-op TX-enable pin for hardware that does not need RS-485 direction control.
201///
202/// Pass this to [`BridgeBuilder::rtu`] when your RS-485 transceiver handles bus
203/// direction automatically (e.g. auto-direction-control adapters, full-duplex
204/// wiring, or RS-232 connections). [`BridgeBuilder::rtu_no_pin`] is a
205/// convenience shorthand that inserts `NoPin` for you.
206///
207/// # Examples
208///
209/// ```rust,ignore
210/// use modbus_bridge::{Bridge, NoPin};
211///
212/// let mut bridge = Bridge::builder()
213///     .rtu(uart, NoPin)
214///     .build();
215/// ```
216pub struct NoPin;
217
218impl embedded_hal::digital::ErrorType for NoPin {
219    type Error = core::convert::Infallible;
220}
221
222impl embedded_hal::digital::OutputPin for NoPin {
223    fn set_high(&mut self) -> Result<(), Self::Error> {
224        Ok(())
225    }
226    fn set_low(&mut self) -> Result<(), Self::Error> {
227        Ok(())
228    }
229}
230
231// ── No-op delay provider ──────────────────────────────────────────────────────
232
233/// No-op delay provider — the default when no timeout is configured.
234///
235/// Pass `NoDelay` (or omit the third generic) when you do not need RTU or TCP
236/// timeouts. `NoDelay` does **not** implement any delay trait; this is
237/// intentional — it enables disjoint `impl` blocks in `Connection` and
238/// `ClientSession` without requiring language specialization.
239///
240/// To enable timeouts, call `.delay(my_delay)` on the builder and set
241/// `.rtu_timeout(ms)` and/or `.tcp_timeout(ms)`.
242pub struct NoDelay;
243
244// ── TCP socket buffer sizing constants ───────────────────────────────────────
245
246/// Recommended receive-buffer size for the underlying TCP socket (512 bytes).
247///
248/// Sized to hold one maximum-length Modbus TCP frame (261 bytes: 255-byte RTU
249/// PDU + 6-byte MBAP header), rounded to the next power of two with headroom
250/// for TCP ACK latency and a pipelined follow-on request.
251///
252/// Pass this constant when constructing your `TcpSocket` in `embassy-net` or
253/// `smoltcp`:
254///
255/// ```rust,ignore
256/// use modbus_bridge::{TCP_SOCKET_RX_BUF, TCP_SOCKET_TX_BUF};
257///
258/// let mut rx_buf = [0u8; TCP_SOCKET_RX_BUF];
259/// let mut tx_buf = [0u8; TCP_SOCKET_TX_BUF];
260/// let socket = embassy_net::tcp::TcpSocket::new(stack, &mut rx_buf, &mut tx_buf);
261/// ```
262pub const TCP_SOCKET_RX_BUF: usize = 512;
263/// Recommended transmit-buffer size for the underlying TCP socket (512 bytes).
264///
265/// See [`TCP_SOCKET_RX_BUF`] for sizing rationale and usage.
266pub const TCP_SOCKET_TX_BUF: usize = 512;
267
268// ── Internal logging ──────────────────────────────────────────────────────────
269
270#[cfg(feature = "defmt")]
271macro_rules! mb_info {
272    ($($t:tt)*) => { defmt::info!($($t)*) };
273}
274#[cfg(feature = "defmt")]
275macro_rules! mb_error {
276    ($($t:tt)*) => { defmt::error!($($t)*) };
277}
278
279#[cfg(all(not(feature = "defmt"), feature = "log"))]
280macro_rules! mb_info {
281    ($($t:tt)*) => { log::info!($($t)*) };
282}
283#[cfg(all(not(feature = "defmt"), feature = "log"))]
284macro_rules! mb_error {
285    ($($t:tt)*) => { log::error!($($t)*) };
286}
287
288#[cfg(not(any(feature = "defmt", feature = "log")))]
289#[expect(unused_macros, reason = "only called under defmt or log feature gates")]
290macro_rules! mb_info {
291    ($($t:tt)*) => {{ let _ = format_args!($($t)*); }};
292}
293#[cfg(not(any(feature = "defmt", feature = "log")))]
294macro_rules! mb_error {
295    ($($t:tt)*) => {{ let _ = format_args!($($t)*); }};
296}
297
298pub(crate) use mb_error;
299#[cfg(any(feature = "defmt", feature = "log"))]
300pub(crate) use mb_info;
301
302// ── Fuzzing surface (hidden from public docs) ─────────────────────────────────
303
304/// Internal module exposing frame primitives for fuzz targets.
305///
306/// Not part of the public API — stability not guaranteed.
307#[doc(hidden)]
308pub mod __fuzzing {
309    pub use crate::frame::{
310        check_crc, crc, rtu_resp_to_tcp, rtu_response_remaining, rtu_to_tcp, tcp_resp_to_rtu,
311        tcp_to_rtu,
312    };
313}