-
Notifications
You must be signed in to change notification settings - Fork 30
Wago DIO Separate
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.
- Introduction
- Requirements
- Architecture Overview
- Hardware Setup
- Software Setup
- Backend Code Walkthrough
- Frontend Code Walkthrough
- Dashboard & Demo
- Troubleshooting
- References
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.
| 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 |
| 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)
See Device Example Basics for software prerequisites (Rust toolchain, Node.js, EtherCAT master setup).
Final assembly order (left to right):
[750-354 Coupler] → [750-602 Power Terminal] → [750-430 DI] → [750-530 DO] → [750-600 End]
Data flow:
- The backend communicates with the 750‑354 coupler over EtherCAT.
- The coupler discovers its attached modules (750‑430 + 750‑530) during initialization.
- Input states are read from the 750‑430 at ~30 Hz and emitted to the frontend via Socket.IO.
- Output commands from the frontend are sent as mutations to the backend, which writes to the 750‑530.
⚠️ Safety Warning Always disconnect power before wiring. Double‑check polarity before energizing. See Device Example Basics Safety for the full safe‑wiring procedure.
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
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
Slide the 750‑430 onto the right side of the 750‑602 until it locks.
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.
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)
- Connect the 24 V power supply to mains.
- Run an Ethernet cable from your Linux PC to the 750‑354 coupler (X1 IN port).
- 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
See Device Example Basics to install and run the QiTech Control software, then return here for the device‑specific code and demo.
The backend follows the standard 4-file machine structure used across the QiTech codebase: mod.rs, new.rs, act.rs, and api.rs.
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 ¶ms.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 do8Each 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.
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();
}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.
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.
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).
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.
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.
The machine module must be declared so the compiler knows it exists:
// machines/src/minimal_machines/mod.rs
pub mod wago_dio_separate;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).
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 |
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);
}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.
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.
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.
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.
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,
};
}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.
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.
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.
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(),
}),
);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>
);
}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.
<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.
<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.
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.
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.machinemust be the sameu16as theMACHINE_WAGO_DIO_SEPARATE_V1constant defined in the backend'slib.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. -
slugis the URL segment used by the router (section 7.6) and must match thepathon the serial route. -
device_rolesmirrors the roles the backend expects innew.rs(section 6.1). Eachroleindex here corresponds to therolefield the backend reads when it validates the device group on the bus.allowed_devicesis 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,
];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.
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:
- Click Assign on the 750‑354 coupler.
- Select "WAGO DIO Separate V1" as the machine type.
- Enter a serial number other than 0.
- Keep the device role as "WAGO 750‑354 Bus Coupler".
- Repeat for the 750‑430 and 750‑530 modules using the same serial number.
- Restart the backend process to apply the assignment.
Figure Assign
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.
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.
| 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 |
| 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.
QiTech Control | GitHub | Video Demo | Open Source Framework for Industrial Control
- Getting Started
- Adding a Machine
- Adding a minimal machine e.G. 4CH DO
- Code Style
- Performance
- Testing
- Adding Presets to Machines
- NixOS
Beckhoff:
WAGO:
- 4 Digital In (750-402)
- 8 Digital In (750-430)
- 8 Digital In + 8 Digital Out (750-1506)
- 4 Analog In (750-455)
- 2 Digital Out (750-501)
- 8 Digital Out (750-530)
- Stepper (750-671 & 750-672)
- Power Supply (2789‐9052)
- Serial Interface (750-652)
- 4-channel Analog input module Pt100 RTD (750-460)
- 8 Digital In + 8 Digital Out (750-430 + 750-530)
Elrest:
WAGO: