Writing Control Programs
The Control Loop
AutoCore’s control program follows a familiar pattern if you have worked with PLC programs:
- The server generates a tick at a fixed interval (e.g., every 10 ms).
- On each tick, the control program:
- Reads all inputs from shared memory
- Executes your control logic (
process_tick) - Writes all outputs back to shared memory
- External modules (EtherCAT, Modbus) synchronize their I/O data with the same shared memory.
This is equivalent to a cyclic task in TwinCAT or a POU assigned to a periodic task in Codesys.
┌─────────────────────────────────────────┐
│ autocore-server │
│ │
Tick │ Shared Memory (autocore_cyclic) │
──────►│ ┌─────────┐ ┌──────────┐ ┌───────────┐ │
10ms │ │ Inputs │ │ Outputs │ │ Internal │ │
│ └────▲────┘ └────┬─────┘ └───────────┘ │
│ │ │ │
└───────┼───────────┼───────────────────────┘
│ │
┌──────┴───────────┴──────┐
│ Control Program │
│ (your program.rs) │
│ │
│ 1. Read inputs │
│ 2. Execute logic │
│ 3. Write outputs │
└──────────────────────────┘
Your First Control Program: A Counter
Let’s start with the simplest possible control program — a counter that increments every cycle. Open control/src/program.rs:
#![allow(unused)]
fn main() {
use autocore_std::{ControlProgram, TickContext};
use crate::gm::GlobalMemory;
pub struct MyControlProgram {
counter: u64,
}
impl MyControlProgram {
pub fn new() -> Self {
Self { counter: 0 }
}
}
impl ControlProgram for MyControlProgram {
type Memory = GlobalMemory;
fn initialize(&mut self, _mem: &mut Self::Memory) {
log::info!("Control program started!");
}
fn process_tick(&mut self, ctx: &mut TickContext<Self::Memory>) {
self.counter += 1;
// Log every 1000 cycles (= every 10 seconds at 10ms cycle time)
if self.counter % 1000 == 0 {
log::info!("Cycle count: {}", self.counter);
}
}
}
}
What is happening here:
MyControlProgramis a struct that holds your program’s state. Thecounterfield is internal to the control program — it is not shared with other processes.new()is called once at startup to create the program instance.initialize()is called once after the program connects to shared memory. Use it for one-time setup.process_tick()is called every cycle (every 10 ms by default). This is where all your control logic goes.ctx.gmgives you access to the global memory (shared variables).ctx.clientgives you access to the command client for sending messages to modules.ctx.cycleis the current cycle number (starts at 1).
Build and deploy:
acctl push control --start
acctl logs --follow
You should see "Control program started!" followed by "Cycle count: 1000" every 10 seconds.
Working with Variables
Variables are the bridge between your control program and the outside world. They are declared in project.json and automatically become fields on the GlobalMemory struct in your Rust code.
Let’s add some variables. Edit project.json:
{
"name": "my_first_machine",
"version": "0.1.0",
"description": "AutoCore project: my_first_machine",
"modules": {},
"control": {
"enable": true,
"source_directory": "./control",
"entry_point": "main.rs",
"signals": {
"tick": {
"description": "System Tick (10ms)",
"source": "internal",
"scan_rate_us": 10000
}
}
},
"variables": {
"cycle_counter": {
"type": "u32",
"direction": "status",
"description": "Number of cycles executed",
"initial": 0
},
"machine_running": {
"type": "bool",
"direction": "command",
"description": "Set to true to start the machine",
"initial": false
},
"motor_speed_setpoint": {
"type": "f32",
"direction": "command",
"description": "Desired motor speed in RPM",
"initial": 0.0
},
"motor_speed_actual": {
"type": "f32",
"direction": "status",
"description": "Current motor speed in RPM",
"initial": 0.0
}
}
}
After editing project.json, you need to regenerate the gm.rs file and re-push:
# Push the updated project.json to the server
acctl push project
# Regenerate the GlobalMemory struct from the new variables
acctl codegen
# Rebuild and restart the control program
acctl push control --start
The acctl codegen command reads the variables from the server and generates control/src/gm.rs, which contains a GlobalMemory struct with a field for each variable:
#![allow(unused)]
fn main() {
// This is auto-generated — do not edit!
#[repr(C)]
#[derive(Copy, Clone)]
pub struct GlobalMemory {
pub cycle_counter: u32,
pub machine_running: bool,
pub motor_speed_setpoint: f32,
pub motor_speed_actual: f32,
}
}
Now update your control program to use these variables:
#![allow(unused)]
fn main() {
use autocore_std::{ControlProgram, TickContext};
use crate::gm::GlobalMemory;
pub struct MyControlProgram;
impl MyControlProgram {
pub fn new() -> Self {
Self
}
}
impl ControlProgram for MyControlProgram {
type Memory = GlobalMemory;
fn initialize(&mut self, _mem: &mut Self::Memory) {
log::info!("Control program started!");
}
fn process_tick(&mut self, ctx: &mut TickContext<Self::Memory>) {
// Increment the cycle counter (visible from the web console)
ctx.gm.cycle_counter = ctx.gm.cycle_counter.wrapping_add(1);
// Only run logic when the machine is enabled
if ctx.gm.machine_running {
// Simulate motor speed ramping up to the setpoint
let error = ctx.gm.motor_speed_setpoint - ctx.gm.motor_speed_actual;
ctx.gm.motor_speed_actual += error * 0.01; // Simple first-order filter
} else {
ctx.gm.motor_speed_actual = 0.0;
}
}
}
}
You can now set machine_running to true and motor_speed_setpoint to 1500.0 from the web console or from the command line:
acctl cmd gm.write --name machine_running --value true
acctl cmd gm.write --name motor_speed_setpoint --value 1500
acctl cmd gm.read --name motor_speed_actual
Variable Types and Directions
Types
AutoCore supports the following variable types:
| Type | Size | Range | Equivalent in IEC 61131-3 |
|---|---|---|---|
bool | 1 byte | true / false | BOOL |
u8 | 1 byte | 0 to 255 | USINT / BYTE |
i8 | 1 byte | -128 to 127 | SINT |
u16 | 2 bytes | 0 to 65,535 | UINT / WORD |
i16 | 2 bytes | -32,768 to 32,767 | INT |
u32 | 4 bytes | 0 to 4,294,967,295 | UDINT / DWORD |
i32 | 4 bytes | -2,147,483,648 to 2,147,483,647 | DINT |
u64 | 8 bytes | 0 to 18,446,744,073,709,551,615 | ULINT / LWORD |
i64 | 8 bytes | -(2^63) to (2^63 - 1) | LINT |
f32 | 4 bytes | IEEE 754 single-precision float | REAL |
f64 | 8 bytes | IEEE 754 double-precision float | LREAL |
Tip: Use
u16ori16for Modbus registers (which are 16-bit). Useboolfor digital I/O. Usef32for analog values and setpoints. Useu32/i32for EtherCAT encoder positions and counters.
Directions
The direction field controls how a variable is used:
| Direction | Description | Who Writes | Who Reads | Typical Use |
|---|---|---|---|---|
"input" | Data from the field (sensors, encoders) | Hardware module | Control program | Sensor readings, encoder feedback |
"output" | Data to the field (actuators, motors) | Control program | Hardware module | Motor commands, valve outputs |
"command" | Data from the HMI or external system | HMI / Web client | Control program | Setpoints, start/stop commands |
"status" | Data to the HMI or external system | Control program | HMI / Web client | Display values, counters, states |
"internal" | Internal to the control program | Control program | Control program | Intermediate calculations |
Tip: If a variable is linked to hardware (has a
"link"field), use"input"for data coming from the device and"output"or"command"for data going to the device.
Links
A variable can be linked to a hardware I/O point. When a variable has a "link" field, the system automatically synchronizes it with the corresponding hardware register:
"motor_speed": {
"type": "u16",
"direction": "command",
"link": "modbus.vfd_01.holding_0",
"description": "Speed command to VFD"
}
The link format is: module_name.device_name.register_name.
Variables without a "link" are purely software variables — they exist in shared memory and can be read/written by the control program, the web console, or other processes, but they are not connected to any hardware.
Reading Inputs and Writing Outputs
Inside process_tick, you access variables directly as struct fields on ctx.gm:
#![allow(unused)]
fn main() {
fn process_tick(&mut self, ctx: &mut TickContext<Self::Memory>) {
// Reading an input (e.g., a sensor value)
let temperature = ctx.gm.temperature_sensor;
// Reading a command (e.g., a setpoint from the HMI)
let target_temp = ctx.gm.temperature_setpoint;
// Writing a status (e.g., for the HMI to display)
ctx.gm.temperature_error = target_temp - temperature;
// Writing an output (e.g., to a heater)
ctx.gm.heater_power = if temperature < target_temp { 100 } else { 0 };
}
}
There is no special API for reading or writing — variables are plain Rust fields. The ControlRunner handles all the shared memory synchronization before and after your process_tick call.
Using Logging
AutoCore provides a logging system that works inside the real-time control loop. Log messages are sent to the server and can be viewed with acctl logs.
#![allow(unused)]
fn main() {
fn process_tick(&mut self, ctx: &mut TickContext<Self::Memory>) {
// Available log levels (from least to most severe):
log::trace!("Very detailed debug info");
log::debug!("Debug information");
log::info!("Normal operational messages");
log::warn!("Warning: something unexpected");
log::error!("Error: something went wrong");
// Use format strings just like println!
log::info!("Temperature: {:.1}°C, Setpoint: {:.1}°C",
ctx.gm.temperature_sensor,
ctx.gm.temperature_setpoint
);
}
}
Warning: Logging inside
process_tickhappens on every cycle. At 100 Hz, logging every cycle would produce 100 messages per second. Use a counter or a condition to limit logging:#![allow(unused)] fn main() { // Log only every 5 seconds (500 cycles at 10ms) if ctx.cycle % 500 == 0 { log::info!("Status: speed={:.0} RPM", ctx.gm.motor_speed_actual); } // Log only when a state changes if ctx.gm.machine_running && !self.was_running { log::info!("Machine started"); } self.was_running = ctx.gm.machine_running; }