Skip to content

Application Guide

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

This guide walks through a complete program that reads an EL1002 (2× digital input) and drives an EL2002 (2× digital output): each output follows its input. Along the way it covers every step a real application goes through. Architecture explains the mechanisms behind each step.

The examples el1002_minimal.rs, el2004_minimal.rs and input_output.rs follow the same structure.

1. Find the interface

If you don't know which NIC the bus is connected to, interface_discovery can find it:

use ethercat_hal::interface_discovery::{list_ethernet_interfaces, test_interface, LinkType};

for iface in list_ethernet_interfaces()? {
    if matches!(iface.link_type, LinkType::Link) && test_interface(&iface.name).is_ok() {
        println!("{} has an EtherCAT bus", iface.name);
    }
}

cargo run --example discovery does the same thing.

2. Start the master

use ethercat_hal::{init_ethercat, MasterConfiguration, EtherCATState};

let control = init_ethercat(&interface, None);       // None = MasterConfiguration::default()
let channel = control.channel.clone();                // command side, Clone
let mut handle = control.app_handle;                  // data side, single owner

The defaults are worth knowing:

Field Default
target_cycle_time_us 1000
dc_config.sync0_period 1 ms
dc_config.sync0_shift 500 µs
dc_config.start_delay 100 ms
wkc_mismatch_threshold 5
op_ramp_grace_cycles 10 000
realtime_optimizations None

If you change the cycle time in most cases keep sync0_period equal to it except for more advanced use cases like Oversampling for example.

3. Go to PreOp and discover subdevices

channel.request_state_change(EtherCATState::PreOp)?;   // returns immediately
while handle.get_state() != EtherCATState::PreOp {
    std::thread::sleep(Duration::from_millis(10));
}

for sd in handle.try_get_subdevices_vec_sync()? {
    println!("{:#06x} {} vendor={:#x} product={:#x} rev={:#x}",
        sd.device_address, sd.get_name()?, sd.vendor, sd.product_id, sd.revision);
}

At this point each MetaSubdevice has its identity and device_address filled in. Its PDO offsets are still 0.

4. Configure terminals (still in PreOp)

All CoE/SDO traffic has to happen now. After the move to Op, the controller no longer services the request queue.

The EL1002 and EL2002 don't need any configuration: their default PDO assignment is already what the drivers expect. If you want to write the assignment explicitly, the PDO structs implement coe::Configuration (derived, see device drivers):

use ethercat_hal::coe::Configuration;

el1002.txpdo.write_config(channel.clone(), sd.device_address)?;   // writes 0x1C13
el2002.rxpdo.write_config(channel.clone(), sd.device_address)?;   // writes 0x1C12

Terminals with real settings expose them through ConfigurableDevice<C>, and some also need DC sync. From el1259_minimal.rs:

el1259.write_config(channel.clone(), sd.device_address, &el1259.get_config())?;
channel.enable_dc_sync0(sd.device_address)?;

You can also read and write raw SDOs. The supported types are bool, u8, u16, i16, u32 and i32:

let v: u16 = channel.sdo_read(sd.device_address, 0x8000, 0x01)?;
channel.sdo_write(sd.device_address, 0x8000, 0x01, 42u16)?;

5. Go to Op

channel.request_state_change(EtherCATState::Op)?;
while handle.get_state() != EtherCATState::Op {
    std::thread::sleep(Duration::from_millis(10));
}
while !handle.check_all_op() {                          // every subdevice actually reached Op
    std::thread::sleep(Duration::from_millis(10));
}
let subdevices = handle.try_get_subdevices_vec_sync()?; // re-read: offsets are valid now

Always read the subdevice list again after Op. start_tx, end_tx, start_rx and end_rx are only computed during the move to Op.

If Op never arrives, check handle.get_last_transition_failure() (Diagnostics).

6. Bind drivers to subdevices

Look up each subdevice's byte ranges once, before the loop:

use ethercat_hal::devices::beckhoff_modules::{
    el1002::{EL1002, EL1002_IDENTITY_A},
    el2002::{EL2002, EL2002_IDENTITY_A, EL2002_IDENTITY_B},
};

fn ident(sd: &MetaSubdevice) -> (u32, u32, u32) {
    (sd.vendor, sd.product_id, sd.revision)
}

let di = subdevices.iter().find(|sd| ident(sd) == EL1002_IDENTITY_A).expect("no EL1002");
let dout = subdevices.iter()
    .find(|sd| matches!(ident(sd), EL2002_IDENTITY_A | EL2002_IDENTITY_B))
    .expect("no EL2002");

let mut el1002 = EL1002::new();
let mut el2002 = EL2002::new();

Matching on the full identity tuple (vendor, product, revision) is stricter than matching on product_id alone, and it tells you whether a driver supports that particular revision.

If you would rather construct drivers generically, devices::device_from_subdevice_identity(sd) returns a Box<dyn EthercatDevice> for any supported identity, and you can downcast it with as_any_mut().

7. The control loop

use bitvec::{order::Lsb0, slice::BitSlice};
use ethercat_hal::devices::EthercatDevice;
use ethercat_hal::io::{digital_input::DigitalInputDevice, digital_output::DigitalOutputDevice};

let mut last_cycle = handle.get_current_cycle();
loop {
    // Wait for a fresh cycle (optional: without this you may process the same snapshot twice).
    while handle.get_current_cycle() == last_cycle { std::hint::spin_loop(); }
    last_cycle = handle.get_current_cycle();

    // 1. Decode inputs
    if let Some(inputs) = handle.get_inputs() {
        el1002.input(BitSlice::<u8, Lsb0>::from_slice(&inputs[di.start_tx..di.end_tx]))?;
    }

    // 2. Application logic, through the io traits
    for port in 0..el1002.get_port_count() {
        el2002.set_output(port, el1002.get_input(port)?);
    }

    // 3. Encode outputs, then hand them to the controller
    if let Some(outputs) = handle.write_outputs() {
        el2002.output(BitSlice::<u8, Lsb0>::from_slice_mut(&mut outputs[dout.start_rx..dout.end_rx]))?;
        handle.send_outputs();
    }
}

Timing rules:

  • Inputs are published at the start of each cycle.
  • Outputs are picked up about 10 µs before the next cycle starts. Anything you send after that goes out one cycle later.
  • write_outputs() returns None while the controller still holds the previous buffer. Skip writing in that case. The controller releases the buffer once per cycle.
  • Sleep instead of spinning (for example std::thread::sleep) if you don't need to act on every cycle. Inputs always return the latest snapshot, and outputs persist until you overwrite them.

8. Monitoring while running

println!("cycle {} took {} µs", handle.get_current_cycle(), handle.get_cycle_time_us());

// Serviced in Op too:
for status in channel.al_status_snapshot()? {
    if status.is_faulty() { eprintln!("{status}"); }
}

If the state-machine thread exits because of an error, you get the error from control.join_handle.take().unwrap().join(), and get_transition_reports() still returns the full history.

Common mistakes

Symptom Cause
sdo_write times out It was called in Op, or before PreOp was reached. SDO requests are only serviced in PreOp, and requests sent during Init are thrown away.
A driver reads garbage or out-of-bounds errors Offsets were taken from the PreOp subdevice list. Read the list again after check_all_op().
Outputs never change send_outputs() was never called, or write_outputs() returned None and the code went ahead anyway.
request_state_change returned Ok but nothing happened That is expected: it only queues the request. Poll get_state(), and read get_last_transition_failure() if the state doesn't move.
RT priority or IRQ pinning warnings The binary is missing capabilities. Run it through the cargo runner (bin/run-linux), or apply setcap yourself.

Clone this wiki locally