Skip to content

Minimal Example: Wago Digital I O

Melvin Spieß edited this page Sep 8, 2026 · 1 revision

This guide walks you through reading digital inputs and driving digital outputs on Wago 750-series I/O modules using the QiTech framework over EtherCAT. By the end you will be able to monitor input channels and toggle output channels in real time from the built-in TUI.

The two modules covered are:

Module Type Channels Description
Wago 750-402 Digital Input 4 × 24 V DC (3 ms filter) Reads external signals
Wago 750-531 Digital Output 4 × 24 V DC (0.5 A/ch) Drives external loads

The architecture is the same in both cases - only the data direction, the property type, and a handful of API calls differ. This guide covers the shared structure first and calls out the differences inline.


Hardware Overview

Neither I/O module can operate on its own. Each must be plugged into a Wago 750-354 EtherCAT fieldbus coupler that bridges the EtherCAT network to the internal Wago module bus.

Components you need

Component Role
Wago 750-354 EtherCAT fieldbus coupler - bridges the EtherCAT network to the internal Wago module bus
Wago 750-402 and/or 750-531 Digital input / output module(s)
Wago 750-600 End module - terminates the module bus (required)
24 V DC power supply Powers the coupler, the module bus, and the field-side I/O
Ethernet cable Connects the coupler to your PC's EtherCAT-capable network interface

Physical wiring

 ┌──────────────────────────────────────────────────────────────────┐
 │  24 V DC PSU                                                     │
 │  ┌──────┐                                                        │
 │  │ +24V ├──────┬─── Coupler power input (24 V)                   │
 │  │      │      └─── I/O module field-side power input (24 V)     │
 │  │  0V  ├──────┬─── Coupler power input (0 V)                    │
 │  │      │      └─── I/O module field-side power input (0 V)      │
 │  └──────┘                                                        │
 └──────────────────────────────────────────────────────────────────┘

  Your PC                    Wago DIN-rail assembly
 ┌──────┐   Ethernet    ┌──────────┐  ┌──────────┐  ┌─────────┐
 │ NIC  ├──────────────►│ 750-354  │──│ I/O mod  │──│ 750-600 │
 │(ETH) │               │ Coupler  │  │          │  │ End Mod │
 └──────┘               └──────────┘  └──────────┘  └─────────┘

You can mount multiple I/O modules between the coupler and the end module. Slot numbering starts at 0 and follows the physical order after the coupler.

Key points:

  • The modules snap together on a standard DIN rail. Order matters: coupler first, then I/O modules, then the end module.
  • The coupler has two power sections: system power (for the coupler's logic) and field power (for the I/O). Consult the coupler datasheet for exact terminal assignments.
  • Both modules have integrated LEDs on each channel. An active input or output lights the corresponding LED automatically.
  • Make sure the Ethernet cable connects to the EtherCAT port on the coupler, not the configuration/web interface port (if present).

I/O wiring

Digital input (750-402):

  24 V ──── Switch / Sensor ──── Input terminal (e.g. I1)
                                  │
                            Reads HIGH when 24 V is present

Each input channel reads true when 24 V is present at the terminal and false when it is not. You can test with a simple wire jumper from the 24 V rail to an input terminal.

Digital output (750-531):

  Output terminal (e.g. O1) ──── Load ──── 0 V
                                  │
                            Conducts when the output is active

Each output channel sources up to 0.5 A at 24 V when active. The integrated LED lights up with no external wiring needed to verify operation.


Project Setup

Cargo.toml

[package]
name = "wago_digital_io"
version = "0.1.0"
edition = "2024"

[dependencies]
qitech_framework = { git = "https://github.com/qitechgmbh/qitech_framework" }
qitech_framework_tui = { git = "https://github.com/qitechgmbh/qitech_framework_tui" }
qitech_lib = { git = "https://github.com/qitechgmbh/qitech_lib" }
tokio = { version = "1", features = ["full"] }

What each dependency does:

  • qitech_framework - the core framework: provides the Machine trait and derive macro, the runtime, EtherCAT configuration, and the property mechanisms (StateProperty for inputs, ConfigProperty for outputs) for exposing values to the UI.
  • qitech_framework_tui - a terminal-based user interface that lets you inspect StateProperty values and change ConfigProperty values at runtime.
  • qitech_lib - the hardware abstraction layer. Contains typed drivers for EtherCAT devices like the Wago 750-354 coupler and the various I/O modules.
  • tokio - async runtime, required because the framework's entry point is asynchronous.

Machine Configuration File (YAML)

Every machine needs a YAML configuration file that declares its identity and the properties the TUI can display or modify.

Digital input example:

qms_version: 1.0
revision: 1
identification:
  name: wago_digital_input
  vendor_id: 0
  machine_id: 0
state:
  in1: !boolean
  in2: !boolean
  in3: !boolean
  in4: !boolean

Entries under state map to StateProperty<bool> values - the TUI renders them as read-only indicators that update in real time.

Digital output example:

qms_version: 1.0
revision: 1
identification:
  name: wago_digital_output
  vendor_id: 0
  machine_id: 0
config:
  led1_on: !boolean
  led2_on: !boolean
  led3_on: !boolean
  led4_on: !boolean

Entries under config map to ConfigProperty<bool> values - the TUI renders them as toggles the user can change at runtime.


Code Walkthrough

Both examples live in a single main.rs. The sections below present the digital input and digital output variants side by side, explaining the shared structure and noting where they diverge.

1 - Imports

// ── Framework types ──────────────────────────────────────────────
use qitech_framework::Machine;
use qitech_framework::TuiConfiguration;
use qitech_framework::machine::ActResult;
use qitech_framework::machine::BuildContext;
use qitech_framework::machine::BuildResult;
use qitech_framework::machine::Machine;
use qitech_framework::machine::MachineBuild;
use qitech_framework::machine_build;
use qitech_framework::run_with_tui;
use qitech_framework::runtime::EtherCATConfig;
use qitech_framework::runtime::RuntimeConfiguration;

// ── Property types (pick the one you need) ───────────────────────
use qitech_framework::machine::StateProperty;   // for digital inputs
use qitech_framework::machine::ConfigProperty;  // for digital outputs

// ── Hardware drivers ─────────────────────────────────────────────
use qitech_lib::ethercat_hal::devices::EthercatDevice;
use qitech_lib::ethercat_hal::devices::wago_modules::wago_750_354::Wago750_354;

// Import the module driver for the hardware you are using:
use qitech_lib::ethercat_hal::devices::wago_modules::wago_750_402::Wago750_402;
use qitech_lib::ethercat_hal::devices::wago_modules::wago_750_531::Wago750_531;

// Import the I/O trait that matches your module:
use qitech_lib::ethercat_hal::io::digital_input::DigitalInputDevice;
use qitech_lib::ethercat_hal::io::digital_output::DigitalOutputDevice;

// ── Standard library ─────────────────────────────────────────────
use std::cell::RefCell;
use std::rc::Rc;
use std::time::Duration; // needed for the act() signature (inputs)

The imports follow a consistent pattern regardless of the I/O direction:

  • Framework types - Machine, MachineBuild, BuildContext, etc. - define and run the machine.
  • Property type - StateProperty (read-only, machine-driven) for inputs; ConfigProperty (read-write, user-driven) for outputs.
  • Hardware drivers - the coupler (Wago750_354) is always needed. Then import the specific module driver and its matching I/O trait.
  • Standard library - Rc<RefCell<…>> for shared ownership of the coupler; Duration if you override act().

2 - The async entry point

#[tokio::main]
pub async fn main() {
    let config_rt = RuntimeConfiguration::new()
        .ethercat(EtherCATConfig::default())
        .machine::<MyMachine>();              // your machine type
    run_with_tui(config_rt, TuiConfiguration::default())
        .await
        .unwrap();
}

This is identical for inputs and outputs. Three things happen:

  1. RuntimeConfiguration::new() - creates a blank runtime configuration.
  2. .ethercat(EtherCATConfig::default()) - enables the EtherCAT subsystem with default settings (it will scan the bus and discover all connected devices).
  3. .machine::<MyMachine>() - registers your machine type. The framework will call its build() method during startup.

run_with_tui starts the framework with the terminal UI so you can monitor state properties or interact with config properties at runtime.

3 - The machine struct

The struct always holds a shared reference to the coupler and an array of properties - one per I/O channel. The I/O module itself is not stored as a separate field; it stays inside the coupler's slot_devices array and is accessed on demand via a helper method.

Digital input:

#[derive(Machine)]
pub struct Wago750_402Machine {
    coupler: Rc<RefCell<Wago750_354>>,
    inputs: [StateProperty<bool>; 4],
}

Digital output:

#[derive(Machine)]
pub struct Wago750_531Machine {
    coupler: Rc<RefCell<Wago750_354>>,
    leds: [ConfigProperty<bool>; 4],
}

impl Machine for Wago750_531Machine {}

The key difference is the property type:

Direction Property type Behaviour in the TUI
Input StateProperty<bool> Read-only indicator, updated by the machine
Output ConfigProperty<bool> Toggle, changed by the user

For outputs, the empty impl Machine for Wago750_531Machine {} is required by the trait system. The default lifecycle hooks are sufficient because output changes are driven by user callbacks, not the cyclic loop.

4 - The Machine implementation (inputs only)

Digital inputs override the act() lifecycle hook to poll the hardware on every cycle:

impl Machine for Wago750_402Machine {
    fn act(&mut self, _dt: Duration) -> ActResult {
        let coupler = self.coupler.borrow();
        let wago402 = Self::wago402(&coupler);
        for (port, state) in self.inputs.iter_mut().enumerate() {
            state.set(wago402.get_input(port).unwrap_or(false));
        }
        Ok(())
    }
}

On each cycle:

  1. self.coupler.borrow() - immutably borrows the coupler (reading inputs does not require &mut).
  2. Self::wago402(&coupler) - obtains an immutable reference to the 750-402 from the coupler's slot devices.
  3. wago402.get_input(port) - reads the current state of each channel. Returns Some(true) if 24 V is present, Some(false) if not, or None if the port is invalid.
  4. state.set(…) - pushes the value into the StateProperty, which updates the TUI display in real time.

The _dt: Duration parameter is the time elapsed since the last cycle. It is unused here but is available for time-dependent logic (debouncing, timing measurements, etc.).

Digital outputs do not need to override act(). Updates are driven by user interaction through the on_external_changed() callback (see section 6c below).

5 - Helper methods

Both examples need a helper to reach the I/O module inside the coupler's slot device array. The only difference is mutability.

Digital input (immutable access):

impl Wago750_402Machine {
    fn wago402(coupler: &Wago750_354) -> &Wago750_402 {
        coupler.slot_devices[0]
            .as_ref()
            .unwrap()
            .as_any()
            .downcast_ref::<Wago750_402>()
            .unwrap()
    }
}

Digital output (mutable access):

impl Wago750_531Machine {
    fn wago531(coupler: &mut Wago750_354) -> &mut Wago750_531 {
        coupler.slot_devices[0]
            .as_mut()
            .unwrap()
            .as_any_mut()
            .downcast_mut::<Wago750_531>()
            .unwrap()
    }

    fn update_led(&mut self, port: usize) -> ActResult {
        let value = self.leds[port].get();
        let mut coupler = self.coupler.borrow_mut();
        Self::wago531(&mut coupler).set_output(port, value);
        Ok(())
    }
}
Aspect Input helper Output helper
Borrow &Wago750_354 (immutable) &mut Wago750_354 (mutable)
Downcast as_any().downcast_ref() as_any_mut().downcast_mut()
Returns &Wago750_402 &mut Wago750_531

Both helpers' unwrap() calls are safe because the build() function validates at startup that the expected module type is present in the slot.

The output example adds an update_led() bridge method that reads the current config property value and writes it to the physical output. This is the method called by the on_external_changed() callback.

6 - Building the machine (MachineBuild implementation)

The build() function runs once at startup. It discovers hardware, initializes it, wires up properties, and returns the constructed machine. The structure is the same for inputs and outputs.

6a - Discover the coupler

let (coupler, coupler_addr) = ctx.find_ethercat_device_and_addr::<Wago750_354>(0)?;
let channel = ctx.get_ethercat_interface()?;
  • find_ethercat_device_and_addr::<Wago750_354>(0) - finds the first (0) Wago 750-354 coupler on the EtherCAT bus. Returns a shared reference and the device's EtherCAT address.
  • get_ethercat_interface() - obtains the low-level EtherCAT communication channel.

This code is identical for both directions.

6b - Initialize the coupler's module bus and validate the I/O module

{
    let mut c = coupler.borrow_mut();
    let modules = Wago750_354::initialize_modules(channel.clone(), coupler_addr)
        .expect("Failed to initialize coupler modules");
    for module in modules {
        c.set_module(module);
    }
    c.init_slot_modules(channel, coupler_addr);

    let slot_dev = c.slot_devices[0]
        .as_ref()
        .expect("No device in slot 0");
    assert!(
        slot_dev.as_any().is::<ExpectedModuleType>(),  // Wago750_402 or Wago750_531
        "Slot 0 is not the expected module type"
    );
}

Step by step:

  1. Borrow the coupler mutably inside a scoped block - the borrow must be released before proceeding.
  2. initialize_modules(…) - scans the coupler's internal module bus and returns detected modules (the I/O module, the 750-600 end module, etc.).
  3. set_module(module) - registers each module with the coupler driver.
  4. init_slot_modules(…) - final initialization handshake for every slot module.
  5. Validate the slot - confirms the expected module type is present. This makes later unwrap() calls in the helper methods safe for the program's lifetime.

Troubleshooting: If .as_ref() returns None (the expect fires), the module was not detected. Check physical wiring and make sure the end module is in place. If the assertion fails, the module in that slot is a different type - verify the slot order on your DIN rail.

6c - Create properties

This is where inputs and outputs diverge most.

Digital input - StateProperty:

let in1 = ctx.state::<bool>("in1").build()?;
let in2 = ctx.state::<bool>("in2").build()?;
let in3 = ctx.state::<bool>("in3").build()?;
let in4 = ctx.state::<bool>("in4").build()?;

State properties have no callback. Data flows from hardware → machine → TUI. The act() loop writes values into the properties; the TUI displays them as read-only indicators.

Digital output - ConfigProperty:

let led1 = ctx
    .config::<bool>("led1_on")
    .on_external_changed(|m: &mut Wago750_531Machine| m.update_led(0))
    .build()?;
let led2 = ctx
    .config::<bool>("led2_on")
    .on_external_changed(|m: &mut Wago750_531Machine| m.update_led(1))
    .build()?;
let led3 = ctx
    .config::<bool>("led3_on")
    .on_external_changed(|m: &mut Wago750_531Machine| m.update_led(2))
    .build()?;
let led4 = ctx
    .config::<bool>("led4_on")
    .on_external_changed(|m: &mut Wago750_531Machine| m.update_led(3))
    .build()?;

Config properties register an on_external_changed() callback that fires when the user toggles the value in the TUI. The callback calls update_led() to push the new value to the physical hardware. Data flows from TUI → machine → hardware.

StateProperty (input) ConfigProperty (output)
Declared with ctx.state::<T>(name) ctx.config::<T>(name)
YAML section state: config:
Data flow Hardware → machine → TUI TUI → machine → hardware
Callback None on_external_changed()
TUI appearance Read-only indicator Interactive toggle

6d - Return the constructed machine

// Digital input
Ok(Self {
    coupler,
    inputs: [in1, in2, in3, in4],
})

// Digital output
Ok(Self {
    coupler,
    leds: [led1, led2, led3, led4],
})

The coupler reference and the property array are assembled into the struct and returned. The framework takes ownership and begins the main loop - the TUI is now live.


Running the Example

  1. Wire everything up as shown in the wiring diagrams above.

  2. Power on the 24 V supply. The coupler's status LEDs should indicate it is on the bus.

  3. Identify your network interface. EtherCAT uses raw Ethernet frames, so you need the OS-level interface name (e.g. eth0, enp3s0). The framework's EtherCAT config may auto-detect it, or you may need to set it explicitly - consult the EtherCATConfig docs.

  4. Run the example (typically requires root or CAP_NET_RAW for raw socket access):

    sudo cargo run --release -p wago_digital_io
  5. Use the TUI:

    • Inputs: You will see read-only indicators (in1 through in4). Apply 24 V to an input terminal - the indicator updates in real time and the module's LED lights up.
    • Outputs: You will see toggles (led1_on through led4_on). Toggle any of them - the physical output switches and the module's LED lights up or turns off immediately.

Quick Reference: Input vs Output at a Glance

Aspect Digital Input (e.g. 750-402) Digital Output (e.g. 750-531)
Module driver Wago750_402 Wago750_531
I/O trait DigitalInputDevice → get_input() DigitalOutputDevice → set_output()
Data direction Read values from the module Write values to the module
Property type StateProperty<bool> (machine-driven, read-only in TUI) ConfigProperty<bool> (user-driven, writable from TUI)
YAML section state: config:
Update mechanism act() lifecycle hook polls on every cycle on_external_changed() callback fires on user toggle
Borrow type Immutable (borrow(), downcast_ref()) Mutable (borrow_mut(), downcast_mut())
Wiring 24 V → switch/sensor → input terminal Output terminal → load → 0 V