Skip to content

Device Drivers

Robin Krämer edited this page Sep 29, 2026 · 3 revisions

A device driver turns the raw bytes a terminal occupies in the process image into typed Rust values, and turns typed values back into bytes. This page walks through two complete drivers, the EL1002 (2-channel digital input) and the EL2002 (2-channel digital output). Every other driver in devices/ is built from the same parts.

The layers

flowchart TB
    APP["Machine / application code"]
    IO["io traits<br/>DigitalInputDevice · DigitalOutputDevice · …"]
    DEV["Device driver<br/>EL1002 · EL2002 · …"]
    PDO["PDO struct<br/>EL1002TxPdo · EL2002RxPdo"]
    OBJ["PDO objects<br/>BoolPdoObject · F32PdoObject · …"]
    BITS[("BitSlice of the process image")]

    APP --> IO --> DEV --> PDO --> OBJ --> BITS
Loading
Layer Lives in Responsibility
PDO object pdo/basic.rs, pdo/el*.rs Encodes or decodes one mapped PDO entry, such as one channel, at a fixed bit width.
PDO struct the driver file Lists every PDO a terminal can map, in order, each with its CoE index.
Device driver devices/<vendor>_modules/ Owns the PDO struct(s) and implements EthercatDevice (mostly derived) and the io traits.
io trait io/ A hardware-agnostic interface. Machine code depends on DigitalInputDevice, not on EL1002.

The EL1002, from the bottom up

1. PDO object: BoolPdoObject

One digital channel is one bit. pdo/basic.rs defines it like this:

#[derive(Debug, Clone, Default, PdoObject)]
#[pdo_object(bits = 1)]                         // derive PdoObject → size() == 1
pub struct BoolPdoObject {
    pub value: bool,
}

impl TxPdoObject for BoolPdoObject {            // device → master (input)
    fn read(&mut self, buffer: &BitSlice<u8, Lsb0>) {
        self.value = buffer[0];
    }
}

impl RxPdoObject for BoolPdoObject {            // master → device (output)
    fn write(&self, buffer: &mut BitSlice<u8, Lsb0>) {
        buffer.set(0, self.value);
    }
}

The buffer a PDO object receives is already cut to exactly size() bits. The object never needs to know where it sits in the frame.

2. PDO struct: EL1002TxPdo

The EL1002 maps one TxPDO per channel, at CoE indices 0x1A00 and 0x1A01:

#[derive(Debug, Clone, TxPdo)]
pub struct EL1002TxPdo {
    #[pdo_object_index(0x1A00)]
    pub channel1: Option<BoolPdoObject>,
    #[pdo_object_index(0x1A01)]
    pub channel2: Option<BoolPdoObject>,
}

impl Default for EL1002TxPdo {
    fn default() -> Self {
        Self {
            channel1: Some(BoolPdoObject::default()),
            channel2: Some(BoolPdoObject::default()),
        }
    }
}

Three rules apply to PDO structs:

  • Field order is wire order. TxPdo::read walks the fields top to bottom and gives each one the next size() bits. For the EL1002 that means channel 1 gets bit 0 and channel 2 gets bit 1.
  • Option means "is this PDO assigned?" A None field takes up no bits and is left out of the CoE assignment. Terminals with alternative PDO sets (for example a standard and a compact analog format) have fields that are mutually exclusive. The Default impl chooses which ones are active.
  • Sizes are rounded up to whole bytes. size() returns bits, padded to the next multiple of 8. The EL1002 uses 2 bits, so input_len() is 8, which matches the 1 byte it gets in the process image.

3. The driver: EL1002

#[derive(Clone, EthercatDevice)]
pub struct EL1002 {
    pub txpdo: EL1002TxPdo,
    is_used: bool,
}

impl EthercatDeviceProcessing for EL1002 {}            // no pre-/post-processing needed

impl NewEthercatDevice for EL1002 {
    fn new() -> Self {
        Self { txpdo: EL1002TxPdo::default(), is_used: false }
    }
}

impl std::fmt::Debug for EL1002 {  write!(f, "EL1002") }

#[derive(EthercatDevice)] relies on field names. The Derive macros shows the full expansion of every derive.

Field Effect
txpdo input(bits) → self.txpdo.read(bits) and input_len() → self.txpdo.size()
rxpdo output(bits) → self.rxpdo.write(bits) and output_len() → self.rxpdo.size()
missing either the matching input/output is a no-op, and its length is 0
is_used: bool required. Used by the derived EthercatDeviceUsed (is_used / set_used).

The derive also supplies as_any, as_any_mut and into_any_boxed for downcasting from dyn EthercatDevice, plus is_module() == false. The EthercatDevice trait also requires NewEthercatDevice, EthercatDeviceProcessing, Send + Sync + Debug and Any. The three impls above cover those.

EthercatDeviceProcessing has two optional hooks. Override them when the raw PDO isn't what the io layer wants:

  • input_post_process(): for example, unwrapping a 16-bit counter into an i128 position.
  • output_pre_process(): for example, converting a velocity into a stepper frequency.

The derived input() and output() do not call these hooks. An application using a driver that overrides them has to call input_post_process() after input(), and output_pre_process() before output(). The EL70x1 stepper drivers are an example.

4. The io trait: DigitalInputDevice

pub trait DigitalInputDevice {
    fn get_input(&self, port: usize) -> Result<bool, anyhow::Error>;
    fn get_port_count(&self) -> usize;
}
impl DigitalInputDevice for EL1002 {
    fn get_input(&self, port: usize) -> Result<bool, anyhow::Error> {
        let error = anyhow::anyhow!("[{}::Device::digital_input_state] Port index {} is not available",
                                    module_path!(), port);
        match port {
            0 => Ok(self.txpdo.channel1.as_ref().ok_or(error)?.value),
            1 => Ok(self.txpdo.channel2.as_ref().ok_or(error)?.value),
            _ => Err(anyhow::anyhow!("EL1002 has 2 ports (0-1), requested index {}", port)),
        }
    }
    fn get_port_count(&self) -> usize { 2 }
}

Ports are 0-based. The driver also exports a named-port enum, EL1002Port::{DI1, DI2}, which has a to_bit_index() method.

5. Identity

pub const EL1002_VENDOR_ID: u32 = 0x2;              // Beckhoff (== BECKHOFF_VENDOR_ID)
pub const EL1002_PRODUCT_ID: u32 = 65679442;
pub const EL1002_REVISION_A: u32 = 1179648;
pub const EL1002_IDENTITY_A: SubDeviceIdentityTuple =
    (EL1002_VENDOR_ID, EL1002_PRODUCT_ID, EL1002_REVISION_A);

A SubDeviceIdentityTuple is (vendor, product, revision). It is what MetaSubdevice reports and what the driver lookup matches on. Each hardware revision you have verified gets its own _IDENTITY_x constant.

Optional: predefined PDO assignments

Terminals that support more than one PDO layout provide an enum implementing PredefinedPdoAssignment<TxPdo, RxPdo>, so callers can pick a layout by name. The EL1002 only has one layout, but it follows the same pattern:

pub enum EL1002PredefinedPdoAssignment { All }

impl PredefinedPdoAssignment<EL1002TxPdo, ()> for EL1002PredefinedPdoAssignment {
    fn txpdo_assignment(&self) -> EL1002TxPdo { /* both channels Some(_) */ }
    fn rxpdo_assignment(&self) { unreachable!() }       // input-only terminal
}

The EL2002: the output side

The EL2002 mirrors the EL1002. The differences:

#[derive(EthercatDevice)]
pub struct EL2002 {
    pub rxpdo: EL2002RxPdo,          // rxpdo instead of txpdo → derive generates output()/output_len()
    is_used: bool,
}

#[derive(Debug, Clone, RxPdo)]       // RxPdo instead of TxPdo
pub struct EL2002RxPdo {
    #[pdo_object_index(0x1600)]      // RxPDO mapping indices start at 0x1600
    pub channel1: Option<BoolPdoObject>,
    #[pdo_object_index(0x1601)]
    pub channel2: Option<BoolPdoObject>,
}

impl DigitalOutputDevice for EL2002 {
    fn set_output(&mut self, port: usize, value: bool) {
        let expect_text = "All channels should be Some(_)";
        match port {
            0 => self.rxpdo.channel1.as_mut().expect(expect_text).value = value,
            1 => self.rxpdo.channel2.as_mut().expect(expect_text).value = value,
            _ => (),                   // out-of-range ports are ignored
        }
    }
    fn get_port_count(&self) -> usize { 2 }
}

set_output only changes the driver's in-memory state. Nothing goes on the wire until the application calls el2002.output(bits) on the device's slice of the output buffer and then send_outputs() (see application guide §7).

The EL2002 is registered with two revisions:

pub const EL2002_PRODUCT_ID: u32 = 0x07d23052;
pub const EL2002_REVISION_A: u32 = 0x00110000;
pub const EL2002_REVISION_B: u32 = 0x00120000;
pub const EL2002_IDENTITY_A: SubDeviceIdentityTuple = (EL2002_VENDOR_ID, EL2002_PRODUCT_ID, EL2002_REVISION_A);
pub const EL2002_IDENTITY_B: SubDeviceIdentityTuple = (EL2002_VENDOR_ID, EL2002_PRODUCT_ID, EL2002_REVISION_B);

A terminal with both inputs and outputs has both a txpdo and an rxpdo field, and the derive generates both directions.

One cycle, end to end

With an EL1002 and an EL2002 on the bus:

inputs  [ … | EL1002: 0b000000_1_0 | … ]      start_tx..end_tx = 1 byte
                          │ │
         el1002.input() ──┘ └── bit 0 → channel1.value = false
                                bit 1 → channel2.value = true
         el1002.get_input(1) == true

         el2002.set_output(1, true)
outputs [ … | EL2002: 0b000000_1_0 | … ]      start_rx..end_rx = 1 byte
         el2002.output() writes bit 0 = channel1, bit 1 = channel2

PDO assignment over CoE

#[derive(TxPdo)] and #[derive(RxPdo)] also implement coe::Configuration::write_config for the PDO struct. It writes the list of assigned PDOs to the sync manager's assignment object: 0x1C13 for TxPDOs and 0x1C12 for RxPDOs. It works like this:

  1. Write 0 to sub-index 0, which clears the assignment.
  2. Write the pdo_object_index of every Some(_) field to sub-index 1, 2, and so on.
  3. Write the count to sub-index 0.

This is how switching a field between Some and None changes what the terminal actually maps. It has to be called in PreOp. The EL1002 and EL2002 have a fixed default mapping, so calling it for them is optional.

Terminals with settings beyond PDO assignment (filters, ranges, motor parameters) define a config struct that implements Configuration, and implement ConfigurableDevice<Config> on the driver. Those config structs usually live in coe.rs next to the driver, or in shared_config/.

Adding a new terminal

  1. Create the driver file in devices/<vendor>_modules/. Copy the closest existing driver: an EL1xxx for inputs, an EL2xxx for outputs. For WAGO 750 slot modules, use wago_modules/BOILERPLATE.rs, which has step-by-step instructions. Those modules implement DynamicEthercatDevice, because their offsets inside the coupler's image are only known at runtime. They are registered in the coupler, not in steps 6–7 below. See WAGO 750.
  2. Define the PDO struct(s). Take the indices, the order and the bit widths from the terminal's documentation or ESI file. Reuse PDO objects from pdo/ where you can. Add a new one with #[derive(PdoObject)] #[pdo_object(bits = N)] plus a TxPdoObject and/or RxPdoObject impl.
  3. Write the driver struct with txpdo/rxpdo fields and is_used: bool, plus #[derive(EthercatDevice)], NewEthercatDevice, EthercatDeviceProcessing and Debug.
  4. Implement the matching io trait(s) so machine code can use the terminal without knowing its type. Only add a new trait to io/ if none of the existing ones fit.
  5. Export the identity constants: *_VENDOR_ID, *_PRODUCT_ID, *_REVISION_* and *_IDENTITY_*.
  6. Register the module with pub mod in the vendor's mod.rs.
  7. Add it to both lookup functions in devices/mod.rs, device_from_subdevice_identity and device_from_subdevice_identity_rc:
    EL1002_IDENTITY_A => Ok(Box::new(EL1002::new())),
  8. Add tests if the PDO decoding goes beyond trivial bool mapping: build a BitVec, call txpdo.read() or rxpdo.write(), and assert on the result. pdo/basic.rs and pdo/el40xx.rs have examples.
  9. Optionally add an example, examples/<terminal>_minimal.rs, modelled on el1002_minimal.rs, so the driver can be checked against real hardware.

To find a terminal's identity values, bring the bus up to PreOp and print handle.try_get_subdevices_vec_sync(). The vendor, product_id and revision fields are the values you need.

Clone this wiki locally