Skip to main content

netget/server/modbus/
codec.rs

1//! Modbus TCP wire format: MBAP header + PDU.
2//!
3//! Reference: *MODBUS Application Protocol Specification V1.1b3* and
4//! *MODBUS Messaging on TCP/IP Implementation Guide V1.0b*.
5//!
6//! This module is deliberately free of any I/O, LLM or state: it turns bytes into
7//! [`ModbusRequest`] values and turns answers back into bytes. Everything that the
8//! specification determines mechanically (frame legality, quantity limits, the shape of
9//! a write echo) lives here; everything a device would *decide* (what a register reads
10//! as, whether a write is accepted) lives in `actions.rs` and is answered by the model.
11//!
12//! There is no register file here, by design — see `src/server/modbus/CLAUDE.md`.
13
14/// Length of the MBAP header: transaction id (2), protocol id (2), length (2), unit id (1).
15pub const MBAP_HEADER_LEN: usize = 7;
16
17/// Protocol identifier reserved for Modbus in the MBAP header.
18pub const MODBUS_PROTOCOL_ID: u16 = 0;
19
20/// Largest legal PDU (function code + data), per the Modbus spec.
21pub const MAX_PDU_LEN: usize = 253;
22
23/// Largest legal ADU on TCP: MBAP header + PDU.
24pub const MAX_ADU_LEN: usize = MBAP_HEADER_LEN + MAX_PDU_LEN;
25
26// ---------------------------------------------------------------------------
27// Function codes
28// ---------------------------------------------------------------------------
29
30pub const FC_READ_COILS: u8 = 0x01;
31pub const FC_READ_DISCRETE_INPUTS: u8 = 0x02;
32pub const FC_READ_HOLDING_REGISTERS: u8 = 0x03;
33pub const FC_READ_INPUT_REGISTERS: u8 = 0x04;
34pub const FC_WRITE_SINGLE_COIL: u8 = 0x05;
35pub const FC_WRITE_SINGLE_REGISTER: u8 = 0x06;
36pub const FC_WRITE_MULTIPLE_COILS: u8 = 0x0F;
37pub const FC_WRITE_MULTIPLE_REGISTERS: u8 = 0x10;
38
39/// Bit OR-ed into the function code of an exception response.
40pub const EXCEPTION_FLAG: u8 = 0x80;
41
42// ---------------------------------------------------------------------------
43// Exception codes
44// ---------------------------------------------------------------------------
45
46pub const EXC_ILLEGAL_FUNCTION: u8 = 0x01;
47pub const EXC_ILLEGAL_DATA_ADDRESS: u8 = 0x02;
48pub const EXC_ILLEGAL_DATA_VALUE: u8 = 0x03;
49pub const EXC_SERVER_DEVICE_FAILURE: u8 = 0x04;
50pub const EXC_ACKNOWLEDGE: u8 = 0x05;
51pub const EXC_SERVER_DEVICE_BUSY: u8 = 0x06;
52pub const EXC_MEMORY_PARITY_ERROR: u8 = 0x08;
53pub const EXC_GATEWAY_PATH_UNAVAILABLE: u8 = 0x0A;
54pub const EXC_GATEWAY_TARGET_FAILED: u8 = 0x0B;
55
56/// What [`exception_name`] returns for a code the specification does not define.
57///
58/// Named rather than spelled out at each site: `execute_action` refuses any code that maps
59/// to it, so the two have to agree.
60pub const UNKNOWN_EXCEPTION: &str = "unknown_exception";
61
62/// Canonical name for an exception code, for logs and for the model's vocabulary.
63pub fn exception_name(code: u8) -> &'static str {
64    match code {
65        EXC_ILLEGAL_FUNCTION => "illegal_function",
66        EXC_ILLEGAL_DATA_ADDRESS => "illegal_data_address",
67        EXC_ILLEGAL_DATA_VALUE => "illegal_data_value",
68        EXC_SERVER_DEVICE_FAILURE => "server_device_failure",
69        EXC_ACKNOWLEDGE => "acknowledge",
70        EXC_SERVER_DEVICE_BUSY => "server_device_busy",
71        EXC_MEMORY_PARITY_ERROR => "memory_parity_error",
72        EXC_GATEWAY_PATH_UNAVAILABLE => "gateway_path_unavailable",
73        EXC_GATEWAY_TARGET_FAILED => "gateway_target_device_failed_to_respond",
74        _ => UNKNOWN_EXCEPTION,
75    }
76}
77
78/// Map a canonical exception name back to its code.
79pub fn exception_code_from_name(name: &str) -> Option<u8> {
80    match name.trim().to_ascii_lowercase().as_str() {
81        "illegal_function" | "illegal function" => Some(EXC_ILLEGAL_FUNCTION),
82        "illegal_data_address" | "illegal data address" => Some(EXC_ILLEGAL_DATA_ADDRESS),
83        "illegal_data_value" | "illegal data value" => Some(EXC_ILLEGAL_DATA_VALUE),
84        "server_device_failure" | "server device failure" | "device_failure" => {
85            Some(EXC_SERVER_DEVICE_FAILURE)
86        }
87        "acknowledge" => Some(EXC_ACKNOWLEDGE),
88        "server_device_busy" | "server device busy" | "device_busy" => Some(EXC_SERVER_DEVICE_BUSY),
89        "memory_parity_error" | "memory parity error" => Some(EXC_MEMORY_PARITY_ERROR),
90        "gateway_path_unavailable" | "gateway path unavailable" => {
91            Some(EXC_GATEWAY_PATH_UNAVAILABLE)
92        }
93        "gateway_target_device_failed_to_respond" | "gateway_target_failed" => {
94            Some(EXC_GATEWAY_TARGET_FAILED)
95        }
96        _ => None,
97    }
98}
99
100// ---------------------------------------------------------------------------
101// Framing
102// ---------------------------------------------------------------------------
103
104/// Why a byte stream could not be turned into a Modbus ADU.
105///
106/// Both variants are fatal for the connection: neither can be answered with a Modbus
107/// exception, because we no longer know where the next frame begins.
108#[derive(Debug, Clone, PartialEq, Eq)]
109pub enum FrameError {
110    /// The MBAP protocol identifier was not 0, so this is not Modbus at all.
111    NotModbus { protocol_id: u16 },
112    /// The MBAP length field is outside the legal range `2..=254`
113    /// (unit id + 1..=253 PDU bytes).
114    BadLength { length: u16 },
115}
116
117impl std::fmt::Display for FrameError {
118    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119        match self {
120            FrameError::NotModbus { protocol_id } => write!(
121                f,
122                "MBAP protocol identifier {protocol_id} is not 0; this is not a Modbus/TCP stream"
123            ),
124            FrameError::BadLength { length } => write!(
125                f,
126                "MBAP length field {length} is outside the legal range 2..=254"
127            ),
128        }
129    }
130}
131
132/// A value the encoder refuses to put on the wire.
133///
134/// The mirror image of [`FrameError`], and it exists because the two directions disagreed:
135/// [`try_parse_adu`] enforced `2..=254` against a hostile peer while [`encode_adu`] enforced
136/// nothing at all against the caller, so a 254-byte PDU produced a frame this codec's own
137/// parser rejects. The rule is refuse, never narrow: a byte count silently wrapped by an
138/// `as u8` is a frame that claims to carry a different number of registers than it does, and
139/// on this protocol a register value is a plant reading.
140#[derive(Debug, Clone, PartialEq, Eq)]
141pub enum EncodeError {
142    /// The PDU is empty or longer than [`MAX_PDU_LEN`], so the MBAP length field would land
143    /// outside the `2..=254` range `try_parse_adu` accepts.
144    PduLength { len: usize },
145    /// A read response's byte count would not fit the one-octet field, or the PDU it heads
146    /// would not fit [`MAX_PDU_LEN`].
147    ByteCount { values: usize, bytes: usize },
148}
149
150impl std::fmt::Display for EncodeError {
151    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
152        match self {
153            EncodeError::PduLength { len } => write!(
154                f,
155                "a Modbus PDU is 1..={MAX_PDU_LEN} octets; {len} given, which the MBAP length \
156                 field cannot describe"
157            ),
158            EncodeError::ByteCount { values, bytes } => write!(
159                f,
160                "{values} value(s) need {bytes} octets of data, past the {} a read response's \
161                 one-octet byte count and the {MAX_PDU_LEN}-octet PDU can carry",
162                MAX_PDU_LEN - 2
163            ),
164        }
165    }
166}
167
168impl std::error::Error for EncodeError {}
169
170/// A complete Modbus/TCP Application Data Unit.
171#[derive(Debug, Clone, PartialEq, Eq)]
172pub struct Adu {
173    pub transaction_id: u16,
174    pub unit_id: u8,
175    pub pdu: Vec<u8>,
176}
177
178/// Try to pull one complete ADU off the front of `buf`.
179///
180/// Returns `Ok(None)` when more bytes are needed (the caller should accumulate and retry),
181/// `Ok(Some((adu, consumed)))` on success, and `Err` when the stream is not Modbus.
182pub fn try_parse_adu(buf: &[u8]) -> Result<Option<(Adu, usize)>, FrameError> {
183    if buf.len() < MBAP_HEADER_LEN {
184        return Ok(None);
185    }
186
187    let transaction_id = u16::from_be_bytes([buf[0], buf[1]]);
188    let protocol_id = u16::from_be_bytes([buf[2], buf[3]]);
189    let length = u16::from_be_bytes([buf[4], buf[5]]);
190
191    if protocol_id != MODBUS_PROTOCOL_ID {
192        return Err(FrameError::NotModbus { protocol_id });
193    }
194
195    // `length` counts the unit id plus the PDU.
196    if length < 2 || length as usize > MAX_PDU_LEN + 1 {
197        return Err(FrameError::BadLength { length });
198    }
199
200    let total = 6 + length as usize;
201    if buf.len() < total {
202        return Ok(None);
203    }
204
205    let unit_id = buf[6];
206    let pdu = buf[MBAP_HEADER_LEN..total].to_vec();
207
208    Ok(Some((
209        Adu {
210            transaction_id,
211            unit_id,
212            pdu,
213        },
214        total,
215    )))
216}
217
218/// Wrap a PDU in an MBAP header addressed back to the requester.
219///
220/// Refuses any PDU [`try_parse_adu`] would not accept back. Without the check a 254-byte PDU
221/// produced a length field of 255 — outside the `2..=254` the parser enforces — and past
222/// 65534 octets the `(pdu.len() as u16) + 1` overflowed, which panics in every debug and test
223/// build and wrapped silently before `[profile.release] overflow-checks` was turned on.
224pub fn encode_adu(transaction_id: u16, unit_id: u8, pdu: &[u8]) -> Result<Vec<u8>, EncodeError> {
225    if pdu.is_empty() || pdu.len() > MAX_PDU_LEN {
226        return Err(EncodeError::PduLength { len: pdu.len() });
227    }
228    let mut out = Vec::with_capacity(MBAP_HEADER_LEN + pdu.len());
229    out.extend_from_slice(&transaction_id.to_be_bytes());
230    out.extend_from_slice(&MODBUS_PROTOCOL_ID.to_be_bytes());
231    // Range-checked above, so neither the cast nor the increment can wrap.
232    out.extend_from_slice(&((pdu.len() as u16) + 1).to_be_bytes());
233    out.push(unit_id);
234    out.extend_from_slice(pdu);
235    Ok(out)
236}
237
238// ---------------------------------------------------------------------------
239// Requests
240// ---------------------------------------------------------------------------
241
242/// A decoded, spec-legal Modbus request.
243#[derive(Debug, Clone, PartialEq, Eq)]
244pub enum ModbusRequest {
245    ReadCoils { start: u16, quantity: u16 },
246    ReadDiscreteInputs { start: u16, quantity: u16 },
247    ReadHoldingRegisters { start: u16, quantity: u16 },
248    ReadInputRegisters { start: u16, quantity: u16 },
249    WriteSingleCoil { address: u16, value: bool },
250    WriteSingleRegister { address: u16, value: u16 },
251    WriteMultipleCoils { start: u16, values: Vec<bool> },
252    WriteMultipleRegisters { start: u16, values: Vec<u16> },
253}
254
255impl ModbusRequest {
256    pub fn function_code(&self) -> u8 {
257        match self {
258            ModbusRequest::ReadCoils { .. } => FC_READ_COILS,
259            ModbusRequest::ReadDiscreteInputs { .. } => FC_READ_DISCRETE_INPUTS,
260            ModbusRequest::ReadHoldingRegisters { .. } => FC_READ_HOLDING_REGISTERS,
261            ModbusRequest::ReadInputRegisters { .. } => FC_READ_INPUT_REGISTERS,
262            ModbusRequest::WriteSingleCoil { .. } => FC_WRITE_SINGLE_COIL,
263            ModbusRequest::WriteSingleRegister { .. } => FC_WRITE_SINGLE_REGISTER,
264            ModbusRequest::WriteMultipleCoils { .. } => FC_WRITE_MULTIPLE_COILS,
265            ModbusRequest::WriteMultipleRegisters { .. } => FC_WRITE_MULTIPLE_REGISTERS,
266        }
267    }
268
269    /// Stable, human/model-readable name of the function.
270    pub fn function_name(&self) -> &'static str {
271        match self {
272            ModbusRequest::ReadCoils { .. } => "read_coils",
273            ModbusRequest::ReadDiscreteInputs { .. } => "read_discrete_inputs",
274            ModbusRequest::ReadHoldingRegisters { .. } => "read_holding_registers",
275            ModbusRequest::ReadInputRegisters { .. } => "read_input_registers",
276            ModbusRequest::WriteSingleCoil { .. } => "write_single_coil",
277            ModbusRequest::WriteSingleRegister { .. } => "write_single_register",
278            ModbusRequest::WriteMultipleCoils { .. } => "write_multiple_coils",
279            ModbusRequest::WriteMultipleRegisters { .. } => "write_multiple_registers",
280        }
281    }
282
283    /// First address touched by this request.
284    pub fn start_address(&self) -> u16 {
285        match self {
286            ModbusRequest::ReadCoils { start, .. }
287            | ModbusRequest::ReadDiscreteInputs { start, .. }
288            | ModbusRequest::ReadHoldingRegisters { start, .. }
289            | ModbusRequest::ReadInputRegisters { start, .. }
290            | ModbusRequest::WriteMultipleCoils { start, .. }
291            | ModbusRequest::WriteMultipleRegisters { start, .. } => *start,
292            ModbusRequest::WriteSingleCoil { address, .. }
293            | ModbusRequest::WriteSingleRegister { address, .. } => *address,
294        }
295    }
296
297    /// How many coils/registers this request touches.
298    pub fn quantity(&self) -> u16 {
299        match self {
300            ModbusRequest::ReadCoils { quantity, .. }
301            | ModbusRequest::ReadDiscreteInputs { quantity, .. }
302            | ModbusRequest::ReadHoldingRegisters { quantity, .. }
303            | ModbusRequest::ReadInputRegisters { quantity, .. } => *quantity,
304            ModbusRequest::WriteSingleCoil { .. } | ModbusRequest::WriteSingleRegister { .. } => 1,
305            ModbusRequest::WriteMultipleCoils { values, .. } => values.len() as u16,
306            ModbusRequest::WriteMultipleRegisters { values, .. } => values.len() as u16,
307        }
308    }
309
310    /// True for FC 1/2 — the answer is a list of bits.
311    pub fn is_bit_read(&self) -> bool {
312        matches!(
313            self,
314            ModbusRequest::ReadCoils { .. } | ModbusRequest::ReadDiscreteInputs { .. }
315        )
316    }
317
318    /// True for FC 3/4 — the answer is a list of 16-bit registers.
319    pub fn is_register_read(&self) -> bool {
320        matches!(
321            self,
322            ModbusRequest::ReadHoldingRegisters { .. } | ModbusRequest::ReadInputRegisters { .. }
323        )
324    }
325
326    /// True for FC 5/6/15/16.
327    pub fn is_write(&self) -> bool {
328        !self.is_bit_read() && !self.is_register_read()
329    }
330}
331
332/// Decode a PDU.
333///
334/// The error carries the exception code the specification prescribes, which the caller
335/// answers with directly — no model round-trip, because none of these is a decision:
336/// an unknown function code is *always* `0x01`, a quantity of 0 is *always* `0x03`.
337pub fn parse_request(pdu: &[u8]) -> Result<ModbusRequest, u8> {
338    let Some(&fc) = pdu.first() else {
339        return Err(EXC_ILLEGAL_FUNCTION);
340    };
341    let body = &pdu[1..];
342
343    // Reads and the two "write multiple" headers all start with a u16 pair.
344    let pair = |b: &[u8]| -> Option<(u16, u16)> {
345        if b.len() < 4 {
346            None
347        } else {
348            Some((
349                u16::from_be_bytes([b[0], b[1]]),
350                u16::from_be_bytes([b[2], b[3]]),
351            ))
352        }
353    };
354
355    match fc {
356        FC_READ_COILS | FC_READ_DISCRETE_INPUTS => {
357            let (start, quantity) = pair(body).ok_or(EXC_ILLEGAL_DATA_VALUE)?;
358            if !(1..=2000).contains(&quantity) {
359                return Err(EXC_ILLEGAL_DATA_VALUE);
360            }
361            check_range(start, quantity)?;
362            Ok(if fc == FC_READ_COILS {
363                ModbusRequest::ReadCoils { start, quantity }
364            } else {
365                ModbusRequest::ReadDiscreteInputs { start, quantity }
366            })
367        }
368        FC_READ_HOLDING_REGISTERS | FC_READ_INPUT_REGISTERS => {
369            let (start, quantity) = pair(body).ok_or(EXC_ILLEGAL_DATA_VALUE)?;
370            if !(1..=125).contains(&quantity) {
371                return Err(EXC_ILLEGAL_DATA_VALUE);
372            }
373            check_range(start, quantity)?;
374            Ok(if fc == FC_READ_HOLDING_REGISTERS {
375                ModbusRequest::ReadHoldingRegisters { start, quantity }
376            } else {
377                ModbusRequest::ReadInputRegisters { start, quantity }
378            })
379        }
380        FC_WRITE_SINGLE_COIL => {
381            let (address, raw) = pair(body).ok_or(EXC_ILLEGAL_DATA_VALUE)?;
382            let value = match raw {
383                0x0000 => false,
384                0xFF00 => true,
385                // The spec allows exactly these two encodings; anything else is a
386                // data-value error, not a device decision.
387                _ => return Err(EXC_ILLEGAL_DATA_VALUE),
388            };
389            Ok(ModbusRequest::WriteSingleCoil { address, value })
390        }
391        FC_WRITE_SINGLE_REGISTER => {
392            let (address, value) = pair(body).ok_or(EXC_ILLEGAL_DATA_VALUE)?;
393            Ok(ModbusRequest::WriteSingleRegister { address, value })
394        }
395        FC_WRITE_MULTIPLE_COILS => {
396            let (start, quantity) = pair(body).ok_or(EXC_ILLEGAL_DATA_VALUE)?;
397            if !(1..=1968).contains(&quantity) {
398                return Err(EXC_ILLEGAL_DATA_VALUE);
399            }
400            check_range(start, quantity)?;
401            let byte_count = *body.get(4).ok_or(EXC_ILLEGAL_DATA_VALUE)? as usize;
402            let expected = quantity.div_ceil(8) as usize;
403            if byte_count != expected || body.len() < 5 + byte_count {
404                return Err(EXC_ILLEGAL_DATA_VALUE);
405            }
406            let data = &body[5..5 + byte_count];
407            let values = (0..quantity as usize)
408                .map(|i| data[i / 8] & (1 << (i % 8)) != 0)
409                .collect();
410            Ok(ModbusRequest::WriteMultipleCoils { start, values })
411        }
412        FC_WRITE_MULTIPLE_REGISTERS => {
413            let (start, quantity) = pair(body).ok_or(EXC_ILLEGAL_DATA_VALUE)?;
414            if !(1..=123).contains(&quantity) {
415                return Err(EXC_ILLEGAL_DATA_VALUE);
416            }
417            check_range(start, quantity)?;
418            let byte_count = *body.get(4).ok_or(EXC_ILLEGAL_DATA_VALUE)? as usize;
419            if byte_count != quantity as usize * 2 || body.len() < 5 + byte_count {
420                return Err(EXC_ILLEGAL_DATA_VALUE);
421            }
422            let data = &body[5..5 + byte_count];
423            let values = data
424                .chunks_exact(2)
425                .map(|c| u16::from_be_bytes([c[0], c[1]]))
426                .collect();
427            Ok(ModbusRequest::WriteMultipleRegisters { start, values })
428        }
429        _ => Err(EXC_ILLEGAL_FUNCTION),
430    }
431}
432
433/// `start + quantity` must stay inside the 16-bit address space.
434fn check_range(start: u16, quantity: u16) -> Result<(), u8> {
435    if start as u32 + quantity as u32 > 0x1_0000 {
436        Err(EXC_ILLEGAL_DATA_ADDRESS)
437    } else {
438        Ok(())
439    }
440}
441
442// ---------------------------------------------------------------------------
443// Responses
444// ---------------------------------------------------------------------------
445
446/// Most data octets a read response can carry: the PDU ceiling less the function code and the
447/// byte-count octet. The byte count is one octet, and `MAX_PDU_LEN - 2` is 251, so the PDU
448/// bound is the tighter of the two and the only one that has to be stated.
449pub const MAX_READ_RESPONSE_DATA: usize = MAX_PDU_LEN - 2;
450
451/// PDU for a FC 1/2 read response: byte count then packed bits, least-significant bit
452/// of the first byte carrying the first requested coil.
453///
454/// Refuses a value list whose byte count would not fit rather than narrowing it with an
455/// `as u8`. At 2040 bits the narrowed count was 255 and at 2048 it was 0, both followed by
456/// the full data — a frame that lies about its own length.
457pub fn encode_bits_response(function_code: u8, values: &[bool]) -> Result<Vec<u8>, EncodeError> {
458    let byte_count = values.len().div_ceil(8);
459    if byte_count > MAX_READ_RESPONSE_DATA {
460        return Err(EncodeError::ByteCount {
461            values: values.len(),
462            bytes: byte_count,
463        });
464    }
465    let mut pdu = Vec::with_capacity(2 + byte_count);
466    pdu.push(function_code);
467    // Range-checked above, so the cast cannot lose a bit.
468    pdu.push(byte_count as u8);
469    let mut packed = vec![0u8; byte_count];
470    for (i, &on) in values.iter().enumerate() {
471        if on {
472            packed[i / 8] |= 1 << (i % 8);
473        }
474    }
475    pdu.extend_from_slice(&packed);
476    Ok(pdu)
477}
478
479/// PDU for a FC 3/4 read response: byte count then big-endian registers.
480///
481/// Refuses past [`MAX_READ_RESPONSE_DATA`]. At 128 registers the old `(values.len() * 2) as u8`
482/// declared a byte count of **0** and then wrote 256 octets of data behind it.
483pub fn encode_registers_response(
484    function_code: u8,
485    values: &[u16],
486) -> Result<Vec<u8>, EncodeError> {
487    let byte_count = values.len() * 2;
488    if byte_count > MAX_READ_RESPONSE_DATA {
489        return Err(EncodeError::ByteCount {
490            values: values.len(),
491            bytes: byte_count,
492        });
493    }
494    let mut pdu = Vec::with_capacity(2 + byte_count);
495    pdu.push(function_code);
496    // Range-checked above, so the cast cannot lose a bit.
497    pdu.push(byte_count as u8);
498    for v in values {
499        pdu.extend_from_slice(&v.to_be_bytes());
500    }
501    Ok(pdu)
502}
503
504/// PDU acknowledging a write.
505///
506/// FC 5 and 6 echo the request verbatim; FC 15 and 16 echo the starting address and the
507/// quantity written. Reconstructed from the parsed request rather than copied from the
508/// input bytes, so a malformed echo cannot be produced.
509pub fn encode_write_ack(request: &ModbusRequest) -> Vec<u8> {
510    let mut pdu = Vec::with_capacity(5);
511    pdu.push(request.function_code());
512    match request {
513        ModbusRequest::WriteSingleCoil { address, value } => {
514            pdu.extend_from_slice(&address.to_be_bytes());
515            pdu.extend_from_slice(&if *value { 0xFF00u16 } else { 0x0000u16 }.to_be_bytes());
516        }
517        ModbusRequest::WriteSingleRegister { address, value } => {
518            pdu.extend_from_slice(&address.to_be_bytes());
519            pdu.extend_from_slice(&value.to_be_bytes());
520        }
521        ModbusRequest::WriteMultipleCoils { start, values } => {
522            pdu.extend_from_slice(&start.to_be_bytes());
523            pdu.extend_from_slice(&(values.len() as u16).to_be_bytes());
524        }
525        ModbusRequest::WriteMultipleRegisters { start, values } => {
526            pdu.extend_from_slice(&start.to_be_bytes());
527            pdu.extend_from_slice(&(values.len() as u16).to_be_bytes());
528        }
529        // Not a write; the caller checks this, but produce something legal rather than
530        // panicking on a programming error.
531        _ => {
532            pdu.clear();
533            pdu.push(request.function_code() | EXCEPTION_FLAG);
534            pdu.push(EXC_SERVER_DEVICE_FAILURE);
535        }
536    }
537    pdu
538}
539
540/// PDU for an exception response: `function_code | 0x80`, then the exception code.
541pub fn encode_exception(function_code: u8, exception_code: u8) -> Vec<u8> {
542    vec![function_code | EXCEPTION_FLAG, exception_code]
543}
544
545// ---------------------------------------------------------------------------
546// The client side: requests out, responses in
547// ---------------------------------------------------------------------------
548//
549// The same grammar read the other way round, for `src/client/modbus`. It lives here rather than
550// in the client so the two directions cannot drift: the client's encoder is checked against
551// this module's own `parse_request`, the decoder the server answers real masters with.
552
553/// Encode a request's PDU.
554///
555/// Refuses anything [`parse_request`] would answer with an exception — a quantity outside the
556/// function's range, an address range past `0xFFFF` — so the client never puts on the wire a
557/// request the specification already says no to. The refusal names the reason.
558pub fn encode_request(request: &ModbusRequest) -> Result<Vec<u8>, String> {
559    let mut pdu = Vec::with_capacity(8);
560    pdu.push(request.function_code());
561    let quantity_of = |n: usize, what: &str| -> Result<u16, String> {
562        u16::try_from(n).map_err(|_| format!("{n} {what} do not fit a Modbus quantity"))
563    };
564    let byte_count_of = |octets: usize, n: usize, what: &str| -> Result<u8, String> {
565        u8::try_from(octets)
566            .map_err(|_| format!("{n} {what} need {octets} octets, past a one-octet byte count"))
567    };
568    match request {
569        ModbusRequest::ReadCoils { start, quantity }
570        | ModbusRequest::ReadDiscreteInputs { start, quantity }
571        | ModbusRequest::ReadHoldingRegisters { start, quantity }
572        | ModbusRequest::ReadInputRegisters { start, quantity } => {
573            pdu.extend_from_slice(&start.to_be_bytes());
574            pdu.extend_from_slice(&quantity.to_be_bytes());
575        }
576        ModbusRequest::WriteSingleCoil { address, value } => {
577            pdu.extend_from_slice(&address.to_be_bytes());
578            pdu.extend_from_slice(&if *value { 0xFF00u16 } else { 0x0000 }.to_be_bytes());
579        }
580        ModbusRequest::WriteSingleRegister { address, value } => {
581            pdu.extend_from_slice(&address.to_be_bytes());
582            pdu.extend_from_slice(&value.to_be_bytes());
583        }
584        ModbusRequest::WriteMultipleCoils { start, values } => {
585            let quantity = quantity_of(values.len(), "coils")?;
586            let octets = values.len().div_ceil(8);
587            pdu.extend_from_slice(&start.to_be_bytes());
588            pdu.extend_from_slice(&quantity.to_be_bytes());
589            pdu.push(byte_count_of(octets, values.len(), "coils")?);
590            let mut packed = vec![0u8; octets];
591            for (i, &on) in values.iter().enumerate() {
592                if on {
593                    packed[i / 8] |= 1 << (i % 8);
594                }
595            }
596            pdu.extend_from_slice(&packed);
597        }
598        ModbusRequest::WriteMultipleRegisters { start, values } => {
599            let quantity = quantity_of(values.len(), "registers")?;
600            let octets = values.len() * 2;
601            pdu.extend_from_slice(&start.to_be_bytes());
602            pdu.extend_from_slice(&quantity.to_be_bytes());
603            pdu.push(byte_count_of(octets, values.len(), "registers")?);
604            for v in values {
605                pdu.extend_from_slice(&v.to_be_bytes());
606            }
607        }
608    }
609    match parse_request(&pdu) {
610        Ok(decoded) if &decoded == request => Ok(pdu),
611        Ok(_) => Err("the request does not survive its own encoding".to_string()),
612        Err(code) => Err(format!(
613            "{} at address {} for {} item(s) is not a legal Modbus request ({})",
614            request.function_name(),
615            request.start_address(),
616            request.quantity(),
617            exception_name(code)
618        )),
619    }
620}
621
622/// A decoded response, checked against the request it answers.
623#[derive(Debug, Clone, PartialEq, Eq)]
624pub enum ModbusResponse {
625    /// FC 1/2: exactly the requested number of bits, first requested item first.
626    Bits(Vec<bool>),
627    /// FC 3/4: exactly the requested number of registers.
628    Registers(Vec<u16>),
629    /// FC 5/6/15/16: the acknowledgement echoed the request.
630    WriteAck,
631    /// `function | 0x80`, then the exception code.
632    Exception { code: u8 },
633}
634
635/// Decode a response PDU against the request it answers.
636///
637/// Every field the specification fixes is checked, because a response that disagrees with its
638/// request is a server answering a different question: the function code (or its exception
639/// form), the byte count against the quantity asked for, the PDU length against the byte count,
640/// and a write's acknowledgement against what was written. The error says which.
641pub fn parse_response(pdu: &[u8], request: &ModbusRequest) -> Result<ModbusResponse, String> {
642    let fc = request.function_code();
643    let Some(&got) = pdu.first() else {
644        return Err("empty response PDU".to_string());
645    };
646    if got == fc | EXCEPTION_FLAG {
647        return match pdu {
648            [_, code] => Ok(ModbusResponse::Exception { code: *code }),
649            _ => Err(format!(
650                "exception response is {} octets; it is exactly 2",
651                pdu.len()
652            )),
653        };
654    }
655    if got != fc {
656        return Err(format!(
657            "response carries function code {got:#04x}; the request was {fc:#04x}"
658        ));
659    }
660    let quantity = usize::from(request.quantity());
661    if request.is_bit_read() || request.is_register_read() {
662        let Some((&byte_count, data)) = pdu[1..].split_first() else {
663            return Err("read response has no byte count".to_string());
664        };
665        let expected = if request.is_bit_read() {
666            quantity.div_ceil(8)
667        } else {
668            quantity * 2
669        };
670        if usize::from(byte_count) != expected || data.len() != expected {
671            return Err(format!(
672                "read response declares {byte_count} octets and carries {}; {quantity} \
673                 item(s) need {expected}",
674                data.len()
675            ));
676        }
677        return Ok(if request.is_bit_read() {
678            ModbusResponse::Bits(
679                (0..quantity)
680                    .map(|i| data[i / 8] & (1 << (i % 8)) != 0)
681                    .collect(),
682            )
683        } else {
684            ModbusResponse::Registers(
685                data.chunks_exact(2)
686                    .map(|c| u16::from_be_bytes([c[0], c[1]]))
687                    .collect(),
688            )
689        });
690    }
691    // A write: the acknowledgement is fixed by the request, so compare it byte for byte.
692    let echo = encode_write_ack(request);
693    if pdu == echo.as_slice() {
694        Ok(ModbusResponse::WriteAck)
695    } else {
696        Err(format!(
697            "write acknowledgement {} does not echo the request {}",
698            hex::encode(pdu),
699            hex::encode(&echo)
700        ))
701    }
702}