-
Notifications
You must be signed in to change notification settings - Fork 1
Creating a Machine
This guide walks through building a machine with the QiTech Framework, from an empty crate in your own project to a running machine you can control from the TUI. It assumes you know Rust and have read Resources.
For reference, the framework repository contains complete, working examples in examples/apps/:
| Example | Hardware | Shows |
|---|---|---|
beckhoff_el2004 |
EtherCAT digital output | the minimal machine: config properties with change callbacks |
beckhoff_el1002 / beckhoff_el4008 / wago_750_531
|
EtherCAT digital/analog I/O | reading and writing EtherCAT devices |
qitech_laser |
Modbus RTU laser | units, state, measurements, events, hardware fault handling |
xtrem_scale |
Xtrem bus scales | commands, several instances of one machine type |
A machine consists of four parts:
- A schema (YAML) declares the machine's identity and its resources. Controllers use it to know what exists.
- A struct holds the hardware handles, resource handles and any internal state.
-
MachineBuild::buildruns once at startup. It finds the hardware and creates the resource handles. -
Machine::actruns every cycle (every 100 µs by default). This is where the control logic goes.
You register the machine type with the Runtime. The Runtime creates one instance for every machine identity it finds in the hardware. Registering a type doesn't create an instance by itself: with no hardware assigned to it, the type simply never runs.
schema.yaml ──(compile time)──► #[derive(Machine)] / #[machine_build]
│
RuntimeConfiguration::machine::<M>() ────┤ registers the type
▼
hardware discovery ── finds vendor:machine:serial ──► M::build(ctx) ──► act(dt) every cycle
Your machine lives in your own crate or workspace. The framework is a dependency, and you don't need to change anything in the framework repository.
cargo new --bin my_machine
cd my_machineThe framework uses Rust edition 2024 and requires Rust 1.90 or newer. Pin the toolchain so everyone builds with the same version:
# rust-toolchain.toml
[toolchain]
channel = "1.90.0"The framework isn't published on crates.io yet, so add it as a git dependency. Pin a rev: the project is experimental, and main can break between commits.
# Cargo.toml
[package]
name = "my_machine"
version = "0.1.0"
edition = "2024"
[dependencies]
qitech_framework = { git = "https://github.com/qitechgmbh/qitech_framework", rev = "<framework commit>" }
# Hardware drivers (EtherCAT terminals, Modbus devices, Xtrem, ...) and units.
# MUST be the exact same rev that the framework uses. See below.
qitech_lib = { git = "https://github.com/qitechgmbh/qitech_lib.git", rev = "<same rev as the framework>" }
tokio = { version = "1", features = ["full"] }qitech_lib must use the same revision as the framework. Device types such as EL2004 and traits such as EthercatDevice come from qitech_lib. If your crate and the framework pull different revisions, Cargo compiles two copies of the library, and their types don't match. You then get confusing errors like "expected EL2004, found EL2004" or "trait EthercatDevice is not implemented". To find the right revision, look up the qitech_lib entry in the framework's root Cargo.toml at the commit you pinned.
Units such as Length and millimeter are also available as qitech_framework::units, so if you only need units you don't need qitech_lib directly.
my_machine/
├── Cargo.toml
├── rust-toolchain.toml
├── .cargo/
│ └── config.toml # runner that grants hardware capabilities (step 7)
├── schemas/
│ └── my_machine.yaml # one file per machine type (step 2)
└── src/
└── main.rs
Put schemas in schemas/ next to the Cargo.toml of the crate that defines the machine. That is where the macros look by default.
If you build several machine types, a workspace keeps the framework revision in one place:
# <workspace root>/Cargo.toml
[workspace]
members = ["machines/*", "app"]
resolver = "2"
[workspace.dependencies]
qitech_framework = { git = "https://github.com/qitechgmbh/qitech_framework", rev = "<framework commit>" }
qitech_lib = { git = "https://github.com/qitechgmbh/qitech_lib.git", rev = "<same rev as the framework>" }
tokio = { version = "1", features = ["full"] }# machines/my_machine/Cargo.toml
[dependencies]
qitech_framework.workspace = true
qitech_lib.workspace = trueA common layout is one library crate per machine type, each with its own schemas/ folder, plus one binary crate (app) that registers them all with the Runtime (step 6).
Create schemas/<machine_name>.yaml. The file name must be the struct name in snake_case: MyMachine becomes schemas/my_machine.yaml. The macros look the file up by that name at compile time.
qms_version: 1.0 # schema format version
revision: 1 # increase when you change this machine's interface
identification:
name: my_machine
vendor_id: 1 # your vendor id (1 = QiTech GmbH), see below
machine_id: 42 # unique for this vendor
config:
speed:
target: !meter_per_second
limit: !meter_per_second
mode: !enum [idle, running, cleaning]
state:
running: !boolean
error: !?string # "?" = nullable
measurements:
speed: !meter_per_second
temperature: !?degree_celsius
commands:
start: !command
stop: !command
events:
overheated: !event-
vendor_id+machine_ididentify the machine type everywhere: on the wire, in the EtherCAT EEPROM and in the controller. Don't reuse amachine_idfor a different machine. -
Vendor ids are listed in the framework's
qitech_framework_core/vendors.toml, which currently only contains QiTech (1). If you build machines under your own name, ask for a vendor id to be added there, so your ids can't collide with anyone else's. Within your vendor id, you choose themachine_ids. -
Resource paths come from nesting: the config above defines
speed.target,speed.limitandmode. -
Types:
!boolean,!integer,!string,!enum [...](or a map{ name: value }),!float(also!fraction,!percentage), or any unit fromqitech_framework_core/quantities.toml, written in snake_case (MeterPerSecond→!meter_per_second).
See Resources for what each kind of resource is for.
use std::cell::RefCell;
use std::rc::Rc;
use qitech_framework::Machine; // the derive macro
use qitech_framework::machine::{
ConfigProperty, StateProperty, Measurement, EventEmitter,
};
use qitech_lib::units::Velocity;
#[derive(Machine)]
pub struct MyMachine {
// --- hardware ---
drive: Rc<RefCell<SomeDriveDevice>>,
// --- config ---
speed_target: ConfigProperty<Velocity>,
speed_limit: ConfigProperty<Velocity>,
mode: ConfigProperty<Mode>,
// --- state ---
running: StateProperty<bool>,
// --- measurements ---
speed: Measurement<Velocity>,
temperature: Measurement<Option<ThermodynamicTemperature>>,
// --- events ---
overheated: EventEmitter<()>,
}#[derive(Machine)] reads the schema and implements MachineDescriptor, which provides:
-
MyMachine::IDENTIFICATION: thevendor_id/machine_idfrom the schema, and -
MyMachine::SCHEMA: the schema text, embedded into the binary.
It does not implement the Machine trait itself. You do that in step 5.
Config properties can use your own enums. Derive EnumProperty, and keep the variants unit-only, matching the schema's variant names in snake_case:
use qitech_framework::EnumProperty;
#[derive(Debug, Clone, Copy, PartialEq, EnumProperty)]
pub enum Mode { Idle, Running, Cleaning }build runs once per machine instance during Runtime initialization. It gets a BuildContext and returns the finished machine.
use qitech_framework::machine::{BuildContext, BuildResult, MachineBuild};
use qitech_framework::machine_build;
use qitech_lib::units::velocity::meter_per_second;
impl MachineBuild for MyMachine {
#[machine_build(MyMachine)]
fn build(ctx: &mut BuildContext<'_>) -> BuildResult<Self> {
// --- hardware ---
let drive = ctx.find_ethercat_device::<SomeDriveDevice>(1)?; // role 1
// --- config ---
let speed_target = ctx
.config::<meter_per_second>("speed.target")
.default(0.5)
.minimum(0.0)
.maximum(2.0)
.on_external_changed(|m: &mut MyMachine| m.apply_speed())
.build()?;
let speed_limit = ctx
.config::<meter_per_second>("speed.limit")
.default(2.0)
.forbid_external_writes() // machine-controlled for now
.build()?;
let mode = ctx.config::<Mode>("mode").default(Mode::Idle).build()?;
// --- commands ---
ctx.command::<MyMachine>("start")
.can_execute(|m| m.start_capability())
.execute(|m| m.start())
.build()?;
ctx.command::<MyMachine>("stop")
.execute(|m| m.stop())
.build()?;
Ok(Self {
drive,
speed_target,
speed_limit,
mode,
running: ctx.state::<bool>("running").initial(false).build()?,
speed: ctx.measurement::<meter_per_second>("speed").build()?,
temperature: ctx
.measurement::<Option<degree_celsius>>("temperature")
.build()?,
overheated: ctx.event("overheated").build()?,
})
}
}The type in the turbofish says how values are given and converted. It is not always the type of the handle you get back:
| Schema type | Turbofish | Resulting handle |
|---|---|---|
!boolean |
::<bool> |
ConfigProperty<bool> |
!integer |
::<i64> (or another integer type) |
ConfigProperty<i64> |
!float |
::<f64> |
ConfigProperty<f64> |
!millimeter |
::<millimeter> (the unit) |
ConfigProperty<Length> (the quantity) |
!?millimeter |
::<Option<millimeter>> |
Measurement<Option<Length>> |
!string |
::<heapless::String<N>> |
ConfigProperty<heapless::String<N>> |
!enum [...] |
::<MyEnum> |
ConfigProperty<MyEnum> |
Values you pass to .default(..), .minimum(..) and .initial(..) are in the turbofish type, so .default(0.5) in the example means 0.5 m/s. Strings use heapless::String because resource values have to live inline, with no heap allocation.
For every ctx.config("…") call, it checks that:
- the path exists in the schema's
configsection, - the
Option<…>wrapper matches the property's nullability, and - the unit type matches the schema's quantity.
Enum and string configs must have an explicit turbofish.
Everything else, including state, measurements, commands, events and duplicate registrations, is checked at build time and returned as a BuildError.
| Method | Finds |
|---|---|
find_ethercat_device::<T>(role) |
the EtherCAT device assigned to this machine with that role (see step 6) |
find_ethercat_device_and_addr::<T>(role) / find_ethercat_device_addr(role)
|
the same, plus or only the device address |
get_ethercat_device::<T>(index) |
the n-th hardware item assigned to this machine, if it's an EtherCAT device |
get_modbus_rtu_device::<T>(index) |
the n-th hardware item, if it's a Modbus RTU device |
get_xtrem_device::<T>(index) / get_xtrem_probe(index)
|
the n-th hardware item, if it's an Xtrem device, and its discovery data |
get_ethercat_interface() |
the EtherCAT thread channel, for mailbox/CoE access |
ctx.ident() |
this instance's vendor:machine:serial
|
Devices are returned as Rc<RefCell<T>>. Keep that in your struct and borrow_mut() it in act.
-
Return
Err, don't panic, when hardware is missing or doesn't fit. The build is rolled back, the controller getsMachineBuildCompleted { result: Err(..) }, and the other machines still start. - Register every resource in the schema that the machine uses, once. Registering the same path twice is a
DuplicateResourceerror. -
on_external_changedandcommand(...)take the machine type (|m: &mut MyMachine|,::<MyMachine>). Using a different type returnsIllegalMachineType.
use std::time::Duration;
use qitech_framework::machine::{
ActError, ActErrorImpact, ActErrorKind, ActResult, Machine, OperationCapability,
};
impl Machine for MyMachine {
fn act(&mut self, dt: Duration) -> ActResult {
// --- read hardware ---
let actual = self.drive.borrow().speed();
self.speed.set(actual);
// --- control logic ---
let target = if *self.running.get_ref() {
self.speed_target.get().min(self.speed_limit.get())
} else {
Velocity::new::<meter_per_second>(0.0)
};
self.drive.borrow_mut().set_speed(target);
// --- faults ---
if self.drive.borrow().is_overheated() {
self.overheated.emit(&());
return Err(ActError {
kind: ActErrorKind::HardwareFault("drive overheated".into()),
impact: ActErrorImpact::Degraded,
});
}
Ok(())
}
}
impl MyMachine {
fn apply_speed(&mut self) -> ActResult { /* … */ Ok(()) }
fn start_capability(&self) -> OperationCapability {
if self.mode.get() == Mode::Running {
OperationCapability::Allowed
} else {
OperationCapability::Forbidden { reason: "switch mode to running first".into() }
}
}
fn start(&mut self) -> ActResult { self.running.set(true); Ok(()) }
fn stop(&mut self) -> ActResult { self.running.set(false); Ok(()) }
}act runs inside the real-time loop. Everything in the Runtime shares one cycle budget, which is 100 µs by default.
-
Never block. Don't sleep, don't do blocking I/O and don't wait on locks. Drivers in
qitech_libfollow a send request / handle response pattern. Call those methods, don't wait. -
Use
dtfor timing. For slow devices, count down a timer and only send a request when it expires. Bothqitech_laser(6 ms) andxtrem_scale(20 ms) do this. - Avoid allocating on the hot path where you can.
-
Report errors through
ActResult.ActErrorImpactdecides what happens:-
Ignore: the error is discarded; the machine keeps running. -
Degraded: the machine keeps running with reduced capability. -
Irrecoverable: the machine is removed from the Runtime, andRuntimeEvent::RemovedMachineis reported. Use it for broken hardware, for example after a grace period with no responses, asqitech_laserdoes.
-
| Handle | Read | Write |
|---|---|---|
ConfigProperty<T> |
get(), get_ref(), get_as::<unit>()
|
set(v) → Result<changed, ConstraintViolation>, set_as::<unit>(f64), reset(), set_default, set_min/max[_clamped], set_allowed, allow_external_write() / forbid_external_write(reason)
|
StateProperty<T> |
get(), get_ref(), get_as::<unit>()
|
set(v) → bool (true if the value changed) |
Measurement<T> |
get(), get_ref(), get_as::<unit>()
|
set(v), set_as::<unit>(f64)
|
EventEmitter<T> |
— |
emit(&payload) (the payload is serialized to JSON) |
Every write is recorded and sent to the controller automatically, so you don't have to do anything extra.
StateProperty::set returning bool makes edge detection easy. For example, if self.in_tolerance.set(ok) && !ok { self.out_of_tolerance.emit(&()) } emits the event only on the change to "out of tolerance".
A machine can read another machine's resources by implementing subscribe. The controller sets up a subscription with a SubscribeMachine request:
fn subscribe(&mut self, ctx: &mut SubscribeContext) -> SubscribeResult {
self.upstream_speed = Some(ctx.measurement::<Velocity>("speed")?); // RemoteProperty<Velocity>
Ok(())
}
fn unsubscribe(&mut self, _provider: MachineInstanceIdentification) {
self.upstream_speed = None; // required: reading after unsubscribe panics
}The value you read is the other machine's value at the end of the previous cycle. The default subscribe implementation rejects every subscription with UnsupportedMachine.
Warning: the Runtime currently doesn't call
unsubscribewhen a subscription ends, so reading a handle after anUnsubscribeMachinerequest panics and takes the Runtime down. Until that's fixed, avoid unsubscribing machines that are running. See Subscriptions for how subscriptions and handle validity work.
In main, configure the Runtime: enable the buses you need, register your machine type, and tell the Runtime which hardware belongs to which machine instance.
use qitech_framework::machine::MachineDescriptor;
use qitech_framework::runtime::{EtherCATConfig, RuntimeConfiguration};
let config = RuntimeConfiguration::new()
.ethercat(EtherCATConfig::default())
.machine::<MyMachine>();An instance is identified by vendor:machine:serial. How hardware gets its serial depends on the bus.
Each EtherCAT device that belongs to a machine stores a MachineDeviceInfo in its EEPROM: machine vendor, machine id, machine serial and role. During startup the Runtime reads these and groups the devices by machine instance. find_ethercat_device::<T>(role) then picks the device with that role.
New devices have no identity yet. Assign one with a WriteMachineDeviceInfo { machine_ident, role, subdevice_index } request from the controller, then restart the Runtime.
Roles are your own convention per machine type. For example, 1 could be "main I/O terminal" and 2 "drive". Document them next to your build function.
.modbus_rtu_device::<LaserDevice>(
"/dev/serial/by-path/pci-…-usb-0:1:1.0-port0", // stable serial path
MyMachine::IDENTIFICATION.unique(1), // → vendor:machine:1
1, // Modbus slave id
None, // Option<ModbusSettings>
)Use /dev/serial/by-path/… so the same physical port always maps to the same machine. In build, get the device with get_modbus_rtu_device::<T>(0).
.xtrem(XtremConfig { bus, ..Default::default() })
.xtrem_device::<XtremScale>(0x03, ScaleV1::IDENTIFICATION.unique(1), ScaleMode::Poll)
.xtrem_device::<XtremScale>(0x04, ScaleV1::IDENTIFICATION.unique(2), ScaleMode::Poll)Each bus device id maps to one machine instance. This example creates two instances of the same machine type. In build, use get_xtrem_device::<T>(0).
RuntimeConfiguration::new()
.cycle_period(Duration::from_micros(100)) // default 100 µs
.export_interval(Duration::from_secs_f64(1.0 / 32.0)) // report rate, default 32 Hz
.requests_per_cycle_max(10) // default 10Pick a controller:
#[tokio::main]
async fn main() {
let config = /* … */;
// interactive terminal UI
qitech_framework::run_with_tui(config, TuiConfiguration::default()).await.unwrap();
// or: the Hub with your own listeners/actors
// qitech_framework::run_with_hub(config, HubConfiguration::new()).await.unwrap();
// or: no controller, just print what the Runtime sends
// qitech_framework::run_debug(config);
}EtherCAT needs raw network access, and the real-time loop benefits from real-time scheduling and locked memory. Binaries you build don't have these permissions, so grant them to the binary before it starts. The easiest way is a Cargo runner in your project. Cargo calls it with the built binary as its first argument, on every cargo run.
# .cargo/config.toml
[target.x86_64-unknown-linux-gnu]
runner = "scripts/run-linux"#!/usr/bin/env bash
# scripts/run-linux (chmod +x)
set -e
BINARY="$1"
shift
# raw sockets (EtherCAT), real-time priority, locked memory, device access
sudo setcap 'cap_dac_override,cap_net_raw,cap_sys_nice,cap_ipc_lock=eip' "$BINARY"
# serial adapters for Modbus RTU
if compgen -G "/dev/ttyUSB*" > /dev/null; then
sudo chown "root:$(id --group)" /dev/ttyUSB*
fi
exec "$BINARY" "$@"This is the same script as bin/run-linux in the framework repository. It prompts for sudo because setcap has to be run again after every rebuild. On a deployed machine, set the capabilities once at install time (for example in your package or systemd unit) instead.
cargo run # or: cargo run -p app in a workspaceEtherCAT needs a free network interface with the devices connected. Modbus RTU needs the serial adapter plugged in at the path you configured.
In the TUI you should see:
- the init events,
-
MachineBuildCompletedfor your instance, and - your resources, where you can edit config properties and run commands.
| Symptom | Cause |
|---|---|
Compile error Could not find schema for 'MyMachine'
|
The schema file isn't at schemas/my_machine.yaml next to the Cargo.toml of the crate that defines the machine. Check the name (struct name in snake_case). |
Errors like "expected EL2004, found EL2004" or "EthercatDevice is not implemented" |
Your qitech_lib revision differs from the framework's. Use exactly the same rev (step 1). |
Operation not permitted when EtherCAT starts |
The binary is missing its capabilities. Set up the runner, or run setcap (step 7). |
Compile error unknown config property
|
A path in ctx.config("…") doesn't match the schema. Remember nesting: speed.target. |
Compile error cannot infer type for config property
|
Enum or string config without a turbofish. |
Compile error expected quantity type … / nullable property requires Option<T>
|
The turbofish doesn't match the schema's unit or nullability. |
| Machine never appears | No hardware is assigned to that vendor:machine:serial. EtherCAT: write the device identity. Modbus/Xtrem: check the path or device id and look for …NotFound init events. |
MachineBuildCompleted with ExpectedEtherCATDeviceWithRole
|
No device with that role is assigned to this instance. |
…DeviceTypeMismatch |
The device at that index or role is a different driver type than you requested. |
IllegalResourcePath / IllegalResourceType at build time |
A state, measurement, command or event path or type doesn't match the schema. |
MachineTypeNotRegistered |
Hardware claims a machine type that you didn't register with .machine::<M>(). |
Runtime panics with bump allocator exhausted
|
All machines together use more resource storage than the 4 KB pool per resource kind. |
| Cycle overruns in timings |
act is too slow: something blocks, or a device is being polled on every cycle. |