-
Notifications
You must be signed in to change notification settings - Fork 2
Device Drivers
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.
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
| 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. |
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.
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::readwalks the fields top to bottom and gives each one the nextsize()bits. For the EL1002 that means channel 1 gets bit 0 and channel 2 gets bit 1. -
Optionmeans "is this PDO assigned?" ANonefield 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. TheDefaultimpl 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, soinput_len()is 8, which matches the 1 byte it gets in the process image.
#[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.
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.
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.
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 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.
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
#[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:
- Write
0to sub-index 0, which clears the assignment. - Write the
pdo_object_indexof everySome(_)field to sub-index 1, 2, and so on. - 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/.
-
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, usewago_modules/BOILERPLATE.rs, which has step-by-step instructions. Those modules implementDynamicEthercatDevice, 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. -
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 aTxPdoObjectand/orRxPdoObjectimpl. -
Write the driver struct with
txpdo/rxpdofields andis_used: bool, plus#[derive(EthercatDevice)],NewEthercatDevice,EthercatDeviceProcessingandDebug. -
Implement the matching
iotrait(s) so machine code can use the terminal without knowing its type. Only add a new trait toio/if none of the existing ones fit. -
Export the identity constants:
*_VENDOR_ID,*_PRODUCT_ID,*_REVISION_*and*_IDENTITY_*. -
Register the module with
pub modin the vendor'smod.rs. -
Add it to both lookup functions in
devices/mod.rs,device_from_subdevice_identityanddevice_from_subdevice_identity_rc:EL1002_IDENTITY_A => Ok(Box::new(EL1002::new())),
-
Add tests if the PDO decoding goes beyond trivial bool mapping: build a
BitVec, calltxpdo.read()orrxpdo.write(), and assert on the result.pdo/basic.rsandpdo/el40xx.rshave examples. -
Optionally add an example,
examples/<terminal>_minimal.rs, modelled onel1002_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.
EtherCAT HAL
EtherCAT Devices
XTREM
Other crates