Skip to content

WAGO 750

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

WAGO 750 modules work differently from Beckhoff EL terminals. Only the 750-354 EtherCAT fieldbus coupler is an EtherCAT subdevice. The I/O modules plugged in after it sit on WAGO's internal local bus: they have no EtherCAT address, don't appear in the subdevice list, and have no identity tuple. Their process data is packed into the coupler's single PDO.

In ethercat_hal, the coupler is therefore a container device:

  • It discovers its modules over CoE.
  • It works out where each module's bits are inside the coupler's PDO.
  • It creates one driver per module.
  • It passes the coupler's whole process-image slice to every module driver. Each driver reads or writes its own bits at its own offset.

This page uses three modules as examples:

  • 750-455: 4 × analog input, 4–20 mA
  • 750-531: 4 × digital output
  • 750-554: 2 × analog output, 4–20 mA

The coupler also supports the 750-402, 750-430, 750-501, 750-530, 750-1506 (digital I/O), the 750-652 (serial) and the 750-671 / 750-672 (stepper). They plug into the coupler the same way, but this page doesn't cover them.

The matching examples are 750_455_minimal.rs, 750_531_minimal.rs and 750_554_minimal.rs.

How it fits together

flowchart LR
    subgraph Bus["EtherCAT bus"]
        C["750-354 coupler<br/>subdevice, has device_address,<br/>start_tx..end_tx / start_rx..end_rx"]
    end
    subgraph K["WAGO local bus (behind the coupler)"]
        M0["slot 0: 750-531"]
        M1["slot 1: 750-455"]
        M2["slot 2: 750-554"]
    end
    C --- M0 --- M1 --- M2

    subgraph SW["Wago750_354 (driver)"]
        S["slots: [Option&lt;Module&gt;; 64]<br/>slot_devices: [Option&lt;Box&lt;dyn DynamicEthercatDevice&gt;&gt;; 64]"]
    end
Loading
Type Role
Wago750_354 The coupler driver. It implements EthercatDevice, and its input() / output() loop over slot_devices.
Module (devices/mod.rs) Metadata for one slot: slot, belongs_to_addr, vendor_id, product_id (the module's ident), has_tx / has_rx, and tx_offset / rx_offset (bit offsets within the coupler's PDO).
DynamicEthercatDevice EthercatDevice + EthercatDynamicPDO (get_/set_tx_offset, get_/set_rx_offset). Every slot-module driver implements it.
SubDeviceProductTuple (vendor_id, product_id). Modules are matched without a revision, for example WAGO_750_531_MODULE_IDENT.

Setup sequence

Everything up to and including init_slot_modules uses SDO reads, so it has to run in PreOp:

sequenceDiagram
    participant App
    participant Coupler as Wago750_354
    participant Ch as EtherCATThreadChannel
    App->>Ch: request_state_change(PreOp), wait
    App->>Coupler: Wago750_354::initialize_modules(channel, addr)
    Coupler->>Ch: sdo_read 0xF050:00 (module count)
    Coupler->>Ch: sdo_read 0x9000+0x10·i :0A (module ident), for each slot
    Coupler-->>App: Vec<Module>
    App->>Coupler: set_module(m), for each module
    App->>Coupler: init_slot_modules(channel, addr)
    Coupler->>Ch: sdo_read 0x1C12/0x1C13 + PDO mapping objects
    Note over Coupler: compute bit offset per slot,<br/>instantiate one driver per slot
    App->>Ch: request_state_change(Op), wait
    App->>App: re-read subdevices (coupler's start/end offsets)
Loading

1. Module discovery: initialize_modules

Wago750_354::initialize_modules(channel, device_address) works like this:

  1. It reads the module count from 0xF050:00.

  2. For every slot i, it reads the module ident from 0x9000 + 0x10·i, sub-index 0x0A.

  3. get_modules() turns each ident into a Module. It sets the name and the has_tx / has_rx flags from a hard-coded match:

    • 750-455: has_tx only
    • 750-531: has_rx only
    • 750-554: has_rx only

    An ident it doesn't recognise still becomes a Module (both flags false), and a line is printed.

The function doesn't touch the coupler instance. You pass each returned Module into the coupler with coupler.set_module(module).

2. Offset calculation and driver creation: init_slot_modules

coupler.init_slot_modules(channel, device_address) first runs get_pdo_offsets() for both directions:

  1. It reads the sync-manager PDO assignment: 0x1C13 for inputs, 0x1C12 for outputs.
  2. The first assigned PDO (sub-index 1) is the coupler's own. Its entry bit lengths are added up to give the starting offset, so the coupler's own status and control bits are skipped.
  3. It collects the mapping entries of every other assigned PDO (u32 = index << 16 | sub << 8 | bit length) and sorts them numerically.
  4. It walks the sorted entries, adding up the bit lengths. Each entry's slot comes from its object index: (index − 0x6000) / 16 for inputs and (index − 0x7000) / 16 for outputs. Anything below 16 counts as slot 0. The first offset seen for each slot becomes that slot's start offset.

This relies on the coupler mapping slot n to objects 0x6000 + 0x10·n / 0x7000 + 0x10·n, and on the process image being laid out in the same order as the sorted entries.

It then fills in tx_offset / rx_offset for every module (only in the directions where has_tx / has_rx is set), and creates a driver for each slot from a second hard-coded match on (vendor_id, product_id), for example:

WAGO_750_455_MODULE_IDENT => Box::new(wago_750_455::Wago750_455::new()),

Each driver gets its offsets through set_tx_offset / set_rx_offset and goes into slot_devices[dev_count].

init_slot_modules only runs once. If dev_count != 0, it returns straight away.

3. Cyclic exchange

After Op, read the subdevice list again so that the coupler's start_tx..end_tx and start_rx..end_rx are valid. Then give the coupler its slice:

coupler.input(BitSlice::<u8, Lsb0>::from_slice(&inputs[c.start_tx..c.end_tx]))?;
// … use slot devices …
coupler.output(BitSlice::<u8, Lsb0>::from_slice_mut(&mut outputs[c.start_rx..c.end_rx]))?;

Wago750_354::input() and output() call input() / output() on every slot_devices entry, in order, and stop at the first None. Every module gets the whole coupler slice, not a sub-slice, and indexes into it with its own bit offset. Errors from the modules are thrown away (let _ = …).

Using a slot module

The slot devices are Box<dyn DynamicEthercatDevice>, so you downcast them to the concrete driver. Look the slot up once, after init_slot_modules:

use ethercat_hal::devices::wago_modules::wago_750_554::Wago750_554;

let ao_slot = coupler.slot_devices.iter()
    .position(|s| s.as_ref().is_some_and(|d| d.as_any().downcast_ref::<Wago750_554>().is_some()))
    .expect("no 750-554");

// every cycle:
let ao = coupler.slot_devices[ao_slot].as_mut().unwrap()
    .as_any_mut().downcast_mut::<Wago750_554>().unwrap();
ao.set_current(0, ElectricCurrent::new::<milliampere>(12.0));

slot_devices[i] is the module in physical slot i, counting from the coupler. This only holds as long as every module before it is supported (see Limitations).

The three reference modules

All three drivers have the same structure:

  • tx_bit_offset / rx_bit_offset, set by the coupler
  • an in-memory PDO struct (tx_pdo / rx_pdo)
  • EthercatDevice implemented by hand, not derived, because their positions depend on the offsets
  • empty EthercatDeviceProcessing hooks, so none of them need input_post_process / output_pre_process
  • a *_MODULE_IDENT constant

750-531: 4 × digital output (DigitalOutputDevice)

Ident WAGO_750_531_MODULE_IDENT = (0x21, 2147483714)
Process data RxPDO, 4 bits: bit rx_bit_offset + n = output n (Wago750_531OutputPort::DO1…DO4)
API set_output(port 0..=3, bool). Out-of-range ports are ignored.
let dout = slot.as_any_mut().downcast_mut::<Wago750_531>().unwrap();
dout.set_output(0, true);

750-455: 4 × analog input, 4–20 mA (AnalogCurrentInputDevice)

Ident WAGO_750_455_MODULE_IDENT = (0x21, 0x045541b3)
Process data TxPDO, 4 × 16 bits starting at tx_bit_offset (channel n at +16·n)
Decoding value = (raw >> 4) / 0x7FF. Error when the two lowest bits are both set.
API get_current(port) returns Option<ElectricCurrent> in the range 4–20 mA. get_current_relative(port) returns Option<f64>. None = wiring or range error.
let ai = slot.as_any().downcast_ref::<Wago750_455>().unwrap();
match ai.get_current(0) {
    Some(i) => println!("{:.2} mA", i.get::<milliampere>()),
    None => println!("wiring error"),
}

get_current_relative panics for ports above 3. The result isn't clamped, so a raw value above 0x7FF0 comes out as more than 20 mA.

750-554: 2 × analog output, 4–20 mA (AnalogCurrentOutputDevice)

Ident WAGO_750_554_MODULE_IDENT = (0x21, 0x055442cd)
Process data RxPDO, 2 × 16 bits starting at rx_bit_offset
Encoding raw = round(relative × 0x7FFF) & 0x7FF8: 0x0000 = 4 mA, 0x7FFF = 20 mA. 12-bit resolution; the module ignores the lowest 3 bits.
API set_current(port, ElectricCurrent) or set_current_relative(port, 0.0..=1.0). Values outside the range are clamped. Ports other than 0 and 1 are ignored.

Complete example

This is a condensed version of the three examples:

use bitvec::{order::Lsb0, slice::BitSlice};
use ethercat_hal::MetaSubdevice;
use ethercat_hal::devices::{EthercatDevice, NewEthercatDevice};
use ethercat_hal::devices::wago_modules::{
    wago_750_354::{WAGO_750_354_IDENTITY_A, Wago750_354},
    wago_750_455::Wago750_455,
    wago_750_531::Wago750_531,
    wago_750_554::Wago750_554,
};
use ethercat_hal::io::{
    analog_input::AnalogCurrentInputDevice, analog_output::AnalogCurrentOutputDevice,
    digital_output::DigitalOutputDevice,
};
use units::{electric_current::milliampere, f64::ElectricCurrent};

// --- PreOp ---
let is_coupler = |s: &&MetaSubdevice| (s.vendor, s.product_id) == (WAGO_750_354_IDENTITY_A.0, WAGO_750_354_IDENTITY_A.1);
let c = *handle.try_get_subdevices_vec_sync()?.iter().find(is_coupler).expect("no 750-354");

let mut coupler = Wago750_354::new();
for module in Wago750_354::initialize_modules(channel.clone(), c.device_address)? {
    println!("slot {}: {} (ident 0x{:08x})", module.slot, module.name, module.product_id);
    coupler.set_module(module);
}
coupler.init_slot_modules(channel.clone(), c.device_address);

fn find<T: 'static>(coupler: &Wago750_354) -> Option<usize> {
    coupler.slot_devices.iter()
        .position(|s| s.as_ref().is_some_and(|d| d.as_any().downcast_ref::<T>().is_some()))
}
let (ai, dout, ao) = (find::<Wago750_455>(&coupler), find::<Wago750_531>(&coupler), find::<Wago750_554>(&coupler));

// --- Op (then re-read the subdevice list for the coupler's offsets) ---
let c = *handle.try_get_subdevices_vec_sync()?.iter().find(is_coupler).unwrap();

loop {
    if let Some(inputs) = handle.get_inputs() {
        coupler.input(BitSlice::<u8, Lsb0>::from_slice(&inputs[c.start_tx..c.end_tx]))?;
    }

    let measured = ai
        .and_then(|i| coupler.slot_devices[i].as_ref())
        .and_then(|d| d.as_any().downcast_ref::<Wago750_455>())
        .and_then(|d| d.get_current(0));

    if let Some(i) = dout {
        let d = coupler.slot_devices[i].as_mut().unwrap().as_any_mut().downcast_mut::<Wago750_531>().unwrap();
        d.set_output(0, measured.is_some());           // DO1 = "sensor connected"
    }
    if let Some(i) = ao {
        let d = coupler.slot_devices[i].as_mut().unwrap().as_any_mut().downcast_mut::<Wago750_554>().unwrap();
        d.set_current(0, measured.unwrap_or(ElectricCurrent::new::<milliampere>(4.0)));   // mirror AI1 → AO1
    }

    if let Some(outputs) = handle.write_outputs() {
        coupler.output(BitSlice::<u8, Lsb0>::from_slice_mut(&mut outputs[c.start_rx..c.end_rx]))?;
        handle.send_outputs();
    }
}

Adding a new 750 module

BOILERPLATE.rs has a template with sections A–E: digital in, digital out, digital in+out, analog in and analog out. The steps are:

  1. Create the driver. Copy the boilerplate to wago_750_XXX.rs, keep the section that fits, and add pub mod wago_750_XXX; to wago_modules/mod.rs. Base it on the closest reference module: 750-531 for bit I/O, 750-455 for 16-bit inputs, 750-554 for 16-bit outputs.
  2. Index from your own offset. Always read and write at tx_bit_offset + … / rx_bit_offset + …. The slice you receive is the coupler's entire PDO.
  3. Set the ident. Set WAGO_750_XXX_PRODUCT_ID to the value the coupler reports in 0x9000+0x10·i :0A. initialize_modules prints it for unknown modules, and the examples print every module's ident.
  4. Register the module in wago_750_354.rs in both places:
    • get_modules(): an arm that sets name, has_tx and has_rx. If you get the flags wrong, the offset for that direction stays 0.
    • init_slot_modules(): an arm that creates the driver from WAGO_750_XXX_MODULE_IDENT.
  5. Don't add it to device_from_subdevice_identity. Slot modules aren't EtherCAT subdevices.

Limitations

These are true of the current code, and worth knowing before you rely on it:

  • One unsupported module breaks every module after it. When init_slot_modules meets an ident it has no driver for, it returns. Every module in a later slot gets no driver.
  • The 750-460 isn't registered. wago_750_460.rs exists, but neither get_modules() nor init_slot_modules() knows about it, so a 750-460 counts as unsupported (see the point above).
  • Errors are swallowed.
    • SDO failures inside init_slot_modules are ignored, and the affected offsets stay 0.
    • Errors from module input() / output() are thrown away by the coupler.
    • Some modules (for example the 750-1506) expect() on out-of-range bits and panic instead.
  • Wago750_354::input_len() / output_len() are always 0. set_module adds each module's tx_offset / rx_offset to the totals before those offsets have been worked out.
  • No config or reset. Modules are neither configured nor re-scanned. To change the module layout, restart the application.

Clone this wiki locally