Skip to content

Wago DIO Separate

Hans Sebastian Pöhlmann edited this page Apr 22, 2026 · 17 revisions

WAGO EtherCAT Digital I/O Separate Terminals Setup

A complete guide to building a machine with separate digital input (750‑430) and digital output (750‑530) WAGO EtherCAT terminals using the QiTech Control framework.


Table of Contents

  1. Introduction
  2. Requirements
  3. Architecture Overview
  4. Hardware Setup
  5. Software Setup
  6. Backend Code Walkthrough
  7. Frontend Code Walkthrough
  8. Dashboard & Demo
  9. Troubleshooting
  10. References

1. Introduction

This guide demonstrates how to set up a WAGO EtherCAT I/O system using separate digital input and output terminals the 750‑430 (8‑channel DI) and 750‑530 (8‑channel DO) controlled through the QiTech machine framework.

What You Will Learn

Topic Description
Hardware wiring How to wire the coupler, power terminal, DI, and DO modules
Backend (Rust) Machine struct, EtherCAT device initialization, and state management
Frontend (React/TS) Control page UI, namespace store, and optimistic state updates
Dashboard Assigning devices and testing I/O through the QiTech Control UI

2. Requirements

Hardware

Component Model Purpose
EtherCAT Coupler WAGO 750‑354 Fieldbus coupler bridges Ethernet to the EtherCAT bus
Power Terminal WAGO 750‑602 Supplies field‑side power to the I/O modules
Digital Input WAGO 750‑430 8‑channel digital input, 24 V DC, 3.0 ms filter
Digital Output WAGO 750‑530 8‑channel digital output, 24 V DC, 0.5 A per channel
End Module WAGO 750‑600 Bus terminator required at the end of every WAGO I/O node
Power Supply 24 V DC / 6 A ( AC/DC adapter ) System power
Cabling Standard Ethernet (Cat 5e+), assorted 24 V wiring Network + power

Figure 24 V AC/DC Adapter

Figure 750‑430 Digital Input Module (rear label)

Figure 750‑530 Digital Output Module (rear label)

Figure 750‑600 End Module (rear label)

Software

See Device Example Basics for software prerequisites (Rust toolchain, Node.js, EtherCAT master setup).


3. Architecture Overview

Final assembly order (left to right):

[750-354 Coupler] → [750-602 Power Terminal] → [750-430 DI] → [750-530 DO] → [750-600 End]

Data flow:

  1. The backend communicates with the 750‑354 coupler over EtherCAT.
  2. The coupler discovers its attached modules (750‑430 + 750‑530) during initialization.
  3. Input states are read from the 750‑430 at ~30 Hz and emitted to the frontend via Socket.IO.
  4. Output commands from the frontend are sent as mutations to the backend, which writes to the 750‑530.

4. Hardware Setup

⚠️ Safety Warning Always disconnect power before wiring. Double‑check polarity before energizing. See Device Example Basics Safety for the full safe‑wiring procedure.

4.1 Wiring the Coupler (750‑354)

Connect the 24 V power supply to the coupler's power input terminals:

Wire Terminal Description
Red (+24V) +24V System power positive
Black (0V) 0V System power ground

Figure 750‑354 Coupler with power wiring

4.2 Integrating the Power Terminal (750‑602)

Slide the 750‑602 onto the right side of the 750‑354 coupler until it clicks and locks.

Wire the power terminal to distribute field‑side power:

Wire Terminal Description
Red (+24 V) from PSU Terminal 2 Field‑side power positive
Black (0 V) from PSU Terminal 3 Field‑side power ground
Red (+24 V) from coupler Terminal 6 System power bridge positive

Figure 750‑354 Coupler + 750‑602 Power Terminal wiring

4.3 Mounting the I/O Modules

Step 2 Attach the 750‑430 (Digital Input)

Slide the 750‑430 onto the right side of the 750‑602 until it locks.

Step 1 Attach the 750‑530 (Digital Output)

Slide the 750‑530 onto the right side of the 750‑430 until it locks.

Important: The 750‑530 must be in position 2 (second module). The backend validates this during startup.

Important: The 750‑430 must be in position 1 (first module after the power terminal). The backend initialization code expects this order.

Step 3 Attach the 750‑600 (End Module)

Slide the 750‑600 onto the right side of the 750‑530 until it locks. No wiring is needed for the end module.

Figure Fully assembled I/O node (top view)

4.5 Ethernet & Power‑On

  1. Connect the 24 V power supply to mains.
  2. Run an Ethernet cable from your Linux PC to the 750‑354 coupler (X1 IN port).
  3. Verify the coupler LEDs:
    • RUN solid green = running
    • I/O solid green = I/O data exchanging
    • ERR off = no errors

Figure Complete setup with Ethernet connected


5. Software Setup

See Device Example Basics to install and run the QiTech Control software, then return here for the device‑specific code and demo.


6. Backend Code Walkthrough

The backend follows the standard 4-file machine structure used across the QiTech codebase: mod.rs, new.rs, act.rs, and api.rs.

6.1 new.rs Hardware Initialization

new.rs implements MachineNewTrait and handles EtherCAT device discovery, module initialization, and channel binding. It runs once at startup.

use std::{sync::Arc, time::Instant};
use anyhow::Error;
use ethercat_hal::devices::EthercatDevice;
use ethercat_hal::{
    devices::{
        downcast_device,
        wago_750_354::{WAGO_750_354_IDENTITY_A, Wago750_354},
        wago_modules::{
            wago_750_430::{Wago750_430, Wago750_430Port},
            wago_750_530::{Wago750_530, Wago750_530Port},
        },
    },
    io::{
        digital_input::DigitalInput,
        digital_output::DigitalOutput,
    },
};
use smol::{block_on, lock::RwLock};
use super::{WagoDioSeparate, api::WagoDioSeparateNamespace};
use crate::{
    MachineNewHardware, MachineNewParams, MachineNewTrait,
    get_ethercat_device, validate_no_role_duplicates,
    validate_same_machine_identification_unique,
};

impl MachineNewTrait for WagoDioSeparate {
    fn new<'maindevice>(params: &MachineNewParams) -> Result<Self, Error> {
        let device_identification = params
            .device_group
            .iter()
            .map(|d| d.clone())
            .collect::<Vec<_>>();
        validate_same_machine_identification_unique(&device_identification)?;
        validate_no_role_duplicates(&device_identification)?;

        let hardware = match &params.hardware {
            MachineNewHardware::Ethercat(x) => x,
            _ => {
                return Err(anyhow::anyhow!(
                    "[{}::WagoDioSeparate::new] MachineNewHardware is not Ethercat",
                    module_path!()
                ));
            }
        };

        block_on(async {
            // Get the WAGO 750-354 coupler at role 0
            let _wago_750_354 = get_ethercat_device::<Wago750_354>(
                hardware,
                params,
                0,
                [WAGO_750_354_IDENTITY_A].to_vec(),
            )
            .await?;

            let modules = Wago750_354::initialize_modules(_wago_750_354.1).await?;

            let mut coupler = _wago_750_354.0.write().await;

            for module in modules {
                coupler.set_module(module);
            }

            coupler.init_slot_modules(_wago_750_354.1);

            // Get the WAGO 750-430 8CH DO module at position 1
            let dev = coupler.slot_devices.get(0).unwrap().clone().unwrap();
            let wago750_430: Arc<RwLock<Wago750_430>> = downcast_device::<Wago750_430>(dev).await?;

            // Get the WAGO 750-530 8CH DI module at position 2
            let dev = coupler.slot_devices.get(1).unwrap().clone().unwrap();
            let wago750_530: Arc<RwLock<Wago750_530>> = downcast_device::<Wago750_530>(dev).await?;


            let di1 = DigitalInput::new(wago750_430.clone(), Wago750_430Port::Port1);
            let di2 = DigitalInput::new(wago750_430.clone(), Wago750_430Port::Port2);
            let di3 = DigitalInput::new(wago750_430.clone(), Wago750_430Port::Port3);
            let di4 = DigitalInput::new(wago750_430.clone(), Wago750_430Port::Port4);
            let di5 = DigitalInput::new(wago750_430.clone(), Wago750_430Port::Port5);
            let di6 = DigitalInput::new(wago750_430.clone(), Wago750_430Port::Port6);
            let di7 = DigitalInput::new(wago750_430.clone(), Wago750_430Port::Port7);
            let di8 = DigitalInput::new(wago750_430.clone(), Wago750_430Port::Port8);

            let do1 = DigitalOutput::new(wago750_530.clone(), Wago750_530Port::Port1);
            let do2 = DigitalOutput::new(wago750_530.clone(), Wago750_530Port::Port2);
            let do3 = DigitalOutput::new(wago750_530.clone(), Wago750_530Port::Port3);
            let do4 = DigitalOutput::new(wago750_530.clone(), Wago750_530Port::Port4);
            let do5 = DigitalOutput::new(wago750_530.clone(), Wago750_530Port::Port5);
            let do6 = DigitalOutput::new(wago750_530.clone(), Wago750_530Port::Port6);
            let do7 = DigitalOutput::new(wago750_530.clone(), Wago750_530Port::Port7);
            let do8 = DigitalOutput::new(wago750_530.clone(), Wago750_530Port::Port8);

            drop(coupler);

            let (sender, receiver) = smol::channel::unbounded();
            let mut machine = Self {
                api_receiver: receiver,
                api_sender: sender,
                machine_identification_unique: params.get_machine_identification_unique(),
                namespace: WagoDioSeparateNamespace {
                    namespace: params.namespace.clone(),
                },
                last_state_emit: Instant::now(),
                inputs: [false; 8],
                led_on: [false; 8],
                main_sender: params.main_thread_channel.clone(),
                digital_input:  [di1, di2, di3, di4, di5, di6, di7, di8],
                digital_output: [do1, do2, do3, do4, do5, do6, do7, do8],
            };
            machine.emit_state();
            Ok(machine)
        })
    }
}

Step 1 Validate the device group

validate_same_machine_identification_unique(&device_identification)?;
validate_no_role_duplicates(&device_identification)?;

Ensures all devices assigned in the dashboard share the same serial number and have no duplicate roles. Fails early with a clear error if the assignment is invalid.

Step 2 Acquire the 750-354 coupler

let _wago_750_354 = get_ethercat_device::<Wago750_354>(
    hardware, params, 0, [WAGO_750_354_IDENTITY_A].to_vec(),
).await?;

Fetches the coupler handle from the EtherCAT bus by identity. The 0 is the expected position on the bus.

Step 3 Discover and register slot modules

let modules = Wago750_354::initialize_modules(_wago_750_354.1).await?;
let mut coupler = _wago_750_354.0.write().await;
for module in modules { coupler.set_module(module); }
coupler.init_slot_modules(_wago_750_354.1);

The coupler scans its physical slots left-to-right and registers whatever modules it finds. After this, coupler.slot_devices is populated by slot index.

Step 4 Downcast slot devices to concrete types

// Slot 0 → 750-530 (Digital Output)
let dev = coupler.slot_devices.get(0).unwrap().clone().unwrap();
let wago750_530: Arc<RwLock<Wago750_530>> =
    downcast_device::<Wago750_530>(dev).await?;
 
// Slot 1 → 750-430 (Digital Input)
let dev = coupler.slot_devices.get(1).unwrap().clone().unwrap();
let wago750_430: Arc<RwLock<Wago750_430>> =
    downcast_device::<Wago750_430>(dev).await?;

downcast_device casts the generic Arc<dyn EthercatDevice> to the concrete module type. If the physical module in a slot doesn't match the expected type, this returns an error at runtime.

⚠️ Physical slot order matters: position 1 = 750-430 (Di), position 2 = 750-430 (DO). Mount the modules in this exact order on the DIN rail.

Step 5 Create per-channel I/O handles

let di1 = DigitalInput::new(wago750_430.clone(), Wago750_430Port::Port1);
// ... di2 through di8
 
let do1 = DigitalOutput::new(wago750_530.clone(), Wago750_530Port::Port1);
// ... do2 through do8

Each DigitalInput / DigitalOutput holds a shared reference (Arc<RwLock<T>>) to its parent module and a port identifier. Multiple channel handles share the same module lock safely.

Step 6 Construct the machine and emit initial state

let mut machine = Self {
    digital_input:  [di1, di2, di3, di4, di5, di6, di7, di8],
    digital_output: [do1, do2, do3, do4, do5, do6, do7, do8],
    inputs: [false; 8],
    led_on: [false; 8],
    // ...
};
machine.emit_state();
Ok(machine)

All channels are bundled into fixed-size arrays. emit_state() is called immediately so any connected frontend receives the initial state without waiting for the first 30 Hz tick.


6.2 mod.rs Machine Struct & Core Methods

mod.rs is the root of the machine module. It defines the WagoDioSeparate struct, declares the submodules, and implements the core helper methods used by act.rs and api.rs.

use std::time::Instant;

use control_core::socketio::namespace::NamespaceCacheingLogic;
use ethercat_hal::io::{digital_input::DigitalInput,digital_output::DigitalOutput,
};
use smol::channel::{Receiver, Sender};
use self::api::{StateEvent, WagoDioSeparateEvents, WagoDioSeparateNamespace};
use crate::{
    AsyncThreadMessage, Machine, MachineMessage,
    VENDOR_QITECH, MACHINE_WAGO_DIO_SEPARATE_V1,
    machine_identification::{MachineIdentification, MachineIdentificationUnique},
};

pub mod act;
pub mod api;
pub mod new;

#[derive(Debug)]
pub struct WagoDioSeparate {
    pub api_receiver: Receiver<MachineMessage>,
    pub api_sender: Sender<MachineMessage>,
    pub machine_identification_unique: MachineIdentificationUnique,
    pub main_sender: Option<Sender<AsyncThreadMessage>>,
    pub namespace: WagoDioSeparateNamespace,
    pub last_state_emit: Instant,
    pub inputs: [bool; 8],
    pub led_on: [bool; 8],
    pub digital_input: [DigitalInput; 8], 
    pub digital_output: [DigitalOutput; 8], 
}

impl Machine for WagoDioSeparate {
    fn get_machine_identification_unique(&self) -> MachineIdentificationUnique {
        self.machine_identification_unique.clone()
    }
    fn get_main_sender(&self) -> Option<Sender<AsyncThreadMessage>> {
        self.main_sender.clone()
    }
}

impl WagoDioSeparate {
    pub const MACHINE_IDENTIFICATION: MachineIdentification = MachineIdentification {
        vendor: VENDOR_QITECH,
        machine: MACHINE_WAGO_DIO_SEPARATE_V1,
    };

    pub fn get_state(&self) -> StateEvent {
        StateEvent {
            inputs: self.inputs,
            led_on: self.led_on,
        }
    }

    pub fn emit_state(&mut self) {
        for (i, di) in self.digital_input.iter().enumerate() {
            self.inputs[i] = match di.get_value() {
                Ok(v) => v,
                Err(_) => false,
            };
        }
        let event = self.get_state().build();
        self.namespace.emit(WagoDioSeparateEvents::State(event));
    }

    pub fn set_led(&mut self, index: usize, on: bool) {
        if index < self.led_on.len() {
            self.led_on[index] = on;
            self.digital_output[index].set(on);
            self.emit_state();
        }
    }

    pub fn set_all_leds(&mut self, on: bool) {
        self.led_on = [on; 8];
        for dout in self.digital_output.iter() {
            dout.set(on);
        }
        self.emit_state();
    }
}

The struct:

#[derive(Debug)]
pub struct WagoDioSeparate {
    pub api_receiver: Receiver<MachineMessage>,
    pub api_sender: Sender<MachineMessage>,
    pub machine_identification_unique: MachineIdentificationUnique,
    pub main_sender: Option<Sender<AsyncThreadMessage>>,
    pub namespace: WagoDioSeparateNamespace,
    pub last_state_emit: Instant,
    pub inputs: [bool; 8],
    pub led_on: [bool; 8],
    pub digital_input: [DigitalInput; 8],
    pub digital_output: [DigitalOutput; 8],
}
Field Purpose
api_receiver / api_sender Channel for receiving mutations from the HTTP API
namespace Socket.IO namespace handle for pushing state to the frontend
last_state_emit Timestamp used to throttle state emission to 30 Hz
inputs Cached readings from the 750-430 DI module, updated on every emit
led_on Commanded output state for the 750-530 DO module
digital_input 8 hardware channel handles bound to the 750-430 ports
digital_output 8 hardware channel handles bound to the 750-530 ports

emit_state read inputs and broadcast state

Iterates all 8 DI channels, updates self.inputs, then emits the full StateEvent over Socket.IO. Errors on individual channels default to false rather than crashing.

pub fn emit_state(&mut self) {
    for (i, di) in self.digital_input.iter().enumerate() {
        self.inputs[i] = match di.get_value() {
            Ok(v) => v,
            Err(_) => false,
        };
    }
    let event = self.get_state().build();
    self.namespace.emit(WagoDioSeparateEvents::State(event));
}

set_led toggle a single output

Bounds-checks the index, updates led_on, writes directly to the hardware channel, then re-emits state so the frontend reflects the change immediately.

pub fn set_led(&mut self, index: usize, on: bool) {
    if index < self.led_on.len() {
        self.led_on[index] = on;
        self.digital_output[index].set(on);
        self.emit_state();
    }
}

set_all_leds set all 8 outputs at once

Fills the entire led_on array, iterates all DO channels, and re-emits state once.

pub fn set_all_leds(&mut self, on: bool) {
    self.led_on = [on; 8];
    for dout in self_.digital_output.iter() {
        dout.set(on);
    }
    self.emit_state();
}

6.3 act.rs The Main Loop

act.rs implements MachineAct, which is called on every iteration of the machine's async thread. It has two responsibilities: processing incoming messages and emitting state at a fixed rate.

use super::WagoDioSeparate;
use crate::{MachineAct, MachineMessage, MachineValues};
use std::time::{Duration, Instant};

impl MachineAct for WagoDioSeparate {
   fn act(&mut self, now: Instant) {
       if let Ok(msg) = self.api_receiver.try_recv() {
           self.act_machine_message(msg);
       }
       if now.duration_since(self.last_state_emit) > Duration::from_secs_f64(1.0 / 30.0) {
           self.emit_state();
           self.last_state_emit = now;
       }
   }

   fn act_machine_message(&mut self, msg: MachineMessage) {
       match msg {
           MachineMessage::SubscribeNamespace(namespace) => {
               self.namespace.namespace = Some(namespace);
               self.emit_state();
           }
           MachineMessage::UnsubscribeNamespace => {
               self.namespace.namespace = None;
           }
           MachineMessage::HttpApiJsonRequest(value) => {
               use crate::MachineApi;
               let _res = self.api_mutate(value);
           }
           MachineMessage::RequestValues(sender) => {
               sender
                   .send_blocking(MachineValues {
                       state: serde_json::to_value(self.get_state())
                           .expect("Failed to serialize state"),
                       live_values: serde_json::Value::Null,
                   })
                   .expect("Failed to send values");
               sender.close();
           }
       }
   }
}

act called every tick

fn act(&mut self, now: Instant) {
    if let Ok(msg) = self.api_receiver.try_recv() {
        self.act_machine_message(msg);
    }
    if now.duration_since(self.last_state_emit) > Duration::from_secs_f64(1.0 / 30.0) {
        self.emit_state();
        self.last_state_emit = now;
    }
}

try_recv is non-blocking if no message is queued the loop continues immediately. State is only emitted when 33ms have elapsed since the last emit, giving a steady 30 Hz update rate.

act_machine_message message dispatch

Message Action
SubscribeNamespace Attaches the Socket.IO namespace and immediately pushes current state to the new subscriber
UnsubscribeNamespace Detaches the namespace no more events are sent
HttpApiJsonRequest Forwards the JSON body to api_mutate for deserialization and dispatch
RequestValues Serializes the current state and sends it over a one-shot blocking channel, then closes it
MachineMessage::RequestValues(sender) => {
    sender.send_blocking(MachineValues {
        state: serde_json::to_value(self.get_state())
            .expect("Failed to serialize state"),
        live_values: serde_json::Value::Null,
    }).expect("Failed to send values");
    sender.close();
}

live_values is Null here because this machine has no continuous analog readings only discrete boolean states.


6.4 api.rs Events, Mutations & Namespace

api.rs defines the public API surface of the machine: what state it emits, what commands it accepts, and how the Socket.IO namespace caches events.

use super::WagoDioSeparate;
use crate::{MachineApi, MachineMessage};
use control_core::socketio::{
    event::{Event, GenericEvent},
    namespace::{
        CacheFn, CacheableEvents, Namespace, NamespaceCacheingLogic,
        cache_first_and_last_event,
    },
};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::sync::Arc;

#[derive(Serialize, Debug, Clone)]
pub struct StateEvent {
    pub inputs: [bool; 8],
    pub led_on: [bool; 8],
}

impl StateEvent {
    pub fn build(&self) -> Event<Self> {
        Event::new("StateEvent", self.clone())
    }
}

pub enum WagoDioSeparateEvents {
    State(Event<StateEvent>),
}

#[derive(Deserialize)]
#[serde(tag = "action", content = "value")]
pub enum Mutation {
    SetLed { index: usize, on: bool },
    SetAllLeds { on: bool },
}

#[derive(Debug, Clone)]
pub struct WagoDioSeparateNamespace {
    pub namespace: Option<Namespace>,
}

impl NamespaceCacheingLogic<WagoDioSeparateEvents> for WagoDioSeparateNamespace {
    fn emit(&mut self, events: WagoDioSeparateEvents) {
        let event = Arc::new(events.event_value());
        let buffer_fn = events.event_cache_fn();
        if let Some(ns) = &mut self.namespace {
            ns.emit(event, &buffer_fn);
        }
    }
}

impl CacheableEvents<WagoDioSeparateEvents> for WagoDioSeparateEvents {
    fn event_value(&self) -> GenericEvent {
        match self {
            WagoDioSeparateEvents::State(event) => event.clone().into(),
        }
    }
    fn event_cache_fn(&self) -> CacheFn {
        cache_first_and_last_event()
    }
}

impl MachineApi for WagoDioSeparate {
    fn api_get_sender(&self) -> smol::channel::Sender<MachineMessage> {
        self.api_sender.clone()
    }
    fn api_mutate(&mut self, request_body: Value) -> Result<(), anyhow::Error> {
        let mutation: Mutation = serde_json::from_value(request_body)?;
        match mutation {
            Mutation::SetLed { index, on } => self.set_led(index, on),
            Mutation::SetAllLeds { on } => self.set_all_leds(on),
        }
        Ok(())
    }
    fn api_event_namespace(&mut self) -> Option<Namespace> {
        self.namespace.namespace.clone()
    }
}

StateEvent emitted to the frontend at 30 Hz

#[derive(Serialize, Debug, Clone)]
pub struct StateEvent {
    pub inputs: [bool; 8],  // live readings from 750-430
    pub led_on: [bool; 8],  // commanded output state on 750-530
}

Built and sent via StateEvent::build() which wraps it in an Event<StateEvent> with the name "StateEvent".

Mutation received from the frontend

#[derive(Deserialize)]
#[serde(tag = "action", content = "value")]
pub enum Mutation {
    SetLed { index: usize, on: bool },
    SetAllLeds { on: bool },
}

The #[serde(tag = "action", content = "value")] attribute means the JSON must use this exact shape:

{ "action": "SetLed", "value": { "index": 3, "on": true } }
{ "action": "SetAllLeds", "value": { "on": false } }

api_mutate mutation dispatch

fn api_mutate(&mut self, request_body: Value) -> Result<(), anyhow::Error> {
    let mutation: Mutation = serde_json::from_value(request_body)?;
    match mutation {
        Mutation::SetLed { index, on } => self.set_led(index, on),
        Mutation::SetAllLeds { on }    => self.set_all_leds(on),
    }
    Ok(())
}

Deserializes the raw JSON body into a Mutation and dispatches to the appropriate method in mod.rs. Returns an error if the JSON doesn't match any known variant.

Event caching cache_first_and_last_event

fn event_cache_fn(&self) -> CacheFn {
    cache_first_and_last_event()
}

When a new client subscribes, it receives both the first and the most recent cached state event. This ensures the frontend hydrates correctly even if it connects mid-session without waiting for the next 30 Hz tick.


6.5 Registration & Identification

Before a machine can be instantiated, it must be registered in three places: the constants file (lib.rs), the machine identification (machine_identification.rs), and the machine registry (registry.rs).

6.5.1 lib.rs Machine ID Constant

Every machine has a unique u16 constant that identifies it on the bus. Add this alongside the other machine constants:

pub const MACHINE_WAGO_DIO_SEPARATE_V1: u16 = 0x0045;

This value is stored in the device's EEPROM and read back at runtime to match physical hardware to its machine implementation.

6.5.2 machine_identification.rs Slug Registration

Import:

use crate::MACHINE_WAGO_DIO_SEPARATE_V1;

The slug() method maps a machine ID to a human-readable string used in logs and the dashboard. Add the new arm to the match block:

x if x == MACHINE_WAGO_DIO_SEPARATE_V1 => "wago_dio_separate".to_string(),

This slug appears in error messages and debug traces, making it easy to identify which machine is involved.

6.5.3 minimal_machines/mod.rs Module Declaration

The machine module must be declared so the compiler knows it exists:

// machines/src/minimal_machines/mod.rs
pub mod wago_dio_separate;

6.5.4 registry.rs Machine Registry

The registry is a global lazy_static map that associates each MachineIdentification with a constructor closure. Two lines were added:

Import:

// machines/src/registry.rs
use crate::minimal_machines::wago_dio_separate::WagoDioSeparate;

Registration:

mc.register::<WagoDioSeparate>(vec![WagoDioSeparate::MACHINE_IDENTIFICATION]);

register inserts a (MachineIdentification, MachineNewClosure) pair into a HashMap. When the framework discovers a device group on the bus whose MachineIdentification matches, it calls the stored closure to construct the machine.

The MACHINE_IDENTIFICATION constant is defined in mod.rs:

pub const MACHINE_IDENTIFICATION: MachineIdentification = MachineIdentification {
    vendor: VENDOR_QITECH,
    machine: MACHINE_WAGO_DIO_SEPARATE_V1,
};

vendor and machine together form a unique key 0x0001 (QiTech) + 0x0045 (DIO Separate V1).


7. Frontend Code Walkthrough

The frontend consists of four files organized under wagodioseparate/:

File Role
wagoDioSeparateNamespace.ts Zod schema, Zustand store, Socket.IO event handler
useWagoDioSeparate.ts React hook serial parsing, optimistic state, mutations
WagoDioSeparateControlPage.tsx UI input badges + output toggle buttons
WagoDioSeparatePage.tsx Page shell topbar with navigation tabs

7.1 wagoDioSeparateNamespace.ts Schema, Store & Event Handler

This file is the bridge between the backend's Socket.IO events and the React UI. It has three responsibilities: validating incoming data, storing it, and dispatching it.

import { StoreApi } from "zustand";
import { create } from "zustand";
import { z } from "zod";
import {
    EventHandler,
    eventSchema,
    Event,
    handleUnhandledEventError,
    NamespaceId,
    createNamespaceHookImplementation,
    ThrottledStoreUpdater,
} from "@/client/socketioStore";
import { MachineIdentificationUnique } from "@/machines/types";

// 1. Event Schema
export const stateEventDataSchema = z.object({
    inputs: z.array(z.boolean()).length(8),  // live readings from 750-430
    led_on: z.array(z.boolean()).length(8),  // output state from 750-530
});

export const stateEventSchema = eventSchema(stateEventDataSchema);

export type StateEvent = z.infer<typeof stateEventDataSchema>;

// 2. Store
export type WagoDioSeparateNamespaceStore = {
    state: StateEvent | null;
};

export const createWagoDioSeparateNamespaceStore =
    (): StoreApi<WagoDioSeparateNamespaceStore> =>
        create<WagoDioSeparateNamespaceStore>(() => ({
            state: null,
        }));

// 3. Message Handler
export function wagoDioSeparateMessageHandler(
    store: StoreApi<WagoDioSeparateNamespaceStore>,
    throttledUpdater: ThrottledStoreUpdater<WagoDioSeparateNamespaceStore>,
): EventHandler {
    return (event: Event<any>) => {
        const updateStore = (
            updater: (state: WagoDioSeparateNamespaceStore) => WagoDioSeparateNamespaceStore,
        ) => throttledUpdater.updateWith(updater);

        try {
            if (event.name === "StateEvent") {
                const parsed = stateEventSchema.parse(event);
                updateStore(() => ({ state: parsed.data }));
            } else {
                handleUnhandledEventError(event.name);
            }
        } catch (error) {
            console.error(`Error processing ${event.name}:`, error);
            throw error;
        }
    };
}

// 4. Namespace Hook
const useWagoDioSeparateNamespaceImplementation =
    createNamespaceHookImplementation<WagoDioSeparateNamespaceStore>({
        createStore: createWagoDioSeparateNamespaceStore,
        createEventHandler: wagoDioSeparateMessageHandler,
    });

export function useWagoDioSeparateNamespace(
    machine_identification_unique: MachineIdentificationUnique,
): WagoDioSeparateNamespaceStore {
    const namespaceId: NamespaceId = {
        type: "machine",
        machine_identification_unique,
    };

    return useWagoDioSeparateNamespaceImplementation(namespaceId);
}

Schema

export const stateEventDataSchema = z.object({
    inputs: z.array(z.boolean()).length(8),  // live readings from 750-430
    led_on: z.array(z.boolean()).length(8),  // output state from 750-530
});
export type StateEvent = z.infer<typeof stateEventDataSchema>;

The schema uses Zod to define exactly what the backend is allowed to send. z.array(z.boolean()).length(8) means: an array of exactly 8 booleans no more, no less. If the backend sends 7 values, or sends a string instead of a boolean, Zod will throw a validation error before the bad data ever reaches the store or the UI. The StateEvent type is then inferred directly from the schema, so the TypeScript types and the runtime validation are always in sync there is no risk of them drifting apart.

Store

export type WagoDioSeparateNamespaceStore = {
    state: StateEvent | null;
};
 
export const createWagoDioSeparateNamespaceStore =
    (): StoreApi<WagoDioSeparateNamespaceStore> =>
        create<WagoDioSeparateNamespaceStore>(() => ({ state: null }));

The store is a Zustand store with a single field: state. It starts as null because no event has been received yet. The UI handles this initial null with a safe fallback (see section 8.3). The store is created via a factory function createWagoDioSeparateNamespaceStore rather than being a module-level singleton this is important because multiple instances of the same machine (different serial numbers) each need their own independent store.

Message Handler

export function wagoDioSeparateMessageHandler(
    store: StoreApi<WagoDioSeparateNamespaceStore>,
    throttledUpdater: ThrottledStoreUpdater<WagoDioSeparateNamespaceStore>,
): EventHandler {
    return (event: Event<any>) => {
        const updateStore = (
            updater: (state: WagoDioSeparateNamespaceStore) => WagoDioSeparateNamespaceStore,
        ) => throttledUpdater.updateWith(updater);
 
        try {
            if (event.name === "StateEvent") {
                const parsed = stateEventSchema.parse(event);
                updateStore(() => ({ state: parsed.data }));
            } else {
                handleUnhandledEventError(event.name);
            }
        } catch (error) {
            console.error(`Error processing ${event.name}:`, error);
            throw error;
        }
    };
}

The message handler is a closure that receives every raw Socket.IO event and decides what to do with it. The routing is done by event.name currently only "StateEvent" is handled. Any other event name calls handleUnhandledEventError, which logs a warning so you immediately know if the backend starts emitting events the frontend doesn't know about.

Notice that updates don't go directly to the Zustand store they go through throttledUpdater.updateWith(). The backend emits at 30 Hz, which means 30 React re-renders per second if every event triggers one. The ThrottledStoreUpdater batches these updates and only triggers a re-render when necessary, keeping the UI smooth.

Namespace Hook

const useWagoDioSeparateNamespaceImplementation =
    createNamespaceHookImplementation<WagoDioSeparateNamespaceStore>({
        createStore: createWagoDioSeparateNamespaceStore,
        createEventHandler: wagoDioSeparateMessageHandler,
    });
 
export function useWagoDioSeparateNamespace(
    machine_identification_unique: MachineIdentificationUnique,
): WagoDioSeparateNamespaceStore {
    const namespaceId: NamespaceId = {
        type: "machine",
        machine_identification_unique,
    };
    return useWagoDioSeparateNamespaceImplementation(namespaceId);
}

createNamespaceHookImplementation wires together the store factory and the message handler into a reusable React hook. The namespaceId tells the Socket.IO client which namespace to subscribe to it uses both the machine type and the serial number, so two machines of the same type with different serials get completely separate subscriptions and stores.


7.2 useWagoDioSeparate.ts Hook

This is the main data layer consumed by the control page. It handles four things: parsing the serial number from the URL, subscribing to the namespace, managing optimistic state, and dispatching mutations to the backend.

import { toastError } from "@/components/Toast";
import { useStateOptimistic } from "@/lib/useStateOptimistic";
import { wagoDioSeparateSerialRoute } from "@/routes/routes";
import { MachineIdentificationUnique } from "@/machines/types";
import {
    useWagoDioSeparateNamespace,
    StateEvent,
} from "./wagoDioSeparateNamespace";
import { useMachineMutate } from "@/client/useClient";
import { produce } from "immer";
import { useEffect, useMemo } from "react";
import { wagoDioSeparate } from "@/machines/properties";
import { z } from "zod";

export function useWagoDioSeparate() {
    // 1. Route params — serial comes from the URL (e.g. /machines/wagodioseparate/42)
    const { serial: serialString } = wagoDioSeparateSerialRoute.useParams();

    // 2. Build MachineIdentificationUnique
    const machineIdentification: MachineIdentificationUnique = useMemo(() => {
        const serial = parseInt(serialString);

        if (isNaN(serial)) {
            toastError(
                "Invalid Serial Number",
                `"${serialString}" is not a valid serial number.`,
            );
            return {
                machine_identification: { vendor: 0, machine: 0 },
                serial: 0,
            };
        }

        return {
            machine_identification: wagoDioSeparate.machine_identification,
            serial,
        };
    }, [serialString]);

    // 3. Subscribe to backend state via WebSocket namespace
    const { state } = useWagoDioSeparateNamespace(machineIdentification);

    // 4. Optimistic state — instant local updates, rolled back by next real event
    const stateOptimistic = useStateOptimistic<StateEvent>();

    useEffect(() => {
        if (state) stateOptimistic.setReal(state);
    }, [state, stateOptimistic]);

    // 5. Mutation sender — sends JSON to the backend api_mutate handler
    const { request: sendMutation } = useMachineMutate(
        z.object({
            action: z.string(),
            value: z.any(),
        }),
    );

    // Helper: apply an optimistic update locally and fire a server request
    const updateStateOptimistically = (
        producer: (current: StateEvent) => void,
        serverRequest?: () => void,
    ) => {
        const currentState = stateOptimistic.value;
        if (currentState)
            stateOptimistic.setOptimistic(produce(currentState, producer));
        serverRequest?.();
    };

    // 6. Mutation actions — one per Rust `Mutation` variant in api.rs
    const setLed = (index: number, on: boolean) => {
        updateStateOptimistically(
            (current) => {
                current.led_on[index] = on;
            },
            () =>
                sendMutation({
                    machine_identification_unique: machineIdentification,
                    data: { action: "SetLed", value: { index, on } },
                }),
        );
    };

    const setAllLeds = (on: boolean) => {
        updateStateOptimistically(
            (current) => {
                current.led_on = Array(8).fill(on);
            },
            () =>
                sendMutation({
                    machine_identification_unique: machineIdentification,
                    data: { action: "SetAllLeds", value: { on } },
                }),
        );
    };

    return {
        state: stateOptimistic.value,
        setLed,
        setAllLeds,
    };
}

Serial Parsing

const { serial: serialString } = wagoDioSeparateSerialRoute.useParams();
 
const machineIdentification: MachineIdentificationUnique = useMemo(() => {
    const serial = parseInt(serialString);
    if (isNaN(serial)) {
        toastError(
            "Invalid Serial Number",
            `"${serialString}" is not a valid serial number.`,
        );
        return { machine_identification: { vendor: 0, machine: 0 }, serial: 0 };
    }
    return {
        machine_identification: wagoDioSeparate.machine_identification,
        serial,
    };
}, [serialString]);

The serial number comes from the URL as a string (e.g. /wagodioseparate/42). It is parsed to an integer with parseInt. If parsing failsfor example if someone navigates to /wagodioseparate/abc a toast notification is shown and a zero-value identification is returned, which will simply result in no data being displayed rather than a crash. The useMemo ensures the identification object is only recomputed when the URL parameter actually changes, not on every render.

Optimistic State

const { state } = useWagoDioSeparateNamespace(machineIdentification);
const stateOptimistic = useStateOptimistic<StateEvent>();
 
useEffect(() => {
    if (state) stateOptimistic.setReal(state);
}, [state, stateOptimistic]);

useWagoDioSeparateNamespace subscribes to the Socket.IO namespace and returns the latest StateEvent from the Zustand store. Every time a new event arrives, the useEffect calls stateOptimistic.setReal(state) to sync the real server state into the optimistic state manager.

useStateOptimistic keeps two parallel copies of the state:

  • Real state the last confirmed state from the server
  • Optimistic statea locally-modified version applied immediately when the user clicks a button

The UI always reads from the optimistic state, so it feels instant.

Mutation Helper

const updateStateOptimistically = (
    producer: (current: StateEvent) => void,
    serverRequest?: () => void,
) => {
    const currentState = stateOptimistic.value;
    if (currentState)
        stateOptimistic.setOptimistic(produce(currentState, producer));
    serverRequest?.();
};

This helper is the core of the optimistic update pattern. It takes two arguments:

  • A producer function an Immer mutation that describes how the state should change
  • A serverRequest the actual HTTP call to send to the backend

It applies the producer to the current state immediately using Immer's produce, which creates a new immutable state object without mutating the original. Then it fires the server request. If the server succeeds, the next Socket.IO event will confirm the change. If it fails, the next real state from the server will overwrite the optimistic one, effectively rolling back the UI.

setLed and setAllLeds

const setLed = (index: number, on: boolean) => {
    updateStateOptimistically(
        (current) => { current.led_on[index] = on; },
        () => sendMutation({
            machine_identification_unique: machineIdentification,
            data: { action: "SetLed", value: { index, on } },
        }),
    );
};
 
const setAllLeds = (on: boolean) => {
    updateStateOptimistically(
        (current) => { current.led_on = Array(8).fill(on); },
        () => sendMutation({
            machine_identification_unique: machineIdentification,
            data: { action: "SetAllLeds", value: { on } },
        }),
    );
};

setLed updates a single index in led_on and sends { action: "SetLed", value: { index, on } } to the backend. setAllLeds replaces the entire led_on array with Array(8).fill(on) and sends { action: "SetAllLeds", value: { on } }. Both JSON shapes match exactly what the Mutation enum in api.rs expects the #[serde(tag = "action", content = "value")] attribute on the Rust side.

The mutation schema is validated with Zod before sending:

const { request: sendMutation } = useMachineMutate(
    z.object({
        action: z.string(),
        value: z.any(),
    }),
);

7.3 WagoDioSeparateControlPage.tsx UI

The control page is a pure presentational component. It gets all its data and callbacks from useWagoDioSeparate and renders two cards.

import { Page } from "@/components/Page";
import { ControlCard } from "@/control/ControlCard";
import { ControlGrid } from "@/control/ControlGrid";
import { Label } from "@/control/Label";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import React from "react";
import { useWagoDioSeparate } from "./useWagoDioSeparate";

export function WagoDioSeparateControlPage() {
    const { state, setLed, setAllLeds } = useWagoDioSeparate();

    // Safe default so the UI renders before the first server event arrives
    const safeState = state ?? {
        inputs: Array(8).fill(false),
        led_on: Array(8).fill(false),
    };

    return (
        <Page>
            <ControlGrid columns={2}>
                {/* Digital Inputs (read-only) */}
                <ControlCard title="Digital Inputs (750-430)">
                    <div className="grid grid-cols-2 gap-4">
                        {safeState.inputs.map((input, index) => (
                            <Label key={index} label={`Input ${index + 1}`}>
                                <Badge variant={input ? "outline" : "destructive"}>
                                    {input ? "ON" : "OFF"}
                                </Badge>
                            </Label>
                        ))}
                    </div>
                </ControlCard>

                {/* Digital Outputs (interactive) */}
                <ControlCard title="Digital Outputs (750-530)">
                    <div className="grid grid-cols-2 gap-4">
                        {safeState.led_on.map((on, index) => (
                            <Label key={index} label={`Output ${index + 1}`}>
                                <Button
                                    variant={on ? "default" : "outline"}
                                    onClick={() => setLed(index, !on)}
                                >
                                    {on ? "ON" : "OFF"}
                                </Button>
                            </Label>
                        ))}
                    </div>
                    <div className="flex gap-2 mt-4">
                        <Button onClick={() => setAllLeds(true)}>All ON</Button>
                        <Button variant="outline" onClick={() => setAllLeds(false)}>
                            All OFF
                        </Button>
                    </div>
                </ControlCard>
            </ControlGrid>
        </Page>
    );
}

Safe State Fallback

const { state, setLed, setAllLeds } = useWagoDioSeparate();
 
const safeState = state ?? {
    inputs: Array(8).fill(false),
    led_on: Array(8).fill(false),
};

state is null until the first Socket.IO event arrives. Rather than adding null checks throughout the JSX, a single fallback object is created with all channels set to false. This means the UI renders immediately on mount showing everything as OFF, then updates as soon as the first real state arrives.

Digital Inputs Card (read-only)

<ControlCard title="Digital Inputs (750-430)">
    <div className="grid grid-cols-2 gap-4">
        {safeState.inputs.map((input, index) => (
            <Label key={index} label={`Input ${index + 1}`}>
                <Badge variant={input ? "outline" : "destructive"}>
                    {input ? "ON" : "OFF"}
                </Badge>
            </Label>
        ))}
    </div>
</ControlCard>

The 8 input channels are mapped into a 2-column grid. Each renders as a Badge componentoutline variant when the input is ON (24V signal detected on the 750-430 terminal), destructive (red) when OFF. There is no onClick handler inputs are purely reactive and cannot be changed from the UI.

Digital Outputs Card (interactive)

<ControlCard title="Digital Outputs (750-530)">
    <div className="grid grid-cols-2 gap-4">
        {safeState.led_on.map((on, index) => (
            <Label key={index} label={`Output ${index + 1}`}>
                <Button
                    variant={on ? "default" : "outline"}
                    onClick={() => setLed(index, !on)}
                >
                    {on ? "ON" : "OFF"}
                </Button>
            </Label>
        ))}
    </div>
    <div className="flex gap-2 mt-4">
        <Button onClick={() => setAllLeds(true)}>All ON</Button>
        <Button variant="outline" onClick={() => setAllLeds(false)}>All OFF</Button>
    </div>
</ControlCard>

The 8 output channels are also mapped into a 2-column grid, but each renders as a Button. The button variant switches between default (filled, when ON) and outline (when OFF), giving instant visual feedback. Clicking calls setLed(index, !on) which toggles the current state because of the optimistic update, the button switches appearance immediately without waiting for the server round-trip.

The two bulk buttons at the bottom call setAllLeds(true) and setAllLeds(false), which set all 8 outputs simultaneously in a single mutation.


7.4 WagoDioSeparatePage.tsx Page Shell

import { Topbar } from "@/components/Topbar";
import { wagoDioSeparateSerialRoute } from "@/routes/routes";
import React from "react";

export function WagoDioSeparatePage() {
    const { serial } = wagoDioSeparateSerialRoute.useParams();
    return (
        <Topbar
            pathname={`/_sidebar/machines/wagodioseparate/${serial}`}
            items={[
                {
                    link: "control",
                    activeLink: "control",
                    title: "Control",
                    icon: "lu:CirclePlay",
                },
            ]}
        />
    );
}

This is the outermost page component. It does not render any machine UI itself it only renders the Topbar, which provides the navigation tab bar at the top of the page. The serial number is extracted from the URL and embedded into the pathname so the topbar knows which machine instance it belongs to.

The items array defines the available tabs. Currently only Control is defined, which links to WagoDioSeparateControlPage. To add more tabs in the future (e.g. a diagnostics or settings tab), you would add more entries to this array and register the corresponding route and component.


7.5 properties.ts Machine Registration

The four files under wagodioseparate/ define the UI, but on their own the frontend has no idea the machine exists. electron/src/machines/properties.ts is the single source of truth that the dashboard uses to discover machines, render their assignment dialogs, and validate device-role matching against the devices reported by the backend. Add a new export for the DIO Separate machine:

export const wagoDioSeparate: MachineProperties = {
    name: "WAGO DIO Separate",
    version: "V1",
    slug: "wagodioseparate",
    icon: "lu:ToggleRight",
    machine_identification: {
        vendor: VENDOR_QITECH,
        machine: 0x0045, // must match MACHINE_WAGO_DIO_SEPARATE_V1 in lib.rs
    },
    device_roles: [
        {
            role: 0,
            role_label: "WAGO 750-354 Bus Coupler",
            allowed_devices: [
                { vendor_id: 0x21, product_id: 0x07500354, revision: 0x2 },
            ],
        },
        {
            role: 1,
            role_label: "WAGO 750-430 8CH Digital Input",
            allowed_devices: [
                { vendor_id: 0x21, product_id: 0x07504305, revision: 0x1 },
            ],
        },
        {
            role: 2,
            role_label: "WAGO 750-530 8CH Digital Output",
            allowed_devices: [
                { vendor_id: 0x21, product_id: 0x07505305, revision: 0x1 },
            ],
        },
    ],
};

A few points worth calling out:

  • machine_identification.machine must be the same u16 as the MACHINE_WAGO_DIO_SEPARATE_V1 constant defined in the backend's lib.rs (section 6.5.1). If the two drift apart, the dashboard will not recognize the backend's machine and the assignment dialog will refuse the device group.
  • slug is the URL segment used by the router (section 7.6) and must match the path on the serial route.
  • device_roles mirrors the roles the backend expects in new.rs (section 6.1). Each role index here corresponds to the role field the backend reads when it validates the device group on the bus. allowed_devices is a whitelist of (vendor_id, product_id, revision) triples the dashboard uses to populate the dropdown in the assignment dialog only matching devices will be selectable for that role.

Finally, add wagoDioSeparate to the exported array at the bottom of the file so the dashboard actually picks it up:

export const MACHINES: MachineProperties[] = [
    // ... existing machines
    wagoDioSeparate,
];

7.6 routes.tsx Router Registration

electron/src/routes/routes.tsx is where TanStack Router learns about each machine's page tree. Without an entry here, navigating to /_sidebar/machines/wagodioseparate/$serial would 404 even if properties.ts lists the machine. Four edits are needed, all following the pattern used by the other minimal machines.

a) Import the page components (at the top of the file, alongside the other machine imports):

import { WagoDioSeparatePage } from "@/machines/minimal_machines/wagodioseparate/WagoDioSeparatePage";
import { WagoDioSeparateControlPage } from "@/machines/minimal_machines/wagodioseparate/WagoDioSeparateControlPage";

b) Define the serial route the parent route that scopes everything by $serial so that multiple instances of the same machine can coexist at different URLs:

export const wagoDioSeparateSerialRoute = createRoute({
    getParentRoute: () => machinesRoute,
    path: "wagodioseparate/$serial",
    component: () => <WagoDioSeparatePage />,
});

The path segment must be identical to the slug in properties.ts otherwise the link produced by the dashboard will not resolve. This export is also what WagoDioSeparatePage.tsx imports to call useParams() (section 7.4).

c) Define the control sub-route nested under the serial route, this is what actually renders when the user clicks the Control tab in the topbar:

const wagoDioSeparateControlRoute = createRoute({
    getParentRoute: () => wagoDioSeparateSerialRoute,
    path: "control",
    component: () => <WagoDioSeparateControlPage />,
});

If you later add more tabs to WagoDioSeparatePage.tsx, each new tab needs a matching sub-route here.

d) Wire the sub-route into the route tree find the top-level routeTree definition (the big .addChildren([...]) call on rootRoute/machinesRoute) and add the DIO Separate machine alongside the other minimal machines:

wagoDioSeparateSerialRoute.addChildren([wagoDioSeparateControlRoute]),

Verify the build once all edits are in place:

npm run typecheck

A clean type-check is usually a good signal that the serial route export, the page components, and the properties.ts entry all line up.


8. Dashboard & Demo

8.1 Assigning Devices

Once the backend and frontend are running, open the QiTech Control dashboard. You should see the discovered EtherCAT devices:

  • WAGO 750‑354 (Coupler)
  • WAGO 750‑430 (8‑CH Digital Input)
  • WAGO 750‑530 (8‑CH Digital Output)

Figure Discovery

Steps to assign:

  1. Click Assign on the 750‑354 coupler.
  2. Select "WAGO DIO Separate V1" as the machine type.
  3. Enter a serial number other than 0.
  4. Keep the device role as "WAGO 750‑354 Bus Coupler".
  5. Repeat for the 750‑430 and 750‑530 modules using the same serial number.
  6. Restart the backend process to apply the assignment.

Figure Assign

8.2 Testing I/O

After restart, click the machine in the right side bar to open it:

  • Digital Inputs (750‑430): Displays the current state of all 8 input channels as ON/OFF badges.
  • Digital Outputs (750‑530): Provides toggle buttons for each of the 8 output channels, plus bulk "All ON" / "All OFF" controls.

Test Wiring (Output → Input Loopback)

To verify the setup, wire any digital output channel on the 750‑530 to any digital input channel on the 750‑430. For example:

750-530 DO1 ──────► 750-430 DI1

This lets you toggle an output in the dashboard and immediately see the corresponding input change state.


9. Troubleshooting

Symptom Likely Cause Fix
Coupler LEDs are off No power to 750‑354 Check 24 V wiring to coupler terminals
ERR LED is red EtherCAT communication error Check Ethernet cable, verify EtherCAT master is running
I/O LED is off No process data exchange Restart backend, check module assignment
"Expected 750‑430 in position 0" error Modules in wrong physical order Ensure DI module is mounted directly after the power terminal
"Expected 750‑530 in position 1" error Modules in wrong physical order Ensure DO module is mounted after the DI module
Inputs always show OFF No signal on DI terminals Check loopback wiring, verify DO is toggled ON
Outputs don't respond Backend not running or mutation error Check backend logs, verify serial number matches

10. References

Resource Link
WAGO 750‑430 Documentation wago.com/750-430
WAGO 750‑530 Documentation wago.com/750-530
WAGO 750‑354 Documentation wago.com/750-354
WAGO 750‑602 Documentation wago.com/750-602
WAGO 750‑600 Documentation wago.com/750-600
QiTech Control Repository github.com/qitechgmbh/control
Device Example Basics (Wiki) Device-Example-Basics

Contributing: If you find issues or want to improve this guide, please open a PR or issue in the control repo.

Clone this wiki locally