Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Writing Control Programs

The Control Loop

AutoCore’s control program follows a familiar pattern if you have worked with PLC programs:

  1. The server generates a tick at a fixed interval (e.g., every 10 ms).
  2. 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
  3. 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:

  • MyControlProgram is a struct that holds your program’s state. The counter field 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.gm gives you access to the global memory (shared variables).
  • ctx.client gives you access to the command client for sending messages to modules.
  • ctx.cycle is 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:

TypeSizeRangeEquivalent in IEC 61131-3
bool1 bytetrue / falseBOOL
u81 byte0 to 255USINT / BYTE
i81 byte-128 to 127SINT
u162 bytes0 to 65,535UINT / WORD
i162 bytes-32,768 to 32,767INT
u324 bytes0 to 4,294,967,295UDINT / DWORD
i324 bytes-2,147,483,648 to 2,147,483,647DINT
u648 bytes0 to 18,446,744,073,709,551,615ULINT / LWORD
i648 bytes-(2^63) to (2^63 - 1)LINT
f324 bytesIEEE 754 single-precision floatREAL
f648 bytesIEEE 754 double-precision floatLREAL

Tip: Use u16 or i16 for Modbus registers (which are 16-bit). Use bool for digital I/O. Use f32 for analog values and setpoints. Use u32/i32 for EtherCAT encoder positions and counters.

Directions

The direction field controls how a variable is used:

DirectionDescriptionWho WritesWho ReadsTypical Use
"input"Data from the field (sensors, encoders)Hardware moduleControl programSensor readings, encoder feedback
"output"Data to the field (actuators, motors)Control programHardware moduleMotor commands, valve outputs
"command"Data from the HMI or external systemHMI / Web clientControl programSetpoints, start/stop commands
"status"Data to the HMI or external systemControl programHMI / Web clientDisplay values, counters, states
"internal"Internal to the control programControl programControl programIntermediate 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.

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_tick happens 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;
}