-
Notifications
You must be signed in to change notification settings - Fork 1
Minimal Example: Digital‐Output (Video Script)
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.
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.
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
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
EL2004 Terminal
EK1100 + EL2004 Connected
Power: connect the 24 V adapter to the hollow plug used earlier. Example AC/DC adapter
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:
After wiring, your module should look like Figure 1.
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-Worldmain()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.
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.
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).
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 areasync, somainneeds an async executor. Addtokio = { 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.
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: iftrue, 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.
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 isasync(hence#[tokio::main]), andunwrapsurfaces any startup error immediately instead of failing silently.
What you should see (running with sudo, hardware connected):
- The TUI opens with the runtime view, the cycle is beating.
- After the interface scan finds the bus, the EK1100 and EL2004 appear in the device list and reach the OP state.
- No machine yet: the list stays empty until we implement one in the next section. That is our cue.
Stop everything with the TUI's quit key.
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.
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 theleds_onconfig 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.
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.
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(())
}
}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 inMachineDescriptor(next section), or the runtime rejects the machine at startup. -
config.leds_on: !boolean: one switchable value. Everything underconfig:automatically becomes editable in the TUI — that is our entire user interface, no UI code needed.
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 theidentificationblock in the YAML exactly.0/0is fine for this example. -
SCHEMA:include_str!embeds the YAML into the binary at compile time; the path is relative tosrc/, so../el2004_machine.ymlpoints next to yourCargo.toml. No file to ship alongside the executable.
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,
})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 theDigitalOutputDevicetrait, 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.
Everything compiles now. Build, then run with sudo (EtherCAT needs a raw network socket):
cargo build && sudo ./target/debug/el2004_exampleIn the TUI you should see: EtherCAT reaches Op, the machine EL2004 (0) appears, and its Config tab shows leds_on: false.
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.
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.)