pnio 0.1.0

PROFINET RT IO-Device stack in pure Rust (community, not affiliated with PI)
Documentation
//! Encoding/decoding of PROFINET process data types.
//!
//! All multi-byte types are big-endian ("Motorola format"),
//! matching the Siemens memory layout: no word-swap required.
//! `REAL` is IEEE-754 32-bit. `BOOL` is packed 8 bits per byte, LSB-first
//! (bit `byte.0` is the least-significant bit), following the Siemens addressing convention.

use thiserror::Error;

/// The 5 supported process types.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FieldType {
    /// Siemens `BOOL`: one bit, packed 8 per byte, LSB-first.
    Bool,
    /// Siemens `INT`: 16-bit signed, big-endian.
    Int,
    /// Siemens `WORD`: 16-bit unsigned, big-endian.
    Word,
    /// Siemens `DINT`: 32-bit signed, big-endian.
    Dint,
    /// Siemens `REAL`: IEEE-754 32-bit float, big-endian.
    Real,
}

impl FieldType {
    /// Byte size for byte-aligned types; `None` for `Bool` (bit-packed).
    pub fn byte_len(self) -> Option<usize> {
        match self {
            FieldType::Bool => None,
            FieldType::Int | FieldType::Word => Some(2),
            FieldType::Dint | FieldType::Real => Some(4),
        }
    }
}

/// Typed process value, encoded/decoded through the codecs below.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Value {
    /// A [`FieldType::Bool`] value.
    Bool(bool),
    /// A [`FieldType::Int`] value.
    Int(i16),
    /// A [`FieldType::Word`] value.
    Word(u16),
    /// A [`FieldType::Dint`] value.
    Dint(i32),
    /// A [`FieldType::Real`] value.
    Real(f32),
}

impl Value {
    /// The process type this value carries.
    pub fn field_type(&self) -> FieldType {
        match self {
            Value::Bool(_) => FieldType::Bool,
            Value::Int(_) => FieldType::Int,
            Value::Word(_) => FieldType::Word,
            Value::Dint(_) => FieldType::Dint,
            Value::Real(_) => FieldType::Real,
        }
    }

    /// Write this value at the start of `dst` (byte types, big-endian) or at bit index
    /// `bit` of `dst` (`Bool`, LSB-first: byte `bit / 8`, mask `1 << (bit % 8)`).
    pub fn encode(&self, dst: &mut [u8], bit: usize) -> Result<(), CodecError> {
        fn put<const N: usize>(dst: &mut [u8], bytes: [u8; N]) -> Result<(), CodecError> {
            if dst.len() < N {
                return Err(CodecError::TooShort {
                    need: N,
                    have: dst.len(),
                });
            }
            dst[..N].copy_from_slice(&bytes);
            Ok(())
        }
        match *self {
            Value::Bool(b) => set_bit(dst, bit, b),
            Value::Int(v) => put(dst, encode_i16(v)),
            Value::Word(v) => put(dst, encode_u16(v)),
            Value::Dint(v) => put(dst, encode_i32(v)),
            Value::Real(v) => put(dst, encode_f32(v)),
        }
    }

    /// Read a value of type `ty` from the start of `src` (byte types) or from bit
    /// index `bit` (`Bool`).
    pub fn decode(ty: FieldType, src: &[u8], bit: usize) -> Result<Value, CodecError> {
        Ok(match ty {
            FieldType::Bool => Value::Bool(get_bit(src, bit)?),
            FieldType::Int => Value::Int(decode_i16(src)?),
            FieldType::Word => Value::Word(decode_u16(src)?),
            FieldType::Dint => Value::Dint(decode_i32(src)?),
            FieldType::Real => Value::Real(decode_f32(src)?),
        })
    }
}

/// Encoding/decoding errors.
#[derive(Debug, Error, PartialEq, Eq)]
pub enum CodecError {
    /// The destination/source buffer is shorter than the type being
    /// encoded/decoded needs.
    #[error("buffer too short: need {need}, have {have}")]
    TooShort {
        /// Bytes required.
        need: usize,
        /// Bytes actually available.
        have: usize,
    },
    /// `bit` (in [`get_bit`]/[`set_bit`]) falls outside the buffer.
    #[error("bit index {bit} out of range for {bytes}-byte buffer")]
    BitOutOfRange {
        /// The requested bit index.
        bit: usize,
        /// The buffer's length in bytes.
        bytes: usize,
    },
}

/// Encodes an `i16` as 2 big-endian bytes.
pub fn encode_i16(v: i16) -> [u8; 2] {
    v.to_be_bytes()
}
/// Encodes a `u16` as 2 big-endian bytes.
pub fn encode_u16(v: u16) -> [u8; 2] {
    v.to_be_bytes()
}
/// Encodes an `i32` as 4 big-endian bytes.
pub fn encode_i32(v: i32) -> [u8; 4] {
    v.to_be_bytes()
}
/// Encodes an `f32` (IEEE-754) as 4 big-endian bytes.
pub fn encode_f32(v: f32) -> [u8; 4] {
    v.to_be_bytes()
}

/// Decodes an `i16` from the first 2 big-endian bytes of `b`.
/// Errors: [`CodecError::TooShort`] if `b` has fewer than 2 bytes.
pub fn decode_i16(b: &[u8]) -> Result<i16, CodecError> {
    let a = take::<2>(b)?;
    Ok(i16::from_be_bytes(a))
}
/// Decodes a `u16` from the first 2 big-endian bytes of `b`.
/// Errors: [`CodecError::TooShort`] if `b` has fewer than 2 bytes.
pub fn decode_u16(b: &[u8]) -> Result<u16, CodecError> {
    let a = take::<2>(b)?;
    Ok(u16::from_be_bytes(a))
}
/// Decodes an `i32` from the first 4 big-endian bytes of `b`.
/// Errors: [`CodecError::TooShort`] if `b` has fewer than 4 bytes.
pub fn decode_i32(b: &[u8]) -> Result<i32, CodecError> {
    let a = take::<4>(b)?;
    Ok(i32::from_be_bytes(a))
}
/// Decodes an `f32` (IEEE-754) from the first 4 big-endian bytes of `b`.
/// Errors: [`CodecError::TooShort`] if `b` has fewer than 4 bytes.
pub fn decode_f32(b: &[u8]) -> Result<f32, CodecError> {
    let a = take::<4>(b)?;
    Ok(f32::from_be_bytes(a))
}

/// Copies the first `N` bytes from `b`, or returns `TooShort`.
fn take<const N: usize>(b: &[u8]) -> Result<[u8; N], CodecError> {
    if b.len() < N {
        return Err(CodecError::TooShort {
            need: N,
            have: b.len(),
        });
    }
    let mut a = [0u8; N];
    a.copy_from_slice(&b[..N]);
    Ok(a)
}

/// Reads bit at index `bit` (LSB-first: byte `bit/8`, mask `1 << (bit % 8)`).
pub fn get_bit(buf: &[u8], bit: usize) -> Result<bool, CodecError> {
    let byte = bit / 8;
    if byte >= buf.len() {
        return Err(CodecError::BitOutOfRange {
            bit,
            bytes: buf.len(),
        });
    }
    Ok((buf[byte] >> (bit % 8)) & 1 == 1)
}

/// Writes bit at index `bit` (same convention as `get_bit`).
pub fn set_bit(buf: &mut [u8], bit: usize, value: bool) -> Result<(), CodecError> {
    let byte = bit / 8;
    if byte >= buf.len() {
        return Err(CodecError::BitOutOfRange {
            bit,
            bytes: buf.len(),
        });
    }
    let mask = 1u8 << (bit % 8);
    if value {
        buf[byte] |= mask;
    } else {
        buf[byte] &= !mask;
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn known_vectors_big_endian() {
        assert_eq!(encode_i16(-1), [0xFF, 0xFF]);
        assert_eq!(encode_u16(0x0102), [0x01, 0x02]);
        assert_eq!(encode_i32(-1), [0xFF, 0xFF, 0xFF, 0xFF]);
        // IEEE-754: 1.0_f32 = 0x3F800000; -2.0_f32 = 0xC0000000
        assert_eq!(encode_f32(1.0), [0x3F, 0x80, 0x00, 0x00]);
        assert_eq!(encode_f32(-2.0), [0xC0, 0x00, 0x00, 0x00]);
    }

    #[test]
    #[allow(clippy::approx_constant)]
    fn scalar_round_trips() {
        for v in [i16::MIN, -1, 0, 1, 1234, i16::MAX] {
            assert_eq!(decode_i16(&encode_i16(v)).unwrap(), v);
        }
        for v in [0u16, 1, 0x00FF, 0xFF00, u16::MAX] {
            assert_eq!(decode_u16(&encode_u16(v)).unwrap(), v);
        }
        for v in [i32::MIN, -1, 0, 1, 70000, i32::MAX] {
            assert_eq!(decode_i32(&encode_i32(v)).unwrap(), v);
        }
        for v in [f32::MIN, -2.0, -0.5, 0.0, 0.5, 3.14159, f32::MAX] {
            assert_eq!(decode_f32(&encode_f32(v)).unwrap(), v);
        }
    }

    #[test]
    fn decode_too_short_errors() {
        assert_eq!(
            decode_i16(&[0x00]),
            Err(CodecError::TooShort { need: 2, have: 1 })
        );
        assert_eq!(
            decode_i32(&[0x00, 0x00]),
            Err(CodecError::TooShort { need: 4, have: 2 })
        );
        assert_eq!(
            decode_f32(&[]),
            Err(CodecError::TooShort { need: 4, have: 0 })
        );
    }

    #[test]
    fn decode_ignores_extra_bytes() {
        // a buffer longer than needed decodes the first bytes
        assert_eq!(decode_u16(&[0x01, 0x02, 0x03]).unwrap(), 0x0102);
    }

    #[test]
    fn field_type_byte_len() {
        assert_eq!(FieldType::Bool.byte_len(), None);
        assert_eq!(FieldType::Int.byte_len(), Some(2));
        assert_eq!(FieldType::Word.byte_len(), Some(2));
        assert_eq!(FieldType::Dint.byte_len(), Some(4));
        assert_eq!(FieldType::Real.byte_len(), Some(4));
    }

    #[test]
    fn set_and_get_bits_lsb_first() {
        let mut buf = [0u8; 4]; // 32 bits

        // bit 0 = LSB of byte 0
        set_bit(&mut buf, 0, true).unwrap();
        assert_eq!(buf[0], 0b0000_0001);
        // bit 7 = MSB of byte 0
        set_bit(&mut buf, 7, true).unwrap();
        assert_eq!(buf[0], 0b1000_0001);
        // bit 8 = LSB of byte 1
        set_bit(&mut buf, 8, true).unwrap();
        assert_eq!(buf[1], 0b0000_0001);
        // bit 31 = MSB of byte 3
        set_bit(&mut buf, 31, true).unwrap();
        assert_eq!(buf[3], 0b1000_0000);

        for &(i, expected) in &[(0, true), (1, false), (7, true), (8, true), (31, true)] {
            assert_eq!(get_bit(&buf, i).unwrap(), expected, "bit {i}");
        }
    }

    #[test]
    #[allow(clippy::bool_assert_comparison)]
    fn clearing_a_bit() {
        let mut buf = [0xFFu8; 1];
        set_bit(&mut buf, 3, false).unwrap();
        assert_eq!(buf[0], 0b1111_0111);
        assert_eq!(get_bit(&buf, 3).unwrap(), false);
    }

    #[test]
    fn bit_out_of_range_errors() {
        let mut buf = [0u8; 1]; // 8 valid bits: 0..=7
        assert_eq!(
            get_bit(&buf, 8),
            Err(CodecError::BitOutOfRange { bit: 8, bytes: 1 })
        );
        assert_eq!(
            set_bit(&mut buf, 8, true),
            Err(CodecError::BitOutOfRange { bit: 8, bytes: 1 })
        );
    }

    #[test]
    fn value_round_trips_every_type() {
        let cases = [
            (Value::Int(-2), FieldType::Int, vec![0xFF, 0xFE]),
            (Value::Word(0xBEEF), FieldType::Word, vec![0xBE, 0xEF]),
            (
                Value::Dint(-1),
                FieldType::Dint,
                vec![0xFF, 0xFF, 0xFF, 0xFF],
            ),
            (
                Value::Real(1.0),
                FieldType::Real,
                vec![0x3F, 0x80, 0x00, 0x00],
            ),
        ];
        for (v, ty, bytes) in cases {
            let mut buf = vec![0u8; bytes.len()];
            v.encode(&mut buf, 0).unwrap();
            assert_eq!(buf, bytes, "{v:?}");
            assert_eq!(Value::decode(ty, &buf, 0).unwrap(), v);
            assert_eq!(v.field_type(), ty);
        }
    }

    #[test]
    fn bool_value_uses_the_bit_argument_lsb_first() {
        // Bench 2026-08-27 (captures/q-bits): TIA `%Q0.0 := TRUE` alone -> output byte 0x01;
        // device input byte 0x80 -> `%I0.7` in TIA.
        let mut buf = [0u8; 1];
        Value::Bool(true).encode(&mut buf, 0).unwrap();
        assert_eq!(buf, [0x01]);
        assert_eq!(
            Value::decode(FieldType::Bool, &[0x80], 7).unwrap(),
            Value::Bool(true)
        );
        assert_eq!(
            Value::decode(FieldType::Bool, &[0x80], 6).unwrap(),
            Value::Bool(false)
        );
        // bit 9 lives in byte 1
        let mut two = [0u8; 2];
        Value::Bool(true).encode(&mut two, 9).unwrap();
        assert_eq!(two, [0x00, 0x02]);
    }

    #[test]
    fn value_codec_errors_are_typed() {
        assert!(matches!(
            Value::Real(0.0).encode(&mut [0u8; 3], 0),
            Err(CodecError::TooShort { need: 4, have: 3 })
        ));
        assert!(matches!(
            Value::decode(FieldType::Bool, &[0u8; 1], 8),
            Err(CodecError::BitOutOfRange { bit: 8, bytes: 1 })
        ));
    }
}