Skip to content

Minimal Example: Digital‐Output (Video Script)

Oshgnacknak edited this page Sep 7, 2026 · 1 revision

A complete script according to the video to walk you through a whole minimal example building a standalone app on the QiTech Framework that toggels the LEDs of a Beckhoff EL2004 from the TUI. This is the template example for all QiTech Framework minimal machines. If you can follow this page, you can build the Digital In, Analog In, and Analog Out variants the same way.

Table of Contents

  1. Introduction
  2. Requirements
  3. Hardware Setup
  4. Software Setup
  5. Demo in the TUI
  6. Complete Code

1. Introduction

The EL2004 LED example is the simplest possible complete machine: four digital outputs, one boolean config value, one screen to flip it. It demonstrates the full QiTech Framework loop:

YAML schema → machine struct → runtime → EtherCAT terminal → LED

and the other direction, how the machine shows in up in the TUI to operate it. At the end you will have a standalone Cargo project no clone of the framework repository needed that finds the terminal on the bus and toggles its outputs live.

Screenshot 2026-08-18 at 13 18 21

2. Requirements

Hardware:

  • Beckhoff EL2004 EtherCAT Terminal (4-channel digital output)
  • Beckhoff EK1100 EtherCAT Coupler
  • 24 V DC power supply (AC/DC adapter + DC hollow plug)
  • Jumper / bridge wires (0.5–1.5 mm² recommended)
  • A Linux PC (Ubuntu/Debian recommended)
  • Standard Ethernet cable
  • Flat screwdriver

Software:

  • Linux with a wired Ethernet port (EtherCAT needs raw network access this is why we run with sudo later)
  • Rust toolchain (stable) and git
  • No QiTech repositories need to be cloned everything arrives as a Cargo dependency

3. Hardware Setup

Perform the following wiring on the EK1100:

Red wire (+24 V) → Terminal 2

Black wire (0 V) → Terminal 3

Jumper wire from Terminal 1 → Terminal 6

Jumper wire from Terminal 5 → Terminal 7

EK1100 Minimal Wiring

Screenshot 2026-08-18 at 14 55 25

EL2004 Terminal

Screenshot 2026-08-18 at 15 07 28

EK1100 + EL2004 Connected

Screenshot 2026-08-18 at 15 08 02

Power: connect the 24 V adapter to the hollow plug used earlier. Example AC/DC adapter

Screenshot 2026-08-18 at 15 09 09

Ethernet: use a standard LAN cable to connect your PC directly to the EK1100's upper RJ45 port. No switch, no router, EtherCAT is not regular Ethernet traffic. The final powered-up setup:

Screenshot 2026-08-18 at 15 10 10

After wiring, your module should look like Figure 1.

4. Software Setup

Creating a new Project using cargo init

We build this example as a standalone Cargo project, no clone of the framework repository needed. Create an empty project and enter it:

cargo init el2004_example
cd el2004_example

​

cargo init generates the minimal skeleton of a Rust application:

  • Cargo.toml: the project manifest. This is where we declare our dependencies in the next step.
  • src/main.rs :the entry point, prefilled with a Hello-World main() that we will replace step by step.

Run cargo run once now — if it prints Hello, world!, your Rust toolchain works and we can start building.

QiTech Framework as dependency

Open Cargo.toml and add the framework crates under [dependencies]:

[dependencies]
qitech_framework = { git = "https://github.com/qitechgmbh/qitech_framework", rev = "812babfe83dd4d14a3e16d40e1174b5b4718e017" }
ethercat_hal = { git = "https://github.com/qitechgmbh/qitech_lib", rev = "530355c00baf6335f6085441c2a497d9ac060af6" }

What each crate provides:

  • qitech_framework:the machine toolkit and the runtime that executes your machine in a fixed cycle.
  • qitech_framework_tui: the terminal interface we will operate the machine with.
  • qitech_lib: the hardware abstraction layer; it contains the driver for the EL2004 terminal we use later.
Screenshot 2026-08-19 at 14 23 30

The framework is not published on crates.io yet, so we pull it straight from GitHub as a git dependency. The rev pins an exact commit: this guarantees the example builds identically for everyone, even as the framework keeps evolving. When you later target a newer framework version, update the rev values (and expect small API changes).

Start the QiTech Runtime without any hardware

Replace the Hello-World main() with the smallest possible runtime start:

​use qitech_framework::runtime::RuntimeConfiguration;
use qitech_framework::run_with_tui;
use qitech_framework_tui::TuiConfiguration;

#[tokio::main]
pub async fn main() {
    let config_rt = RuntimeConfiguration::new();

    run_with_tui(config_rt, TuiConfiguration::default())
        .await
        .unwrap()
}

What happens here:

  • RuntimeConfiguration::new(): a builder for the runtime's settings. For now we keep the defaults: no EtherCAT, no machines, the runtime simply idles in its fixed cycle.
  • run_with_tui(…): starts the runtime in the background and opens the TUI in your terminal, already connected to it. The TUI is empty for now, but you can see the runtime beating.
  • #[tokio::main]: the run helpers are async, so main needs an async executor. Add tokio = { version = "1", features = ["full"] } to your dependencies.

This is your first checkpoint: cargo run must open the TUI without errors before we touch any hardware.

Add EtherCAT (and connect hardware)

Now we tell the runtime to actually talk to the bus. Only the configuration changes, the rest of main() stays as it is:

use std::time::Duration;
use qitech_framework::runtime::EtherCATConfig;

let config_rt = RuntimeConfiguration::new()
    .ethercat(EtherCATConfig {
        interface_scan_interval: Duration::from_secs(1),
        master_config: Default::default(),
        stay_in_preop: false,
    });

What the fields mean:

  • interface_scan_interval: the runtime scans your network interfaces once per second until it finds an EtherCAT bus. Plug the Ethernet cable directly from your PC into the EK1100's upper RJ45 port no switch or router in between, EtherCAT is not regular network traffic.
  • master_config: tuning options for the EtherCAT master (cycle timing, real-time settings). The defaults are fine for this example.
  • stay_in_preop: if true, devices are held in the Pre-OP state for debugging. We want them fully operational, so: false.

Now try to run it and watch it find nothing:

​bash cargo run ​

The runtime starts, scans… and never finds a bus. That is expected. EtherCAT sends raw Ethernet frames, and opening a raw network socket requires root privileges. So build normally, then run with sudo:

​bash cargo build && sudo ./target/debug/el2004_example ​

Now the scan succeeds: the TUI shows your EK1100 and EL2004, and after a moment they reach the OP (operational) state. Second checkpoint done, the bus is alive.

Operating it in the TUI

Our main() has been using run_with_tui since step one, so there is no new code here, just an explanation of what it does and what you should see now that the bus is alive:

​rust run_with_tui(config_rt, TuiConfiguration::default()) .await .unwrap() ​

What this call does:

  • run_with_tui(runtime_config, tui_config): starts the runtime in the background and the TUI in the foreground, already wired together through an internal session. The call only returns when you close the TUI.
  • TuiConfiguration::default(): sensible defaults; the builder also lets you tune things like the refresh rate.
  • .await.unwrap(): the helper is async (hence #[tokio::main]), and unwrap surfaces any startup error immediately instead of failing silently.

What you should see (running with sudo, hardware connected):

  1. The TUI opens with the runtime view, the cycle is beating.
  2. After the interface scan finds the bus, the EK1100 and EL2004 appear in the device list and reach the OP state.
  3. No machine yet: the list stays empty until we implement one in the next section. That is our cue.
Screenshot 2026-08-17 at 14 04 03

Stop everything with the TUI's quit key.

Building the machine: EL2004 digital output (LEDs on/off)

Our goal: toggle the terminal's LED outputs from the TUI. One boolean config value, written to all four output ports, the smallest possible machine.

We build everything in src/main.rs, from top to bottom: first the struct, then the three traits the runtime demands, one by one.

The machine struct

A machine in the QiTech Framework is plain data: handles to its hardware and to its registered properties. Ours needs exactly two fields. Add this above fn main(), and the two imports at the top of the file:

use std::{cell::RefCell, rc::Rc};
use ethercat_hal::devices::beckhoff_modules::el2004::EL2004;
use qitech_framework::machine::ConfigProperty;

pub struct EL2004Machine {
    leds_on: ConfigProperty<bool>,
    el2004: Rc<RefCell<EL2004>>,
}
  • leds_on: a typed handle to the leds_on config value we will declare in the YAML schema. Reading .get() always returns the current value, no matter where it was last set (TUI, default).
  • el2004: the handle to the terminal driver. Rc<RefCell<…>> because the runtime and the machine share access to the device.

Don't worry that nothing compiles yet! The struct alone isn't a machine. The runtime demands three traits (Machine, MachineDescriptor, MachineBuild), and we implement them one by one in the next sections.

Register the machine with the runtime

Inside fn main(), add the machine to the runtime configuration with .machine::<…>():

let config = RuntimeConfiguration::new()
    .cycle_period(Duration::from_millis(100))
    .ethercat(EtherCATConfig {
        interface_scan_interval: Duration::from_secs(1),
        master_config: None,
        stay_in_preop: false,
    })
    .machine::<EL2004Machine>();

Try to compile: it fails on purpose: the runtime demands three traits (Machine, MachineDescriptor, MachineBuild) that our struct doesn't implement yet. The compiler errors are our to-do list for the next sections.

First trait: Machine

act() is called by the runtime every cycle (every 100 ms with our config) this is where a machine's continuous logic lives. For now it does nothing; we come back to it at the very end. Add this below the struct:

impl Machine for EL2004Machine {
    fn act(&mut self, dt: Duration) -> qitech_framework::machine::ActResult {
        Ok(())
    }
}

The YAML schema

Create a new file el2004_machine.yml next to your Cargo.toml (not inside src/) this file is the machine's public interface:

qms_version: 1.0
revision: 1

identification:
  name: EL2004
  vendor_id: 0
  machine_id: 0

config:
  leds_on: !boolean
  • qms_version / revision: the schema format version, and your machine's interface version (bump it when you change the interface).
  • identification: the machine's identity. These values must match the ones in MachineDescriptor (next section), or the runtime rejects the machine at startup.
  • config.leds_on: !boolean: one switchable value. Everything under config: automatically becomes editable in the TUI — that is our entire user interface, no UI code needed.

Second trait: MachineDescriptor

The descriptor tells the runtime who this machine is and what its interface looks like it binds the YAML file to the struct. Add this below the Machine impl:

impl MachineDescriptor for EL2004Machine {
    const IDENTIFICATION: MachineIdentification = MachineIdentification {
        vendor_id: 0,
        machine_id: 0,
    };

    const SCHEMA: &'static str = include_str!("../el2004_machine.yml");
}
  • IDENTIFICATION: must match the identification block in the YAML exactly. 0/0 is fine for this example.
  • SCHEMA: include_str! embeds the YAML into the binary at compile time; the path is relative to src/, so ../el2004_machine.yml points next to your Cargo.toml. No file to ship alongside the executable.

Third trait: MachineBuild

build() is called by the runtime when it assembles the machine. The BuildContext is our toolbox — add the empty skeleton below the MachineDescriptor impl:

impl MachineBuild for EL2004Machine {
    fn build(ctx: &mut BuildContext) -> BuildResult<Self> {

    }
}

Now we fill it, step by step. First, find our terminal on the bus:

let el2004 = ctx.find_ethercat_device::<EL2004>(0)?;

The 0 selects which EL2004 if several are connected — with a single terminal, the first one. If the terminal is missing, build() fails with a clear error instead of silently doing nothing.

Next, get the handle for our config value from the schema:

let leds_on = ctx
    .config::<bool>("leds_on")
    .default(false)
    .build()?;

The name must match the YAML exactly (leds_on) and the type (bool) must match !boolean — both are checked at startup. .default(false) means: LEDs off until someone toggles them.

Finally, return the finished machine — all three lines together inside build():

Ok(Self {
    el2004,
    leds_on,
})

Back to act(): write the outputs

Now the empty act() from earlier gets its job — every cycle, write the current config value to all four output ports. Replace the body of your Machine impl:

impl Machine for EL2004Machine {
    fn act(&mut self, dt: Duration) -> qitech_framework::machine::ActResult {
        let mut el2004 = self.el2004.borrow_mut();

        for port in 0..el2004.get_port_count() {
            el2004.set_output(port, self.leds_on.get());
        }

        Ok(())
    }
}
  • self.leds_on.get(): reads the current value of the config property, wherever it was last set (TUI, default).
  • set_output(port, …): comes from the DigitalOutputDevice trait, so make sure it is imported at the top of the file: use ethercat_hal::io::digital_output::DigitalOutputDevice;
  • The runtime pushes the outputs to the bus after every act() that is all the machine has to do.

If anything doesn't compile, compare your file with the Complete Code below.

5. Demo in the TUI

Everything compiles now. Build, then run with sudo (EtherCAT needs a raw network socket):

cargo build && sudo ./target/debug/el2004_example

In the TUI you should see: EtherCAT reaches Op, the machine EL2004 (0) appears, and its Config tab shows leds_on: false.

The machine appears: EtherCAT in Op, EL2004 (0) in the machine list, leds_on in the Config tab

Select leds_on, set it to true the LEDs on the terminal light up. Set it back, they go dark. That is the whole loop: YAML schema → TUI entry → config property → act() → EtherCAT output → light.

The Events tab of leds_on tells the same story as a protocol: Registered with default=false, then true => Accepted, then false => Accepted.


6. Complete Code

For reference: the finished src/main.rs in one piece:

use std::{cell::RefCell, rc::Rc, thread, time::Duration};

use ethercat_hal::{
    devices::beckhoff_modules::el2004::EL2004,
    io::digital_output::DigitalOutputDevice,
};
use qitech_framework::{
    MachineIdentification,
    machine::{ConfigProperty, Machine, MachineBuild, MachineDescriptor},
    runtime::{EtherCATConfig, Runtime, RuntimeConfiguration},
    session,
};
use qitech_framework_tui::{Tui, TuiConfiguration};

pub struct EL2004Machine {
    leds_on: ConfigProperty<bool>,
    el2004: Rc<RefCell<EL2004>>,
}

impl Machine for EL2004Machine {
    fn act(&mut self, _dt: Duration) -> qitech_framework::machine::ActResult {
        let mut el2004 = self.el2004.borrow_mut();

        for port in 0..el2004.get_port_count() {
            el2004.set_output(port, self.leds_on.get());
        }

        Ok(())
    }
}

impl MachineDescriptor for EL2004Machine {
    const IDENTIFICATION: MachineIdentification = MachineIdentification {
        vendor_id: 0,
        machine_id: 0,
    };

    const SCHEMA: &'static str = include_str!("../el2004_machine.yml");
}

impl MachineBuild for EL2004Machine {
    fn build(
        ctx: &mut qitech_framework::machine::BuildContext,
    ) -> qitech_framework::machine::BuildResult<Self> {
        let el2004 = ctx.find_ethercat_device::<EL2004>(0)?;

        let leds_on = ctx
            .config::<bool>("leds_on")
            .default(false)
            .build()?;

        Ok(Self { el2004, leds_on })
    }
}

fn main() {
    let config = RuntimeConfiguration::new()
        .cycle_period(Duration::from_millis(100))
        .ethercat(EtherCATConfig {
            interface_scan_interval: Duration::from_secs(1),
            master_config: None,
            stay_in_preop: false,
        })
        .machine::<EL2004Machine>();

    let (session_rt, session_tui) = session::crossbeam(64);

    thread::spawn(move || {
        let rt = Runtime::init(config, session_rt).expect("Failed to create runtime!");
        rt.run().expect("Runtime error!");
    });

    let tui_config = TuiConfiguration::new()
        .refresh_rate(Duration::from_secs_f64(1.0 / 30.0));

    let app = Tui::create(tui_config).expect("Failed to create TUI!");
    app.run(session_tui).expect("TUI error!");
}

(In act() the parameter is written _dt here — the underscore silences the "unused variable" warning as long as we don't use it.)