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.3.2", features = ["async", "defmt"] } # x-release-please-version
25//!
26//! # Blocking — esp-idf-hal, FreeRTOS tasks, bare-metal loops
27//! modbus-bridge = { version = "0.3.2", default-features = false, features = ["sync", "log"] } # x-release-please-version
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;
192
193/// Returns a [`BridgeBuilder`] for constructing a [`Bridge`].
194///
195/// Equivalent to [`Bridge::builder()`], but avoids type-inference failures that
196/// occur when the compiler cannot deduce the `Bridge` type parameters from
197/// context (e.g. in Embassy tasks where the UART type is known only later in
198/// the builder chain).
199///
200/// Prefer this over `Bridge::builder()` in generic or no-infer contexts.
201pub fn builder() -> BridgeBuilder<(), (), NoDelay> {
202 BridgeBuilder::new()
203}
204pub use client::Client;
205pub use client_builder::ClientBuilder;
206pub use client_session::ClientSession;
207pub use connection::Connection;
208pub use event::{BridgeError, BridgeEvent, FunctionCode, Transaction, Warning};
209
210// ── No-op TX-enable pin ───────────────────────────────────────────────────────
211
212/// No-op TX-enable pin for hardware that does not need RS-485 direction control.
213///
214/// Pass this to [`BridgeBuilder::rtu`] when your RS-485 transceiver handles bus
215/// direction automatically (e.g. auto-direction-control adapters, full-duplex
216/// wiring, or RS-232 connections). [`BridgeBuilder::rtu_no_pin`] is a
217/// convenience shorthand that inserts `NoPin` for you.
218///
219/// # Examples
220///
221/// ```rust,ignore
222/// use modbus_bridge::{Bridge, NoPin};
223///
224/// let mut bridge = Bridge::builder()
225/// .rtu(uart, NoPin)
226/// .build();
227/// ```
228pub struct NoPin;
229
230impl embedded_hal::digital::ErrorType for NoPin {
231 type Error = core::convert::Infallible;
232}
233
234impl embedded_hal::digital::OutputPin for NoPin {
235 fn set_high(&mut self) -> Result<(), Self::Error> {
236 Ok(())
237 }
238 fn set_low(&mut self) -> Result<(), Self::Error> {
239 Ok(())
240 }
241}
242
243// ── No-op delay provider ──────────────────────────────────────────────────────
244
245/// No-op delay provider — the default when no timeout is configured.
246///
247/// Pass `NoDelay` (or omit the third generic) when you do not need RTU or TCP
248/// timeouts. `NoDelay` does **not** implement any delay trait; this is
249/// intentional — it enables disjoint `impl` blocks in `Connection` and
250/// `ClientSession` without requiring language specialization.
251///
252/// To enable timeouts, call `.delay(my_delay)` on the builder and set
253/// `.rtu_timeout(ms)` and/or `.tcp_timeout(ms)`.
254pub struct NoDelay;
255
256// ── TCP socket buffer sizing constants ───────────────────────────────────────
257
258/// Recommended receive-buffer size for the underlying TCP socket (512 bytes).
259///
260/// Sized to hold one maximum-length Modbus TCP frame (261 bytes: 255-byte RTU
261/// PDU + 6-byte MBAP header), rounded to the next power of two with headroom
262/// for TCP ACK latency and a pipelined follow-on request.
263///
264/// Pass this constant when constructing your `TcpSocket` in `embassy-net` or
265/// `smoltcp`:
266///
267/// ```rust,ignore
268/// use modbus_bridge::{TCP_SOCKET_RX_BUF, TCP_SOCKET_TX_BUF};
269///
270/// let mut rx_buf = [0u8; TCP_SOCKET_RX_BUF];
271/// let mut tx_buf = [0u8; TCP_SOCKET_TX_BUF];
272/// let socket = embassy_net::tcp::TcpSocket::new(stack, &mut rx_buf, &mut tx_buf);
273/// ```
274pub const TCP_SOCKET_RX_BUF: usize = 512;
275/// Recommended transmit-buffer size for the underlying TCP socket (512 bytes).
276///
277/// See [`TCP_SOCKET_RX_BUF`] for sizing rationale and usage.
278pub const TCP_SOCKET_TX_BUF: usize = 512;
279
280// ── Internal logging ──────────────────────────────────────────────────────────
281
282#[cfg(feature = "defmt")]
283macro_rules! mb_info {
284 ($($t:tt)*) => { defmt::info!($($t)*) };
285}
286#[cfg(feature = "defmt")]
287macro_rules! mb_error {
288 ($($t:tt)*) => { defmt::error!($($t)*) };
289}
290
291#[cfg(all(not(feature = "defmt"), feature = "log"))]
292macro_rules! mb_info {
293 ($($t:tt)*) => { log::info!($($t)*) };
294}
295#[cfg(all(not(feature = "defmt"), feature = "log"))]
296macro_rules! mb_error {
297 ($($t:tt)*) => { log::error!($($t)*) };
298}
299
300#[cfg(not(any(feature = "defmt", feature = "log")))]
301#[expect(unused_macros, reason = "only called under defmt or log feature gates")]
302macro_rules! mb_info {
303 ($($t:tt)*) => {{ let _ = format_args!($($t)*); }};
304}
305#[cfg(not(any(feature = "defmt", feature = "log")))]
306macro_rules! mb_error {
307 ($($t:tt)*) => {{ let _ = format_args!($($t)*); }};
308}
309
310pub(crate) use mb_error;
311#[cfg(any(feature = "defmt", feature = "log"))]
312pub(crate) use mb_info;
313
314// ── Fuzzing surface (hidden from public docs) ─────────────────────────────────
315
316/// Internal module exposing frame primitives for fuzz targets.
317///
318/// Not part of the public API — stability not guaranteed.
319#[doc(hidden)]
320pub mod __fuzzing {
321 pub use crate::frame::{
322 check_crc, crc, rtu_resp_to_tcp, rtu_response_remaining, rtu_to_tcp, tcp_resp_to_rtu,
323 tcp_to_rtu,
324 };
325}