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

AutoCore User Manual

Welcome to AutoCore

What Is AutoCore?

AutoCore is an industrial automation platform that runs on standard PC hardware. If you have used TwinCAT, Codesys, or acontis, you can think of AutoCore as a modern alternative that replaces proprietary IDEs and runtimes with open tools and standard programming languages.

With AutoCore you can:

  • Write control programs in Rust that execute in a deterministic, real-time loop (1kHz - 4kHz or faster).
  • Connect to field devices via EtherCAT and Modbus TCP, with the same kind of cyclic I/O exchange you are familiar with from TwinCAT.
  • Build web-based HMIs using React and TypeScript instead of proprietary visualization tools.
  • Deploy and monitor using a simple command-line tool (acctl) that works over the network.

AutoCore runs on Linux. Your development machine can be either an Ubuntu desktop or a Windows 11 Pro machine using WSL2 (Windows Subsystem for Linux).

How AutoCore Compares to Traditional PLCs

If you are coming from a TwinCAT or Codesys background, this table will help you map familiar concepts:

TwinCAT / CodesysAutoCoreNotes
TwinCAT XAE (Visual Studio)VS Code + Rust + acctlYou write code in any editor; acctl handles build and deploy
PLC Runtimeautocore-serverThe server process that manages the control loop, I/O, and communication
PLC Program (ST/FBD/LD)control/src/program.rsYour control logic, written in Rust
Global Variable List (GVL)project.json variables + gm.rsVariables are declared in JSON; a Rust struct is auto-generated
I/O Configuration (XAE)project.json modules sectionEtherCAT slaves, Modbus devices, etc. are configured in JSON
EtherCAT Masterautocore-ethercat moduleRuns as a separate process; maps I/O into shared memory
Modbus TCP Clientautocore-modbus moduleSame pattern — separate process, shared memory I/O
TwinCAT HMI / Visualizationwww/ directory (React app)Web-based HMI accessible from any browser
ADS ProtocolWebSocket JSON APIAll monitoring and HMI communication uses WebSockets
TcSysManageracctl CLIProject creation, deployment, status, log streaming
Scan cycle / task cycleTick signalServer-generated timing signal, configurable in microseconds

Key Concepts

Before you begin, here are the terms you will encounter throughout this manual:

  • autocore-server: The main process that manages everything — shared memory, the tick signal, communication, modules, and the web interface.
  • Control program: Your Rust application that runs the real-time control logic. It is a separate process from the server, synchronized via shared memory.
  • acctl: The command-line tool you use to create projects, deploy code, monitor logs, and manage the server.
  • project.json: The single configuration file that defines your entire automation project — variables, hardware modules, cycle time, and more.
  • Global Memory (GM): A shared memory region that all processes (control program, EtherCAT driver, Modbus driver, etc.) can read from and write to with zero-copy performance.
  • Tick: A periodic timing signal generated by the server. Your control program executes one cycle per tick.
  • Module: An external process (EtherCAT master, Modbus client, camera driver, etc.) that connects to the server and exchanges data through shared memory and IPC.
  • Variable: A named piece of data in global memory. Variables have a type (e.g., u16, bool, f32), a direction (input, output, command, status, internal), and optionally a link to hardware I/O.
  • FQDN (Fully Qualified Domain Name): The address of any resource in the system, using dot-separated segments. For example, gm.motor_speed addresses the motor_speed variable, and ethercat.drive_0.rxpdo_1.controlword addresses a specific EtherCAT PDO entry.

Setting Up Your Development Machine

AutoCore runs on Linux. You have two options for your development machine:

  • Windows 11 Pro: Use WSL2 to run Ubuntu inside Windows. This is the recommended setup for most users.
  • Ubuntu Desktop: Install directly on an Ubuntu 22.04 or 24.04 machine.

Both paths result in the same development environment. Follow the section that matches your machine.

Option A: Windows 11 Pro with WSL2

WSL2 (Windows Subsystem for Linux) lets you run a full Linux environment inside Windows. AutoCore development works entirely within WSL2. Windows 10 is not supported — you need Windows 11.

Important: WSL2 runs on top of the Windows hypervisor. It is suitable for development, compilation, and logic testing, but it is not suitable for real-time production control. Your production target should be a dedicated Linux PC (see Option B for target machine setup).

Step 1: Enable WSL2

Open PowerShell as Administrator and run:

wsl --install

This installs WSL2 with Ubuntu 24.04 as the default distribution. When it finishes, restart your computer.

After restarting, the Ubuntu terminal will open automatically. It will ask you to create a username and password — these are for your Linux environment only and do not need to match your Windows credentials.

Tip: If you already have WSL1 installed, upgrade to WSL2 with:

wsl --set-default-version 2

Step 2: Create a Dedicated WSL2 Instance

To keep your AutoCore development environment isolated from your base Ubuntu install, create a dedicated WSL2 instance. This way, the custom kernel and any driver changes won’t affect other WSL distributions you may be using.

Open PowerShell and run:

# Create directories for WSL management
mkdir $HOME\wsl_backups
mkdir $HOME\wsl_instances
mkdir $HOME\wsl_kernels

# Export your base Ubuntu as a backup, then import as a new instance
wsl --export Ubuntu-24.04 $HOME\wsl_backups\ubuntu_base.tar
wsl --import AutoCore-Dev $HOME\wsl_instances\AutoCore $HOME\wsl_backups\ubuntu_base.tar

By default, imported instances log in as root. Fix this by setting your default user:

wsl -d AutoCore-Dev

Inside the WSL terminal:

sudo nano /etc/wsl.conf

Add the following (replace <your_username> with the username you created during Ubuntu setup):

[user]
default=<your_username>

Save and exit (Ctrl+O, Enter, Ctrl+X). Then in PowerShell, restart the instance:

wsl --terminate AutoCore-Dev

From now on, launch your development environment with:

wsl -d AutoCore-Dev

Tip: If you prefer to skip the dedicated instance and just use your default Ubuntu install, that works too — just skip this step and continue with Step 3.

Step 3: Update Ubuntu and Install Dependencies

In the WSL2 terminal (either your dedicated AutoCore-Dev instance or default Ubuntu):

sudo apt update && sudo apt upgrade -y
sudo apt install -y build-essential pkg-config libssl-dev git curl \
    flex bison libelf-dev bc dwarves python3 kmod rsync

The extra packages (flex, bison, libelf-dev, etc.) are needed if you plan to build the custom kernel or EtherCAT master.

Step 4: Set Up Your Code Editor

Visual Studio Code is the recommended editor. Install it on Windows (not inside WSL), then install the WSL extension from Microsoft. This lets VS Code edit files inside your Linux environment seamlessly.

After installing the WSL extension, open the WSL terminal and type:

code .

This opens VS Code connected to your WSL2 environment. From here, you can edit files, open terminals, and use extensions — all running on the Linux side.

Recommended VS Code extensions (install inside WSL when prompted):

  • rust-analyzer: Rust language support
  • Even Better TOML: Syntax highlighting for Cargo.toml files
  • Error Lens: Shows errors inline in the editor

Installing the Custom WSL2 Kernel

AutoCore provides a pre-built custom WSL2 kernel that enables loadable kernel modules (LKM). The stock WSL2 kernel has restricted module support, which prevents the EtherCAT master and other kernel drivers from loading. Even if you do not plan to use EtherCAT from WSL2, the custom kernel is recommended for full compatibility with AutoCore.

If you received the pre-built kernel file (bzImage) from your AutoCore distribution:

  1. Copy the kernel file to your Windows user folder:
# In PowerShell
copy <path-to-bzImage> $HOME\wsl_kernels\bzImage
  1. Skip to Configure WSL2 to Use the Custom Kernel below.

Option 2: Build the Kernel from Source

If you need to build the kernel yourself (e.g., for a specific kernel version):

  1. Clone the WSL2 kernel source. Check your current version with uname -r, then clone the matching branch:
git clone --depth 1 -b linux-msft-wsl-6.6.y https://github.com/microsoft/WSL2-Linux-Kernel.git
cd WSL2-Linux-Kernel
  1. Configure for module support:
cp Microsoft/config-wsl .config
./scripts/config --enable CONFIG_MODULES
./scripts/config --enable CONFIG_MODULE_UNLOAD
./scripts/config --enable CONFIG_MODVERSIONS
./scripts/config --set-str CONFIG_LOCALVERSION "-autocore"
  1. Build the kernel and modules:
make -j$(nproc)
sudo make modules_install
sudo make install

This takes 10-30 minutes depending on your machine.

  1. Copy the kernel image to Windows:
cp arch/x86/boot/bzImage /mnt/c/Users/<Windows_User>/wsl_kernels/bzImage

Replace <Windows_User> with your actual Windows username (check with ls /mnt/c/Users/).

Configure WSL2 to Use the Custom Kernel

On Windows, create or edit the file C:\Users\<Windows_User>\.wslconfig:

[wsl2]
kernel=C:\\Users\\<Windows_User>\\wsl_kernels\\bzImage
networkingMode=mirrored

The networkingMode=mirrored setting is important — it makes your physical Windows Ethernet adapters visible inside WSL2 with their real MAC addresses. This is required for EtherCAT development and also simplifies accessing the AutoCore web console.

Now restart WSL entirely from PowerShell:

wsl --shutdown

Then re-launch your instance:

wsl -d AutoCore-Dev

Verify the custom kernel is running:

uname -a

You should see output like:

Linux YOURPC 6.6.114.1-autocore+ #2 SMP PREEMPT_DYNAMIC ... x86_64 GNU/Linux

The -autocore (or -ethercat-local) suffix confirms you are running the custom kernel.

Configure Networking (WSL2)

With networkingMode=mirrored in your .wslconfig, your WSL2 instance shares the host’s network interfaces. This means:

  • You can access the AutoCore web console at http://localhost:8080 directly from Windows.
  • The acctl tool can reach remote AutoCore servers on your network without any port forwarding.
  • Physical Ethernet adapters are visible for EtherCAT (see below).

If you are not using mirrored mode, WSL2 has its own IP address. Find it with:

hostname -I

Then access the web console at http://<WSL2_IP>:8080 from Windows.

Setting Up EtherCAT in WSL2 (Optional)

If you plan to connect to physical EtherCAT hardware from your Windows development machine (for testing and commissioning), follow these steps. If you are only writing and compiling control programs and will deploy to a separate target machine, you can skip this section.

Reminder: EtherCAT from WSL2 is for development and testing only. Production systems should run on a dedicated Linux PC with a real-time kernel.

Step 1: Build and Install the EtherLab EtherCAT Master

With the custom kernel running, compile the EtherCAT master against the kernel source:

# Clone the EtherLab repository
git clone https://gitlab.com/etherlab.org/ethercat.git
cd ethercat
./bootstrap

# Configure — WSL2 requires the generic driver (no direct PCI access)
./configure --prefix=/opt/etherlab \
    --sysconfdir=/etc \
    --disable-8139too \
    --enable-generic \
    --with-linux-dir=$HOME/WSL2-Linux-Kernel

# Build and install
make -j$(nproc)
make modules
sudo make install
sudo make modules_install
sudo depmod -a

Step 2: Configure the EtherCAT Master

Edit the configuration file:

sudo nano /etc/ethercat.conf

Set the following (you will update MASTER0_DEVICE later when connecting a USB Ethernet adapter):

MASTER0_DEVICE=""
DEVICE_MODULES="generic"

Step 3: Enable Non-Root Access

By default, only root can access the EtherCAT master device. Create a udev rule to allow your user:

echo 'KERNEL=="EtherCAT[0-9]*", MODE="0666"' | sudo tee /etc/udev/rules.d/99-ethercat.rules

Enable the EtherCAT service to start automatically:

sudo systemctl enable ethercat

Connecting USB Ethernet to WSL2 for EtherCAT

EtherCAT requires a dedicated Ethernet interface. In WSL2, the most reliable way to provide this is with a USB-to-Ethernet adapter passed through from Windows using usbipd-win.

First-Time Setup (Windows)

  1. Install usbipd-win on Windows. Download the latest .msi from: https://github.com/dorssel/usbipd-win/releases

    Run the installer and restart if prompted.

  2. Build the usbip kernel modules inside WSL2 (needed for the custom kernel):

# Navigate to the USB tools in the kernel source
cd ~/WSL2-Linux-Kernel/tools/usb/usbip

# Build and install
./autogen.sh
./configure
make -j$(nproc)
sudo make install
sudo ldconfig

# Install USB utilities
sudo apt install -y usbutils

Connecting the Adapter

Each time you want to use EtherCAT, you need to attach the USB adapter to WSL2. This process has a Windows side and a Linux side.

On Windows (PowerShell as Administrator):

  1. Plug in your USB-to-Ethernet adapter.

  2. List USB devices to find the adapter’s bus ID:

usbipd list

Example output:

Connected:
BUSID  VID:PID    DEVICE                                    STATE
2-4    0bda:8153  Realtek USB GbE Family Controller          Not shared
6-7    06cb:00f9  Synaptics UWP WBDI                         Not shared
...
  1. Bind and attach the adapter (using the BUSID from above):
usbipd bind --busid 2-4
usbipd attach --wsl --busid 2-4

Note the IP address printed in the output — you may need it if the automatic attachment fails.

In WSL2:

  1. Load the USB host controller module (needed on first use after each WSL restart):
sudo modprobe vhci-hcd

If modprobe fails, you may need to manually attach. Use the IP address from the PowerShell output:

sudo usbip attach -r <IP_FROM_POWERSHELL> -b 2-4
  1. Verify the adapter is visible:
ip link

You should see a new interface (e.g., enx6c6e0719971b or enpXs0):

1: lo: <LOOPBACK,UP,LOWER_UP> ...
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> ...
3: enx6c6e0719971b: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN ...
    link/ether 6c:6e:07:19:97:1b brd ff:ff:ff:ff:ff:ff
  1. Bring the interface up:
sudo ip link set enx6c6e0719971b up
  1. Update the EtherCAT master configuration with the adapter name:
sudo nano /etc/ethercat.conf

Set the device to the adapter name from ip link:

MASTER0_DEVICE="enx6c6e0719971b"
DEVICE_MODULES="generic"
  1. Start (or restart) the EtherCAT service:
sudo systemctl restart ethercat
  1. Verify it is working:
ethercat master
ethercat slaves

You should see your EtherCAT master status and any connected slaves:

Master0
  Phase: Idle
  Active: no
  Slaves: 1
  Ethernet devices:
    Main: 6c:6e:07:19:97:1b (attached)
      Link: UP
      ...

Tip: The USB adapter attachment does not persist across WSL restarts. After a wsl --shutdown or system reboot, you will need to re-run the usbipd attach command from PowerShell and the modprobe / ip link set up commands from WSL2.

Now continue to Installing the Rust Toolchain.

Option B: Ubuntu Desktop

If you are using a native Ubuntu 22.04 or 24.04 installation, the setup is straightforward.

Step 1: Update Your System

sudo apt update && sudo apt upgrade -y

Step 2: Install Build Dependencies

sudo apt install -y build-essential pkg-config libssl-dev git curl

Step 3: Set Up Your Code Editor

Install Visual Studio Code:

sudo snap install code --classic

Or download it from the VS Code website and install with:

sudo dpkg -i code_*.deb
sudo apt install -f

Recommended VS Code extensions:

  • rust-analyzer: Rust language support
  • Even Better TOML: Syntax highlighting for Cargo.toml files
  • Error Lens: Shows errors inline in the editor

Now continue to Installing the Rust Toolchain.

Installing the Rust Toolchain

AutoCore control programs are written in Rust. Install the Rust toolchain using rustup, the official installer:

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

When prompted, select the default installation (option 1). After installation completes, load the new environment:

source "$HOME/.cargo/env"

Verify the installation:

rustc --version
cargo --version

You should see version numbers for both. The minimum supported Rust version for AutoCore is 1.85.0 (Rust 2024 edition).

What is Cargo? Cargo is Rust’s build tool and package manager — similar to npm for JavaScript or pip for Python. You will use cargo build to compile control programs and cargo install to install tools like acctl.

Installing Node.js (for Web HMI Development)

If you plan to build a web-based HMI for your machine, you will need Node.js. If you only need to write control programs, you can skip this step.

Install Node.js using the NodeSource repository:

curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install -y nodejs

Verify:

node --version
npm --version

Installing AutoCore

AutoCore consists of two components you install on your development machine:

  1. autocore-server — the runtime engine
  2. acctl — the command-line project management tool

If you received a .deb package file:

sudo dpkg -i autocore_server_*.deb
sudo apt install -f   # Install any missing dependencies

This installs:

  • The server binary to /opt/autocore/bin/autocore_server
  • Module binaries (EtherCAT, Modbus) to /opt/autocore/bin/modules/
  • The standard library to /srv/autocore/lib/autocore-std/
  • The web console to /srv/autocore/console/
  • A systemd service for automatic startup
  • Default configuration to /opt/autocore/config/config.ini

Enable and start the server:

sudo systemctl enable autocore_server
sudo systemctl start autocore_server

Installing acctl

The acctl CLI tool is installed separately using Cargo:

cargo install --path /path/to/autocore-server/acctl

Or, if you received acctl as a standalone package:

cargo install acctl

Manual Configuration (Development Setup)

If you are building from source or using a development setup, you need a config.ini file. Create one at /opt/autocore/config/config.ini (or run the server with --config /path/to/config.ini):

[console]
port = 11969
www_root = /srv/autocore/console/dist

[general]
projects_directory = /srv/autocore/projects
module_base_directory = /opt/autocore/bin/modules
port = 8080
autocore_std_directory = /srv/autocore/lib/autocore-std
disable_ads = 1
ipc_port = 9100
project_name = default

[modules]
modbus = ${general.module_base_directory}/autocore-modbus
ethercat = ${general.module_base_directory}/autocore-ethercat
SettingDescription
console.portWebSocket port for CLI and web clients
console.www_rootPath to the web console static files
general.projects_directoryRoot directory where all projects are stored
general.portHTTP port for the web server
general.autocore_std_directoryPath to the autocore-std library (used for building control programs on the server)
general.ipc_portTCP port for module IPC communication
general.project_nameThe project to load on startup
modules.*Paths to module executables

Verifying Your Installation

Run these commands to confirm everything is working:

# Check the Rust toolchain
rustc --version
cargo --version

# Check acctl
acctl --help

# Check if the server is running
sudo systemctl status autocore_server

# Check server status via acctl (if server is running locally)
acctl status

If acctl status shows the server version and a list of projects, your installation is complete.


Your First Project

Creating a Project

Use acctl new to create a new project:

acctl new my_first_machine
cd my_first_machine

This creates a complete project with all the files you need:

my_first_machine/
├── project.json              # Project configuration
├── control/                  # Your control program (Rust)
│   ├── Cargo.toml           # Rust package manifest
│   └── src/
│       ├── main.rs          # Entry point (auto-generated — do not edit)
│       ├── program.rs       # Your control logic (edit this!)
│       └── gm.rs            # Generated memory mappings
├── www/                      # Web HMI (React + TypeScript)
│   ├── package.json
│   ├── index.html
│   ├── vite.config.ts
│   └── src/
│       ├── main.tsx
│       ├── App.tsx
│       └── ...
├── datastore/                # Persistent storage
│   └── autocore_gnv.ini
└── .gitignore

A git repository is also initialized automatically.

Understanding the Project Structure

Directory / FilePurposeWhen You Edit It
project.jsonDefines variables, hardware modules, cycle timeWhen adding variables, changing cycle time, or configuring hardware
control/src/program.rsYour control logicThis is where you spend most of your time
control/src/main.rsEntry point — connects to the serverNever (auto-generated)
control/src/gm.rsRust struct mapping your variablesNever (auto-generated by acctl codegen)
control/Cargo.tomlRust dependenciesWhen adding external Rust libraries
www/Web-based HMIWhen building operator screens
datastore/Non-volatile storage (persists across restarts)Managed by the server; you read/write via commands

The project.json File

The project.json file is the heart of your project. Here is the default one that acctl new generates:

{
  "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": {}
}

Let’s break down each section:

control — Configures the control program execution:

FieldDescriptionExample
enableWhether the control program should runtrue
source_directoryPath to the Rust source code"./control"
entry_pointThe main Rust file"main.rs"
signals.tick.scan_rate_usCycle time in microseconds10000 (= 10 ms = 100 Hz)
signals.tick.sourceWhere the tick comes from"internal" (server-generated)

Common cycle times:

scan_rate_usCycle TimeFrequencyTypical Use
10001 ms1 kHzHigh-speed motion control
20002 ms500 HzServo drives
50005 ms200 HzGeneral motion
1000010 ms100 HzProcess control, I/O
5000050 ms20 HzSlow processes, monitoring

variables — Defines all the data points in your system. We will cover this in detail in Working with Variables.

modules — Configures hardware interface modules (EtherCAT, Modbus, etc.). We will cover this in the hardware integration chapters.

Building and Running Locally

If you are running the AutoCore server on your development machine (which is the typical development workflow):

# Step 1: Push the project configuration to the server
acctl push project

# Step 2: Build and deploy the control program, then start it
acctl push control --start

The push control command:

  1. Compiles your Rust control program
  2. Uploads the binary to the server
  3. With --start, starts the control program immediately

If you only want to build without starting:

acctl push control

Then start it separately:

acctl control start

Viewing Logs

Your control program’s log output is captured by the server and can be viewed with:

# Show recent logs
acctl logs

# Stream logs in real time (like tail -f)
acctl logs --follow

Press Ctrl+C to stop streaming.

You can also check the control program’s status:

acctl control status

This shows whether the program is running, stopped, or has encountered an error.


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

Control Program Patterns and Examples

This chapter covers common patterns you will use in nearly every control program. If you have written PLC programs in Structured Text, you will recognize most of these.

State Machines

State machines are the most common pattern in machine control. In AutoCore, you define states as a Rust enum and transition between them in process_tick:

#![allow(unused)]
fn main() {
use autocore_std::{ControlProgram, TickContext};
use crate::gm::GlobalMemory;

#[derive(Debug, Clone, Copy, PartialEq)]
enum MachineState {
    Idle,
    Homing,
    Ready,
    Running,
    Stopping,
    Faulted,
}

pub struct MyControlProgram {
    state: MachineState,
    prev_state: MachineState,
}

impl MyControlProgram {
    pub fn new() -> Self {
        Self {
            state: MachineState::Idle,
            prev_state: MachineState::Idle,
        }
    }
}

impl ControlProgram for MyControlProgram {
    type Memory = GlobalMemory;

    fn initialize(&mut self, _mem: &mut Self::Memory) {
        log::info!("Machine starting in Idle state");
    }

    fn process_tick(&mut self, ctx: &mut TickContext<Self::Memory>) {
        // Log state changes
        if self.state != self.prev_state {
            log::info!("State: {:?} -> {:?}", self.prev_state, self.state);
            self.prev_state = self.state;
        }

        match self.state {
            MachineState::Idle => {
                ctx.gm.status_code = 0;
                if ctx.gm.cmd_start {
                    self.state = MachineState::Homing;
                }
            }
            MachineState::Homing => {
                ctx.gm.status_code = 1;
                // Perform homing sequence...
                // When complete:
                self.state = MachineState::Ready;
            }
            MachineState::Ready => {
                ctx.gm.status_code = 2;
                if ctx.gm.cmd_run {
                    self.state = MachineState::Running;
                }
            }
            MachineState::Running => {
                ctx.gm.status_code = 3;
                // Main production logic here...

                if ctx.gm.cmd_stop {
                    self.state = MachineState::Stopping;
                }
                if ctx.gm.emergency_stop {
                    self.state = MachineState::Faulted;
                }
            }
            MachineState::Stopping => {
                ctx.gm.status_code = 4;
                // Decelerate, finish current operation...
                // When stopped:
                self.state = MachineState::Idle;
            }
            MachineState::Faulted => {
                ctx.gm.status_code = 99;
                ctx.gm.motor_enable = false;
                if ctx.gm.cmd_reset {
                    self.state = MachineState::Idle;
                }
            }
        }
    }
}
}

Corresponding variables in project.json:

"variables": {
  "cmd_start":      { "type": "bool", "direction": "command", "description": "Start command from HMI" },
  "cmd_run":        { "type": "bool", "direction": "command", "description": "Run command from HMI" },
  "cmd_stop":       { "type": "bool", "direction": "command", "description": "Stop command from HMI" },
  "cmd_reset":      { "type": "bool", "direction": "command", "description": "Reset faults from HMI" },
  "emergency_stop": { "type": "bool", "direction": "input",   "description": "Emergency stop input" },
  "motor_enable":   { "type": "bool", "direction": "output",  "description": "Motor enable output" },
  "status_code":    { "type": "i32",  "direction": "status",  "description": "Machine state code" }
}

Edge Detection (Rising and Falling Triggers)

AutoCore provides function blocks for detecting signal transitions, just like R_TRIG and F_TRIG in IEC 61131-3:

#![allow(unused)]
fn main() {
use autocore_std::fb::{RTrig, FTrig};

pub struct MyControlProgram {
    start_trigger: RTrig,   // Detects false → true
    stop_trigger: FTrig,    // Detects true → false
    part_counter: u32,
}

impl MyControlProgram {
    pub fn new() -> Self {
        Self {
            start_trigger: RTrig::new(),
            stop_trigger: FTrig::new(),
            part_counter: 0,
        }
    }
}

impl ControlProgram for MyControlProgram {
    type Memory = GlobalMemory;

    fn process_tick(&mut self, ctx: &mut TickContext<Self::Memory>) {
        // Rising edge: fires once when start_button goes from false to true
        if self.start_trigger.call(ctx.gm.start_button) {
            log::info!("Start button pressed!");
            // This runs exactly once per button press
        }

        // Falling edge: fires once when sensor goes from true to false
        if self.stop_trigger.call(ctx.gm.part_sensor) {
            self.part_counter += 1;
            log::info!("Part detected! Count: {}", self.part_counter);
        }

        ctx.gm.part_count = self.part_counter;
    }
}
}

How RTrig works:

Previous ValueCurrent ValueOutput
falsefalsefalse
falsetruetrue (rising edge!)
truetruefalse
truefalsefalse

How FTrig works:

Previous ValueCurrent ValueOutput
truetruefalse
truefalsetrue (falling edge!)
falsefalsefalse
falsetruefalse

Timers

The Ton (Timer On Delay) function block works like TON in IEC 61131-3. The output becomes true after the input has been true for a specified duration:

#![allow(unused)]
fn main() {
use autocore_std::fb::Ton;
use std::time::Duration;

pub struct MyControlProgram {
    startup_delay: Ton,
    fault_timer: Ton,
}

impl MyControlProgram {
    pub fn new() -> Self {
        Self {
            startup_delay: Ton::new(),
            fault_timer: Ton::new(),
        }
    }
}

impl ControlProgram for MyControlProgram {
    type Memory = GlobalMemory;

    fn process_tick(&mut self, ctx: &mut TickContext<Self::Memory>) {
        // Wait 3 seconds after machine_running becomes true
        // before enabling the motor
        let delay_done = self.startup_delay.call(
            ctx.gm.machine_running,
            Duration::from_secs(3),
        );
        ctx.gm.motor_enable = delay_done;

        // If temperature is too high for more than 5 seconds, raise an alarm
        let overtemp = ctx.gm.temperature > 80.0;
        let alarm = self.fault_timer.call(overtemp, Duration::from_secs(5));
        ctx.gm.temperature_alarm = alarm;

        // You can also read the elapsed time
        if overtemp && !alarm {
            log::warn!(
                "Temperature high for {:.1}s (alarm at 5.0s)",
                self.fault_timer.et.as_secs_f64()
            );
        }
    }
}
}

Ton behavior:

InputDurationTimer StateOutput (q)Elapsed (et)
falseanyResetfalse0
true3sCountingfalse0..3s
true (after 3s)3sDonetrue3s
false (any time)anyResetfalse0

Combining Patterns: A Complete Machine Example

Here is a more realistic example that combines state machines, edge detection, and timers to control a simple pick-and-place machine:

project.json variables:

"variables": {
  "cmd_start":          { "type": "bool", "direction": "command", "description": "Start button" },
  "cmd_stop":           { "type": "bool", "direction": "command", "description": "Stop button" },
  "cmd_reset":          { "type": "bool", "direction": "command", "description": "Reset faults" },
  "part_present":       { "type": "bool", "direction": "input",   "description": "Part sensor at pick position" },
  "cylinder_extended":  { "type": "bool", "direction": "input",   "description": "Cylinder extended sensor" },
  "cylinder_retracted": { "type": "bool", "direction": "input",   "description": "Cylinder retracted sensor" },
  "gripper_closed":     { "type": "bool", "direction": "input",   "description": "Gripper closed sensor" },
  "extend_cylinder":    { "type": "bool", "direction": "output",  "description": "Cylinder extend solenoid" },
  "close_gripper":      { "type": "bool", "direction": "output",  "description": "Gripper close solenoid" },
  "conveyor_run":       { "type": "bool", "direction": "output",  "description": "Conveyor motor" },
  "parts_completed":    { "type": "u32",  "direction": "status",  "description": "Total parts completed" },
  "machine_state":      { "type": "i32",  "direction": "status",  "description": "Current state code" },
  "fault_active":       { "type": "bool", "direction": "status",  "description": "Fault is active" }
}

control/src/program.rs:

#![allow(unused)]
fn main() {
use autocore_std::{ControlProgram, TickContext};
use autocore_std::fb::{RTrig, Ton};
use std::time::Duration;
use crate::gm::GlobalMemory;

#[derive(Debug, Clone, Copy, PartialEq)]
enum State {
    Idle,           // 0: Waiting for start
    WaitForPart,    // 1: Conveyor running, waiting for a part
    Extend,         // 2: Extending cylinder to pick position
    WaitExtended,   // 3: Waiting for cylinder to reach
    Grip,           // 4: Closing gripper
    WaitGripped,    // 5: Waiting for gripper to close
    Retract,        // 6: Retracting cylinder
    WaitRetracted,  // 7: Waiting for cylinder to retract
    Release,        // 8: Opening gripper to release part
    WaitReleased,   // 9: Waiting for gripper to open
    Fault,          // 99: Fault condition
}

pub struct MyControlProgram {
    state: State,
    prev_state: State,
    start_trig: RTrig,
    reset_trig: RTrig,
    timeout: Ton,
    parts_done: u32,
}

impl MyControlProgram {
    pub fn new() -> Self {
        Self {
            state: State::Idle,
            prev_state: State::Idle,
            start_trig: RTrig::new(),
            reset_trig: RTrig::new(),
            timeout: Ton::new(),
            parts_done: 0,
        }
    }

    fn go_to(&mut self, new_state: State) {
        self.state = new_state;
    }

    fn fault(&mut self, reason: &str) {
        log::error!("FAULT: {}", reason);
        self.state = State::Fault;
    }
}

impl ControlProgram for MyControlProgram {
    type Memory = GlobalMemory;

    fn initialize(&mut self, _mem: &mut Self::Memory) {
        log::info!("Pick-and-place machine initialized");
    }

    fn process_tick(&mut self, ctx: &mut TickContext<Self::Memory>) {
        // Log state transitions
        if self.state != self.prev_state {
            log::info!("State: {:?} -> {:?}", self.prev_state, self.state);
            self.prev_state = self.state;
        }

        // Edge triggers
        let start_pressed = self.start_trig.call(ctx.gm.cmd_start);
        let reset_pressed = self.reset_trig.call(ctx.gm.cmd_reset);

        // Global timeout: if any wait state takes longer than 10 seconds, fault
        let waiting = matches!(
            self.state,
            State::WaitExtended | State::WaitGripped |
            State::WaitRetracted | State::WaitReleased
        );
        if self.timeout.call(waiting, Duration::from_secs(10)) {
            self.fault("Operation timed out");
        }

        // Global stop
        if ctx.gm.cmd_stop && self.state != State::Idle && self.state != State::Fault {
            log::info!("Stop requested");
            self.go_to(State::Idle);
        }

        // State machine
        match self.state {
            State::Idle => {
                ctx.gm.extend_cylinder = false;
                ctx.gm.close_gripper = false;
                ctx.gm.conveyor_run = false;
                if start_pressed {
                    self.go_to(State::WaitForPart);
                }
            }

            State::WaitForPart => {
                ctx.gm.conveyor_run = true;
                if ctx.gm.part_present {
                    ctx.gm.conveyor_run = false;
                    self.go_to(State::Extend);
                }
            }

            State::Extend => {
                ctx.gm.extend_cylinder = true;
                self.go_to(State::WaitExtended);
            }

            State::WaitExtended => {
                if ctx.gm.cylinder_extended {
                    self.go_to(State::Grip);
                }
            }

            State::Grip => {
                ctx.gm.close_gripper = true;
                self.go_to(State::WaitGripped);
            }

            State::WaitGripped => {
                if ctx.gm.gripper_closed {
                    self.go_to(State::Retract);
                }
            }

            State::Retract => {
                ctx.gm.extend_cylinder = false;
                self.go_to(State::WaitRetracted);
            }

            State::WaitRetracted => {
                if ctx.gm.cylinder_retracted {
                    self.go_to(State::Release);
                }
            }

            State::Release => {
                ctx.gm.close_gripper = false;
                self.go_to(State::WaitReleased);
            }

            State::WaitReleased => {
                if !ctx.gm.gripper_closed {
                    self.parts_done += 1;
                    ctx.gm.parts_completed = self.parts_done;
                    log::info!("Part complete! Total: {}", self.parts_done);
                    self.go_to(State::WaitForPart);
                }
            }

            State::Fault => {
                // Turn off all outputs
                ctx.gm.extend_cylinder = false;
                ctx.gm.close_gripper = false;
                ctx.gm.conveyor_run = false;

                if reset_pressed {
                    log::info!("Fault reset");
                    self.go_to(State::Idle);
                }
            }
        }

        // Update status outputs
        ctx.gm.machine_state = match self.state {
            State::Idle => 0,
            State::WaitForPart => 1,
            State::Extend | State::WaitExtended => 2,
            State::Grip | State::WaitGripped => 3,
            State::Retract | State::WaitRetracted => 4,
            State::Release | State::WaitReleased => 5,
            State::Fault => 99,
        };
        ctx.gm.fault_active = self.state == State::Fault;
    }
}
}

Hardware Integration: Modbus TCP

Modbus Overview

Modbus TCP is one of the most common industrial communication protocols. If you have used Modbus with TwinCAT or any other PLC, the concepts are the same — holding registers, input registers, coils, and discrete inputs.

In AutoCore, Modbus communication is handled by the autocore-modbus module. This module:

  1. Runs as a separate process
  2. Connects to your Modbus TCP devices
  3. Cyclically reads and writes registers
  4. Maps register data into shared memory

Your control program reads and writes Modbus data through variables, just like any other I/O.

Configuring a Modbus Device

Add the Modbus module to your project.json:

{
  "name": "modbus_example",
  "version": "0.1.0",
  "description": "Modbus TCP example",
  "control": {
    "enable": true,
    "source_directory": "./control",
    "entry_point": "main.rs",
    "signals": {
      "tick": {
        "source": "internal",
        "scan_rate_us": 10000
      }
    }
  },
  "modules": {
    "modbus": {
      "enabled": true,
      "args": ["service"],
      "config": {
        "devices": [
          {
            "name": "sensor_unit",
            "type": "modbus_tcp",
            "host": "192.168.1.100",
            "port": 502,
            "slave_id": 1,
            "scan_rate_ms": 100,
            "registers": [
              {
                "name": "temperature",
                "type": "input_register",
                "address": 0,
                "count": 1
              },
              {
                "name": "humidity",
                "type": "input_register",
                "address": 1,
                "count": 1
              },
              {
                "name": "setpoint",
                "type": "holding_register",
                "address": 0,
                "count": 1
              }
            ]
          }
        ]
      }
    }
  },
  "variables": {
    "temperature_raw": {
      "type": "u16",
      "direction": "input",
      "link": "modbus.sensor_unit.temperature",
      "description": "Raw temperature reading (0.1°C per count)"
    },
    "humidity_raw": {
      "type": "u16",
      "direction": "input",
      "link": "modbus.sensor_unit.humidity",
      "description": "Raw humidity reading (0.1% per count)"
    },
    "setpoint_raw": {
      "type": "u16",
      "direction": "command",
      "link": "modbus.sensor_unit.setpoint",
      "description": "Temperature setpoint (0.1°C per count)"
    }
  }
}

Key configuration fields:

FieldDescription
modules.modbus.enabledSet to true to enable the Modbus module
modules.modbus.argsMust include "service" for the Modbus module
config.devices[].nameA friendly name for the device (used in variable links)
config.devices[].hostIP address of the Modbus TCP device
config.devices[].portTCP port (usually 502)
config.devices[].slave_idModbus unit ID (1-247)
config.devices[].registers[].type"holding_register", "input_register", "coil", or "discrete_input"
config.devices[].registers[].addressThe register address (0-based)

Linking Variables to Modbus Registers

The "link" field in a variable definition connects it to a Modbus register. The format is:

modbus.<device_name>.<register_name>

For example, if your device is named "sensor_unit" and the register is named "temperature", the link is "modbus.sensor_unit.temperature".

The direction of the variable determines the data flow:

Variable DirectionModbus Behavior
"input"Module reads from the device and writes to shared memory. Your control program reads it.
"command" or "output"Your control program writes to shared memory. Module reads it and writes to the device.

Example: Reading a Temperature Sensor

This example reads a temperature and humidity sensor via Modbus TCP and converts the raw values to engineering units.

project.json variables (using the Modbus config above):

"variables": {
  "temperature_raw": {
    "type": "u16",
    "direction": "input",
    "link": "modbus.sensor_unit.temperature",
    "description": "Raw temperature (0.1°C per count)"
  },
  "humidity_raw": {
    "type": "u16",
    "direction": "input",
    "link": "modbus.sensor_unit.humidity",
    "description": "Raw humidity (0.1% per count)"
  },
  "temperature_degc": {
    "type": "f32",
    "direction": "status",
    "description": "Temperature in °C"
  },
  "humidity_pct": {
    "type": "f32",
    "direction": "status",
    "description": "Humidity in %"
  },
  "temp_alarm": {
    "type": "bool",
    "direction": "status",
    "description": "Temperature over limit"
  }
}

control/src/program.rs:

#![allow(unused)]
fn main() {
use autocore_std::{ControlProgram, TickContext};
use autocore_std::fb::Ton;
use std::time::Duration;
use crate::gm::GlobalMemory;

pub struct MyControlProgram {
    alarm_delay: Ton,
}

impl MyControlProgram {
    pub fn new() -> Self {
        Self {
            alarm_delay: Ton::new(),
        }
    }
}

impl ControlProgram for MyControlProgram {
    type Memory = GlobalMemory;

    fn initialize(&mut self, _mem: &mut Self::Memory) {
        log::info!("Temperature monitor started");
    }

    fn process_tick(&mut self, ctx: &mut TickContext<Self::Memory>) {
        // Convert raw Modbus values to engineering units
        // The sensor sends temperature as integer tenths of a degree
        ctx.gm.temperature_degc = ctx.gm.temperature_raw as f32 / 10.0;
        ctx.gm.humidity_pct = ctx.gm.humidity_raw as f32 / 10.0;

        // Alarm if temperature exceeds 50°C for more than 5 seconds
        let over_limit = ctx.gm.temperature_degc > 50.0;
        ctx.gm.temp_alarm = self.alarm_delay.call(over_limit, Duration::from_secs(5));

        // Log every 10 seconds
        if ctx.cycle % 1000 == 0 {
            log::info!(
                "Temp: {:.1}°C, Humidity: {:.1}%, Alarm: {}",
                ctx.gm.temperature_degc,
                ctx.gm.humidity_pct,
                ctx.gm.temp_alarm
            );
        }
    }
}
}

Example: Controlling a VFD (Variable Frequency Drive)

This example shows how to control a VFD (motor drive) over Modbus TCP. Most VFDs use holding registers for speed setpoint and run/stop commands.

project.json (modules section):

"modules": {
  "modbus": {
    "enabled": true,
    "args": ["service"],
    "config": {
      "devices": [
        {
          "name": "vfd_01",
          "type": "modbus_tcp",
          "host": "192.168.1.50",
          "port": 502,
          "slave_id": 1,
          "registers": [
            { "name": "control_word",    "type": "holding_register", "address": 0 },
            { "name": "speed_setpoint",  "type": "holding_register", "address": 1 },
            { "name": "status_word",     "type": "input_register",   "address": 0 },
            { "name": "speed_feedback",  "type": "input_register",   "address": 1 },
            { "name": "current",         "type": "input_register",   "address": 2 }
          ]
        }
      ]
    }
  }
}

Variables:

"variables": {
  "vfd_control":     { "type": "u16", "direction": "command", "link": "modbus.vfd_01.control_word" },
  "vfd_speed_cmd":   { "type": "u16", "direction": "command", "link": "modbus.vfd_01.speed_setpoint" },
  "vfd_status":      { "type": "u16", "direction": "input",   "link": "modbus.vfd_01.status_word" },
  "vfd_speed_fb":    { "type": "u16", "direction": "input",   "link": "modbus.vfd_01.speed_feedback" },
  "vfd_current":     { "type": "u16", "direction": "input",   "link": "modbus.vfd_01.current" },
  "motor_run_cmd":   { "type": "bool", "direction": "command", "description": "Run motor from HMI" },
  "motor_speed_rpm": { "type": "f32",  "direction": "command", "description": "Speed setpoint in RPM" },
  "motor_running":   { "type": "bool", "direction": "status",  "description": "Motor is running" },
  "motor_rpm":       { "type": "f32",  "direction": "status",  "description": "Actual speed in RPM" },
  "motor_amps":      { "type": "f32",  "direction": "status",  "description": "Motor current in A" }
}

control/src/program.rs:

#![allow(unused)]
fn main() {
use autocore_std::{ControlProgram, TickContext};
use autocore_std::fb::RTrig;
use crate::gm::GlobalMemory;

pub struct MyControlProgram {
    run_trig: RTrig,
    stop_trig: RTrig,
}

impl MyControlProgram {
    pub fn new() -> Self {
        Self {
            run_trig: RTrig::new(),
            stop_trig: RTrig::new(),
        }
    }
}

impl ControlProgram for MyControlProgram {
    type Memory = GlobalMemory;

    fn initialize(&mut self, _mem: &mut Self::Memory) {
        log::info!("VFD control started");
    }

    fn process_tick(&mut self, ctx: &mut TickContext<Self::Memory>) {
        // VFD control word bits (typical for many VFDs):
        //   Bit 0: Run forward
        //   Bit 1: Run reverse
        //   Bit 2: Fault reset
        const RUN_FWD: u16 = 0x0001;

        // Convert HMI speed (RPM) to VFD setpoint
        // Many VFDs use 0-10000 = 0-100.00 Hz. Adjust for your drive.
        let max_rpm = 1800.0_f32;
        let max_freq_counts = 5000_u16; // 50.00 Hz = 1800 RPM for a 4-pole motor

        if ctx.gm.motor_run_cmd {
            ctx.gm.vfd_control = RUN_FWD;
            let speed_pct = (ctx.gm.motor_speed_rpm / max_rpm).clamp(0.0, 1.0);
            ctx.gm.vfd_speed_cmd = (speed_pct * max_freq_counts as f32) as u16;
        } else {
            ctx.gm.vfd_control = 0;
            ctx.gm.vfd_speed_cmd = 0;
        }

        // Convert feedback to engineering units
        ctx.gm.motor_running = (ctx.gm.vfd_status & 0x0001) != 0;
        ctx.gm.motor_rpm = (ctx.gm.vfd_speed_fb as f32 / max_freq_counts as f32) * max_rpm;
        ctx.gm.motor_amps = ctx.gm.vfd_current as f32 / 100.0; // 0.01A resolution
    }
}
}

Hardware Integration: EtherCAT

EtherCAT Overview

EtherCAT is a high-performance fieldbus commonly used for servo drives, digital I/O modules, and analog I/O. If you have used EtherCAT with TwinCAT or acontis, the concepts are the same — slaves are scanned on an Ethernet interface, PDOs (Process Data Objects) are exchanged cyclically, and SDOs (Service Data Objects) are used for acyclic configuration.

In AutoCore, the autocore-ethercat module handles the EtherCAT master. It:

  1. Scans and configures slaves on startup
  2. Exchanges PDO data cyclically (synchronized with the server tick)
  3. Maps all PDO entries into shared memory variables

Configuring EtherCAT Slaves

EtherCAT slaves are configured in the modules.ethercat.config.slaves array in project.json. Each slave needs:

  • A name (used to build variable FQDNs)
  • A position on the bus (0 = first slave)
  • A device_id (vendor ID, product code, revision)
  • Sync managers with PDO mappings

Here is a simple example with a Beckhoff EK1100 coupler and an EL1008 8-channel digital input module:

{
  "modules": {
    "ethercat": {
      "enabled": true,
      "args": ["service"],
      "config": {
        "interface_name": "eth0",
        "auto_activate": true,
        "runtime_settings": {
          "cycle_time_us": 5000,
          "priority": 99
        },
        "slaves": [
          {
            "name": "EK1100",
            "position": 0,
            "device_id": {
              "vendor_id": 2,
              "product_code": 72100946,
              "revision_number": 1114112
            }
          },
          {
            "name": "DI_8CH",
            "position": 1,
            "device_id": {
              "vendor_id": 2,
              "product_code": 66084946,
              "revision_number": 1048576
            },
            "sync_managers": [
              {
                "direction": "Inputs",
                "index": 0,
                "pdos": [
                  {
                    "name": "Channel 1-8",
                    "entries": [
                      { "index": "0x6000", "sub": 1, "name": "Input 1", "type": "BIT", "bits": 1 },
                      { "index": "0x6010", "sub": 1, "name": "Input 2", "type": "BIT", "bits": 1 },
                      { "index": "0x6020", "sub": 1, "name": "Input 3", "type": "BIT", "bits": 1 },
                      { "index": "0x6030", "sub": 1, "name": "Input 4", "type": "BIT", "bits": 1 },
                      { "index": "0x6040", "sub": 1, "name": "Input 5", "type": "BIT", "bits": 1 },
                      { "index": "0x6050", "sub": 1, "name": "Input 6", "type": "BIT", "bits": 1 },
                      { "index": "0x6060", "sub": 1, "name": "Input 7", "type": "BIT", "bits": 1 },
                      { "index": "0x6070", "sub": 1, "name": "Input 8", "type": "BIT", "bits": 1 }
                    ]
                  }
                ]
              }
            ]
          }
        ]
      }
    }
  },
  "variables": {
    "di_input_1": { "type": "bool", "direction": "input", "link": "ethercat.di_8ch.channel_1_8.input_1" },
    "di_input_2": { "type": "bool", "direction": "input", "link": "ethercat.di_8ch.channel_1_8.input_2" },
    "di_input_3": { "type": "bool", "direction": "input", "link": "ethercat.di_8ch.channel_1_8.input_3" },
    "di_input_4": { "type": "bool", "direction": "input", "link": "ethercat.di_8ch.channel_1_8.input_4" },
    "di_input_5": { "type": "bool", "direction": "input", "link": "ethercat.di_8ch.channel_1_8.input_5" },
    "di_input_6": { "type": "bool", "direction": "input", "link": "ethercat.di_8ch.channel_1_8.input_6" },
    "di_input_7": { "type": "bool", "direction": "input", "link": "ethercat.di_8ch.channel_1_8.input_7" },
    "di_input_8": { "type": "bool", "direction": "input", "link": "ethercat.di_8ch.channel_1_8.input_8" }
  }
}

Finding device IDs: The vendor ID, product code, and revision number come from the EtherCAT slave’s ESI (EtherCAT Slave Information) file. You can find these in the device manufacturer’s documentation, or by running ethercat slaves on a system with the IgH EtherCAT master installed.

Digital I/O Example

Using the EtherCAT digital input module above, here is a control program that reads the inputs and uses them:

#![allow(unused)]
fn main() {
use autocore_std::{ControlProgram, TickContext};
use autocore_std::fb::RTrig;
use crate::gm::GlobalMemory;

pub struct MyControlProgram {
    start_trig: RTrig,
    stop_trig: RTrig,
    running: bool,
}

impl MyControlProgram {
    pub fn new() -> Self {
        Self {
            start_trig: RTrig::new(),
            stop_trig: RTrig::new(),
            running: false,
        }
    }
}

impl ControlProgram for MyControlProgram {
    type Memory = GlobalMemory;

    fn process_tick(&mut self, ctx: &mut TickContext<Self::Memory>) {
        // Input 1 = Start button, Input 2 = Stop button
        if self.start_trig.call(ctx.gm.di_input_1) {
            log::info!("Start button pressed");
            self.running = true;
        }
        if self.stop_trig.call(ctx.gm.di_input_2) {
            log::info!("Stop button pressed");
            self.running = false;
        }

        // Input 3 = Emergency stop (normally closed, active low)
        if !ctx.gm.di_input_3 {
            self.running = false;
        }

        // Input 4 = Part sensor
        // Input 5 = Home position sensor
        // etc.
    }
}
}

Motion Control with CiA 402 Drives

Many EtherCAT servo drives follow the CiA 402 device profile, which defines standard PDO objects for:

  • Control word (0x6040): Commands to the drive (enable, start, stop, fault reset)
  • Status word (0x6041): Drive state feedback
  • Target position (0x607A): Position command
  • Position actual (0x6064): Encoder feedback
  • Profile velocity (0x6081): Speed limit for profile moves
  • Profile acceleration (0x6083): Acceleration ramp
  • Profile deceleration (0x6084): Deceleration ramp

AutoCore provides the Axis helper from autocore-std that wraps the CiA 402 state machine, giving you high-level methods like home(), enable(), move_absolute(), and reset_faults().

When you configure an EtherCAT slave with "axis": "pp" (Profile Position mode), the code generator in gm.rs automatically creates a TeknicPpView — a borrowed-reference struct that maps the CiA 402 objects to your global memory fields.

Full Example: Teknic ClearPath Servo

This complete example drives a Teknic ClearPath servo motor in a continuous back-and-forth motion using Profile Position mode.

project.json (EtherCAT slave configuration):

{
  "modules": {
    "ethercat": {
      "enabled": true,
      "args": ["service"],
      "config": {
        "interface_name": "eth0",
        "auto_activate": true,
        "runtime_settings": {
          "cycle_time_us": 5000,
          "priority": 99
        },
        "slaves": [
          {
            "name": "ClearPath_0",
            "axis": "pp",
            "position": 0,
            "device_id": {
              "vendor_id": 4919,
              "product_code": 1,
              "revision_number": 1
            },
            "sync_managers": [
              {
                "direction": "Outputs",
                "index": 2,
                "pdos": [{
                  "name": "RxPDO 5",
                  "entries": [
                    { "index": "0x6040", "name": "Controlword",          "type": "UINT",  "bits": 16 },
                    { "index": "0x607A", "name": "Target Position",      "type": "DINT",  "bits": 32 },
                    { "index": "0x6081", "name": "Profile Velocity",     "type": "UDINT", "bits": 32 },
                    { "index": "0x6083", "name": "Profile Acceleration", "type": "UDINT", "bits": 32 },
                    { "index": "0x6084", "name": "Profile Deceleration", "type": "UDINT", "bits": 32 }
                  ]
                }]
              },
              {
                "direction": "Inputs",
                "index": 3,
                "pdos": [{
                  "name": "TxPDO 5",
                  "entries": [
                    { "index": "0x6041", "name": "Statusword",            "type": "UINT", "bits": 16 },
                    { "index": "0x6064", "name": "Position Actual Value", "type": "DINT", "bits": 32 }
                  ]
                }]
              }
            ]
          }
        ]
      }
    }
  },
  "variables": {
    "clearpath_0_rxpdo_5_controlword":          { "type": "u16", "direction": "command", "link": "ethercat.clearpath_0.rxpdo_5.controlword" },
    "clearpath_0_rxpdo_5_target_position":      { "type": "i32", "direction": "command", "link": "ethercat.clearpath_0.rxpdo_5.target_position" },
    "clearpath_0_rxpdo_5_profile_velocity":     { "type": "u32", "direction": "command", "link": "ethercat.clearpath_0.rxpdo_5.profile_velocity" },
    "clearpath_0_rxpdo_5_profile_acceleration": { "type": "u32", "direction": "command", "link": "ethercat.clearpath_0.rxpdo_5.profile_acceleration" },
    "clearpath_0_rxpdo_5_profile_deceleration": { "type": "u32", "direction": "command", "link": "ethercat.clearpath_0.rxpdo_5.profile_deceleration" },
    "clearpath_0_txpdo_5_statusword":           { "type": "u16", "direction": "status",  "link": "ethercat.clearpath_0.txpdo_5.statusword" },
    "clearpath_0_txpdo_5_position_actual_value":{ "type": "i32", "direction": "status",  "link": "ethercat.clearpath_0.txpdo_5.position_actual_value" }
  }
}

control/src/program.rs:

#![allow(unused)]
fn main() {
use autocore_std::motion::{Axis, AxisConfig, HomingMethod};
use autocore_std::{ControlProgram, TickContext};
use crate::gm::{GlobalMemory, ClearPath0PpStubs};

#[derive(Debug, Clone, Copy, PartialEq)]
enum Step {
    Home,
    WaitHomed,
    Enable,
    WaitEnabled,
    MoveCW,
    WaitCW,
    MoveCCW,
    WaitCCW,
    Reset,
    WaitReset,
}

pub struct MyControlProgram {
    axis: Axis,
    step: Step,
    stubs: ClearPath0PpStubs,
}

impl MyControlProgram {
    pub fn new() -> Self {
        // Configure the axis: 12,800 encoder counts per revolution, display in degrees
        let config = AxisConfig::new(12_800)
            .with_user_scale(360.0);

        Self {
            axis: Axis::new(config, "ClearPath_0"),
            step: Step::Home,
            stubs: ClearPath0PpStubs::default(),
        }
    }
}

impl ControlProgram for MyControlProgram {
    type Memory = GlobalMemory;

    fn initialize(&mut self, _mem: &mut Self::Memory) {
        log::info!("ClearPath reversing program started");
    }

    fn process_tick(&mut self, ctx: &mut TickContext<Self::Memory>) {
        // Destructure self so the borrow checker allows separate field access
        let Self { axis, step, stubs } = self;

        // Build the CiA 402 view (maps global memory fields to the Axis helper)
        let mut view = stubs.view(&mut ctx.gm);

        // Let the Axis state machine process one tick
        axis.tick(&mut view, ctx.client);

        // Application state machine
        match *step {
            Step::Home => {
                axis.home(&mut view, HomingMethod::CurrentPosition);
                log::info!("Homing: setting current position as 0 degrees");
                *step = Step::WaitHomed;
            }
            Step::WaitHomed => {
                if !axis.is_busy {
                    if !axis.is_error {
                        log::info!("Homed at {:.1} degrees", axis.position);
                        *step = Step::Enable;
                    } else {
                        log::error!("Homing failed: {}", axis.error_message);
                        *step = Step::Reset;
                    }
                }
            }
            Step::Enable => {
                axis.enable(&mut view);
                *step = Step::WaitEnabled;
            }
            Step::WaitEnabled => {
                if !axis.is_busy {
                    if axis.motor_on {
                        *step = Step::MoveCW;
                    } else {
                        log::error!("Enable failed: {}", axis.error_message);
                        *step = Step::Reset;
                    }
                }
            }
            Step::MoveCW => {
                // Move to 45 degrees at 90 deg/s, 180 deg/s² accel and decel
                axis.move_absolute(&mut view, 45.0, 90.0, 180.0, 180.0);
                log::info!("Moving CW to 45 degrees");
                *step = Step::WaitCW;
            }
            Step::WaitCW => {
                if !axis.is_busy {
                    if !axis.is_error {
                        log::info!("CW move complete at {:.1} degrees", axis.position);
                        *step = Step::MoveCCW;
                    } else {
                        log::error!("CW move failed: {}", axis.error_message);
                        *step = Step::Reset;
                    }
                }
            }
            Step::MoveCCW => {
                axis.move_absolute(&mut view, 0.0, 90.0, 180.0, 180.0);
                log::info!("Moving CCW to 0 degrees");
                *step = Step::WaitCCW;
            }
            Step::WaitCCW => {
                if !axis.is_busy {
                    if !axis.is_error {
                        log::info!("CCW move complete at {:.1} degrees", axis.position);
                        *step = Step::MoveCW; // Repeat
                    } else {
                        log::error!("CCW move failed: {}", axis.error_message);
                        *step = Step::Reset;
                    }
                }
            }
            Step::Reset => {
                axis.reset_faults(&mut view);
                *step = Step::WaitReset;
            }
            Step::WaitReset => {
                if !axis.is_busy {
                    *step = Step::Enable;
                }
            }
        }
    }
}
}

Key points:

  • ClearPath0PpStubs is auto-generated in gm.rs. It holds CiA 402 fields that are not in the PDO mapping (like modes_of_operation) so you don’t have to manage them manually.
  • stubs.view(&mut ctx.gm) creates a TeknicPpView that the Axis helper can use.
  • The let Self { axis, step, stubs } = self; destructuring is necessary so Rust’s borrow checker can see that stubs, axis, and step are separate borrows.
  • axis.tick() must be called every cycle — it processes the CiA 402 state machine internally.
  • Axis provides high-level methods: home(), enable(), move_absolute(), reset_faults(), and status fields: is_busy, is_error, motor_on, position, error_message.

Building a Web HMI

HMI Overview

Every AutoCore project includes a www/ directory that contains a React + TypeScript web application. This web app connects to the server via WebSocket and can:

  • Display live variable values
  • Send commands (write variables, trigger actions)
  • Show logs and status information

The web HMI is served directly by the AutoCore server. After deploying, you open it in any web browser.

The AutoCore React Library

The @adcops/autocore-react library provides React hooks for connecting to the AutoCore server and working with variables. The project template includes this library pre-configured.

Key hooks:

HookPurpose
useAutoCoreConnection()Connect to the server WebSocket
useVariable(name)Subscribe to a variable and get its live value
useCommand()Send commands to the server

Creating a Simple Dashboard

Here is a basic HMI that shows the motor speed and provides start/stop controls. Edit www/src/App.tsx:

import React from 'react';
import { useVariable, useCommand } from './AutoCore';
import { Button } from 'primereact/button';
import { Knob } from 'primereact/knob';

function App() {
    // Subscribe to live variable values
    const motorRunning = useVariable<boolean>('machine_running');
    const speedActual = useVariable<number>('motor_speed_actual');
    const speedSetpoint = useVariable<number>('motor_speed_setpoint');

    // Command hook for writing variables
    const sendCommand = useCommand();

    const handleStart = () => {
        sendCommand('gm.write', { name: 'machine_running', value: true });
    };

    const handleStop = () => {
        sendCommand('gm.write', { name: 'machine_running', value: false });
    };

    const handleSpeedChange = (rpm: number) => {
        sendCommand('gm.write', { name: 'motor_speed_setpoint', value: rpm });
    };

    return (
        <div style={{ padding: '2rem' }}>
            <h1>Motor Control</h1>

            <div style={{ display: 'flex', gap: '2rem', alignItems: 'center' }}>
                <div>
                    <h3>Speed</h3>
                    <Knob
                        value={speedActual?.value ?? 0}
                        max={2000}
                        readOnly
                        valueTemplate="{value} RPM"
                        size={150}
                    />
                </div>

                <div>
                    <h3>Setpoint</h3>
                    <Knob
                        value={speedSetpoint?.value ?? 0}
                        max={2000}
                        onChange={(e) => handleSpeedChange(e.value)}
                        valueTemplate="{value} RPM"
                        size={150}
                    />
                </div>

                <div>
                    <h3>Controls</h3>
                    <Button
                        label="Start"
                        icon="pi pi-play"
                        onClick={handleStart}
                        disabled={motorRunning?.value === true}
                        severity="success"
                        style={{ marginRight: '1rem' }}
                    />
                    <Button
                        label="Stop"
                        icon="pi pi-stop"
                        onClick={handleStop}
                        disabled={motorRunning?.value !== true}
                        severity="danger"
                    />
                </div>
            </div>

            <p>
                Status: {motorRunning?.value ? 'RUNNING' : 'STOPPED'}
            </p>
        </div>
    );
}

export default App;

Subscribing to Live Variable Updates

When you use useVariable(name), the library automatically subscribes to that variable via WebSocket. The value updates in real time whenever the control program changes it — there is no polling.

Under the hood, this sends a subscribe message to the server:

{ "domain": "gm", "fname": "subscribe", "args": { "name": "motor_speed_actual" } }

The server then pushes updates whenever the value changes:

{ "domain": "gm", "fname": "broadcast", "args": { "name": "motor_speed_actual", "value": 1247.5 } }

Sending Commands from the HMI

To write a variable value:

sendCommand('gm.write', { name: 'motor_speed_setpoint', value: 1500 });

To read a variable on demand (instead of subscribing):

const result = await sendCommand('gm.read', { name: 'motor_speed_actual' });
console.log(result.value);

To send a command to an external module:

sendCommand('modbus.status', {});
sendCommand('ethercat.get_state', { slave: 'ClearPath_0' });

Deploying the HMI

Build and deploy the web HMI:

cd www
npm install       # First time only
npm run build     # Creates www/dist/
cd ..
acctl push www    # Uploads dist/ to the server

Then open your browser to http://<server_ip>:8080 to see the HMI.

During development, you can run the HMI in development mode with hot reloading:

cd www
npm run dev

This starts a local dev server (usually at http://localhost:5173). You will need to configure the WebSocket URL to point to your AutoCore server — check www/src/AutoCore.ts for the connection settings.


Sending Commands from the Control Program

Overview

The CommandClient (provided by autocore-std) lets your control program send requests to external modules (Modbus, EtherCAT, camera, etc.) and receive responses — all without blocking the scan cycle.

Key characteristics:

  • Non-blocking: send() queues a message immediately; responses are collected later.
  • Transaction-based: Each request gets a unique ID so you can match responses.
  • Multi-consumer: Multiple subsystems can share one CommandClient, each tracking its own requests.
Control Program                   autocore-server               External Module
      │                                │                              │
      │  send("labelit.inspect", {})   │                              │
      │ ──────────────────────────────►│  route to labelit via TCP    │
      │                                │ ────────────────────────────►│
      │                                │                              │
      │      (scan cycles continue)    │                              │
      │                                │   Response (transaction_id)  │
      │                                │ ◄────────────────────────────│
      │  take_response(tid)            │                              │
      │ ◄───────────────────────────── │                              │

Sending a Request

Call send() with a topic and JSON payload. It returns a transaction_id:

#![allow(unused)]
fn main() {
use serde_json::json;

let tid = ctx.client.send("labelit.inspect_full", json!({
    "exposure_ms": 50,
    "threshold": 0.8
}));
// tid is a u32 you can use to match the response later
}

The topic format is module_name.command:

TopicModuleCommand
labelit.statuslabelitstatus
modbus.read_holdingmodbusread_holding
python.run_scriptpythonrun_script

Polling for Responses

The framework calls poll() before each process_tick, so responses are already buffered. Use take_response(tid) to retrieve yours:

#![allow(unused)]
fn main() {
if let Some(response) = ctx.client.take_response(my_tid) {
    if response.success {
        log::info!("Result: {}", response.data);
    } else {
        log::error!("Failed: {}", response.error_message);
    }
}
}

Handling Timeouts

Clean up requests that have been pending too long:

#![allow(unused)]
fn main() {
use std::time::Duration;

let stale = ctx.client.drain_stale(Duration::from_secs(10));
for tid in &stale {
    log::warn!("Request {} timed out", tid);
}
}

Full Example: Calling an External Vision Module

This example sends an inspect_full command to a camera module when a trigger fires, then uses the result to position a robot:

#![allow(unused)]
fn main() {
use autocore_std::{ControlProgram, TickContext};
use autocore_std::fb::RTrig;
use serde_json::json;
use std::time::Duration;
use crate::gm::GlobalMemory;

pub struct MyControlProgram {
    trigger: RTrig,
    inspect_tid: Option<u32>,
}

impl MyControlProgram {
    pub fn new() -> Self {
        Self {
            trigger: RTrig::new(),
            inspect_tid: None,
        }
    }
}

impl ControlProgram for MyControlProgram {
    type Memory = GlobalMemory;

    fn process_tick(&mut self, ctx: &mut TickContext<Self::Memory>) {
        // 1. On rising edge of the inspect trigger, send the command
        if self.trigger.call(ctx.gm.start_inspect) && self.inspect_tid.is_none() {
            let tid = ctx.client.send("labelit.inspect_full", json!({}));
            self.inspect_tid = Some(tid);
            log::info!("Sent inspect command (tid={})", tid);
        }

        // 2. Check for our response
        if let Some(tid) = self.inspect_tid {
            if let Some(response) = ctx.client.take_response(tid) {
                self.inspect_tid = None;

                if response.success {
                    let placement = &response.data["placement"];
                    ctx.gm.placement_x = placement["robot_x"].as_f64().unwrap_or(0.0) as f32;
                    ctx.gm.placement_y = placement["robot_y"].as_f64().unwrap_or(0.0) as f32;
                    ctx.gm.placement_c = placement["robot_c"].as_f64().unwrap_or(0.0) as f32;
                    ctx.gm.placement_valid = true;
                    log::info!("Placement received: ({:.2}, {:.2}, {:.1} deg)",
                        ctx.gm.placement_x, ctx.gm.placement_y, ctx.gm.placement_c);
                } else {
                    log::error!("Inspect failed: {}", response.error_message);
                    ctx.gm.placement_valid = false;
                }
            }
        }

        // 3. Clean up stale requests
        let stale = ctx.client.drain_stale(Duration::from_secs(10));
        for tid in stale {
            if Some(tid) == self.inspect_tid {
                log::warn!("Inspect request timed out");
                self.inspect_tid = None;
                ctx.gm.placement_valid = false;
            }
        }
    }
}
}

Project Management with acctl

acctl Command Reference

acctl is the command-line tool for managing AutoCore projects. Here is a complete reference of all commands:

Creating and Cloning Projects

CommandDescription
acctl new <name>Create a new project with the standard template
acctl clone <host> [project]Download a project from a server
acctl clone <host> --listList available projects on a server
# Create a new project
acctl new my_machine

# Clone a project from a remote server
acctl clone 192.168.1.100 my_machine

# List all projects on a server
acctl clone 192.168.1.100 --list

Deploying

CommandDescription
acctl push project [--restart]Upload project.json to server
acctl push control [--start] [--no-build]Build and upload the control program
acctl push www [--source]Upload the web HMI (dist/ by default)
acctl pull [--extract]Download the active project as a zip
acctl upload <file> [--dest PATH]Upload a file to the project directory
# Full deploy workflow
acctl push project
acctl codegen
acctl push control --start
acctl push www

# Download a project from the server
acctl pull --extract

Control Program Lifecycle

CommandDescription
acctl control startStart the control program
acctl control stopStop the control program
acctl control restartRestart the control program
acctl control statusShow control program status

Monitoring

CommandDescription
acctl statusShow server status and project list
acctl logsShow recent control program logs
acctl logs --followStream logs in real time

Code Generation

CommandDescription
acctl codegenRegenerate gm.rs from the server’s project.json
acctl syncCompare and synchronize local/remote project.json

Configuration

CommandDescription
acctl set-target <host> [--port PORT]Set the default server address
acctl switch <project> [--restart]Switch the active project on the server

Variable Management

CommandDescription
acctl export-vars [--output FILE]Export variables to CSV
acctl import-vars [--input FILE]Import variables from CSV
acctl dedup-varsFind and remove duplicate variable links

Sending Commands

CommandDescription
acctl cmd <topic> [args...]Send a command to the server
# Read a variable
acctl cmd gm.read --name motor_speed

# Write a variable
acctl cmd gm.write --name motor_speed_setpoint --value 1500

# Send a command to a module
acctl cmd modbus.status
acctl cmd system.get_domains

# Create a new project on the server
acctl cmd system.new_project --project_name my_machine

Working with Multiple Projects

Each AutoCore server can host multiple projects, but only one is active at a time. Think of it like multiple PLC programs on a single controller — you choose which one runs.

# See all projects and which is active
acctl status

# Switch to a different project
acctl switch other_project --restart

# Create a new project directly on the server
acctl cmd system.new_project --project_name new_machine

Deploying to a Remote Server

If your AutoCore server is on a different machine (e.g., the actual production controller):

# Step 1: Set the target server
acctl set-target 192.168.1.100

# Step 2: Verify the connection
acctl status

# Step 3: Deploy
acctl push project
acctl push control --start
acctl push www

# Step 4: Monitor remotely
acctl logs --follow

All acctl commands after set-target will communicate with the remote server.

To override the target for a single command:

acctl --host 192.168.1.200 status

Importing and Exporting Variables

For large projects, you may find it easier to manage variables in a spreadsheet. acctl can export and import variables as CSV:

# Export current variables to CSV
acctl export-vars --output variables.csv

# Edit the CSV in your spreadsheet application...

# Import the modified variables back
acctl import-vars --input variables.csv

The CSV format has these columns: name, type, direction, link, description, initial.


System Architecture

This chapter provides a deeper look at how AutoCore works internally. You don’t need to understand all of this to use AutoCore, but it will help you debug issues and make better design decisions.

Architecture Diagram

┌─────────────────────────────────────────────────────────────────┐
│                        AutoCore Server                           │
│                                                                   │
│  ┌──────────┐ ┌──────────┐ ┌────────────┐ ┌──────────────────┐ │
│  │  System   │ │    GM    │ │  Datastore  │ │   Module IPC     │ │
│  │ Servelet  │ │ Servelet │ │  Servelet   │ │   Server         │ │
│  └─────┬─────┘ └─────┬────┘ └──────┬─────┘ └────────┬─────────┘ │
│        │              │             │                 │           │
│        ▼              ▼             ▼                 ▼           │
│  ┌─────────────────────────────────────────────────────────────┐ │
│  │               Shared Memory (autocore_cyclic)                │ │
│  │  ┌──────────┐  ┌───────────┐  ┌─────────────┐  ┌─────────┐ │ │
│  │  │ Variables │  │  Signals  │  │   Direct    │  │ Events  │ │ │
│  │  │ (I/O)    │  │  (Tick)   │  │   Mapping   │  │ (Sync)  │ │ │
│  │  └──────────┘  └───────────┘  └─────────────┘  └─────────┘ │ │
│  └──────────▲─────────────────────────────▲────────────────────┘ │
│             │   Zero-Copy R/W             │   Zero-Copy R/W      │
│             │   (every cycle)             │   (every cycle)       │
└─────────────┼─────────────────────────────┼──────────────────────┘
              │                             │
   ┌──────────┴──────────┐    ┌─────────────┴──────────────┐
   │   Control Program    │    │      External Modules       │
   │   (your program.rs)  │    │  (EtherCAT, Modbus, etc.)  │
   │                      │    │                              │
   │  autocore-std        │    │  mechutil IPC client         │
   └──────────────────────┘    └──────────────────────────────┘
              │                             │
              ▼                             ▼
   ┌──────────────────────┐    ┌──────────────────────────────┐
   │   Web Console / HMI   │    │    Field Devices              │
   │   (Browser, ws://)    │    │  (Drives, Sensors, I/O)      │
   └──────────────────────┘    └──────────────────────────────┘

Shared Memory Model

Shared memory is the heart of AutoCore’s performance. Instead of sending data through network protocols or message queues, all processes access the same memory region directly.

  1. Allocation: When the server starts, it creates a shared memory segment called autocore_cyclic based on the variables in project.json.
  2. Mapping: The control program and all enabled modules map this segment into their own address space.
  3. Synchronization: The server generates a tick event. The control program waits for this event, reads the memory, processes one cycle, and writes back.

This zero-copy architecture means that I/O data exchange takes nanoseconds, not milliseconds.

The Module System

External modules extend AutoCore’s hardware capabilities. Each module:

  1. Is spawned as a child process by the server on startup
  2. Receives three CLI arguments: --ipc-address, --module-name, and --config
  3. Connects to the server’s IPC port (default 9100)
  4. Receives lifecycle commands: initialize, configure_shm, finalize
  5. Maps shared memory variables to exchange cyclic data
  6. Handles commands routed by the server based on the module’s domain name

Built-in modules:

  • autocore-ethercat: EtherCAT fieldbus master
  • autocore-modbus: Modbus TCP client
  • autocore-labelit: Camera and label inspection

Configuration: config.ini

The config.ini file contains machine-specific settings that stay the same across projects. It is located at:

  • Linux: /opt/autocore/config/config.ini
  • Development: specified with --config flag when running the server
[console]
port = 11969                                        # WebSocket port for CLI and web clients
www_root = /srv/autocore/console/dist               # Path to web console static files

[general]
projects_directory = /srv/autocore/projects          # Root directory for all projects
module_base_directory = /opt/autocore/bin/modules    # Directory containing module executables
port = 8080                                          # HTTP port for the web server
autocore_std_directory = /srv/autocore/lib/autocore-std  # Path to the autocore-std library
disable_ads = 1                                      # Disable TwinCAT ADS compatibility
ipc_port = 9100                                      # TCP port for module IPC
project_name = default                               # Project to load on startup

[modules]
modbus = ${general.module_base_directory}/autocore-modbus
ethercat = ${general.module_base_directory}/autocore-ethercat
labelit = ${general.module_base_directory}/autocore-labelit

The [modules] section maps module names to executable paths. This keeps project.json portable — the same project file works on different machines where modules may be installed in different locations.


Writing External Modules

When to Write a Module

Write an external module when you need to:

  • Interface with hardware that AutoCore doesn’t support natively (cameras, custom sensors, robotic controllers)
  • Run code that should operate independently of the control loop (long-running tasks, blocking SDK calls)
  • Add a service that other components can call by name (e.g., a barcode scanner service)

Module Lifecycle

Server starts
    │
    ├─ Spawns module process with --ipc-address, --module-name, --config
    │
    ├─ Module connects to IPC server
    │
    ├─ Server sends "initialize" command
    │   └─ Module calls on_initialize()
    │
    ├─ Server sends "configure_shm" (if module uses shared memory)
    │   └─ Module calls on_shm_configured()
    │
    ├─ Module handles incoming requests via handle_message()
    │   (continues until shutdown)
    │
    ├─ Server sends "finalize" command
    │   └─ Module calls on_finalize()
    │
    └─ Module process exits

Step-by-Step Module Development

Step 1: Create the Crate

cargo init my-module
cd my-module

Add dependencies to Cargo.toml:

[package]
name = "my-module"
version = "1.0.0"
edition = "2024"

[dependencies]
mechutil = "0.7"
tokio = { version = "1", features = ["full"] }
async-trait = "0.1"
serde_json = "1"
anyhow = "1"
log = "0.4"
simplelog = "0.12"

Step 2: Implement the ModuleHandler Trait

#![allow(unused)]
fn main() {
use anyhow::Result;
use async_trait::async_trait;
use mechutil::ipc::{CommandMessage, IpcClient, ModuleArgs, ModuleHandler};
use mechutil::shm::ShmMap;
use simplelog::{Config, LevelFilter, SimpleLogger};

struct MyModule {
    domain: String,
    shm: Option<ShmMap>,
}

impl MyModule {
    fn new(domain: &str) -> Self {
        Self {
            domain: domain.to_string(),
            shm: None,
        }
    }
}

#[async_trait]
impl ModuleHandler for MyModule {
    fn domain(&self) -> &str {
        &self.domain
    }

    async fn on_initialize(&mut self) -> Result<()> {
        log::info!("{} initialized", self.domain);
        Ok(())
    }

    async fn on_finalize(&mut self) -> Result<()> {
        log::info!("{} shutting down", self.domain);
        Ok(())
    }

    async fn handle_message(&mut self, msg: CommandMessage) -> CommandMessage {
        let subtopic = msg.subtopic().to_string();
        match subtopic.as_str() {
            "status" => {
                msg.into_response(serde_json::json!({
                    "ok": true,
                    "shm_connected": self.shm.is_some(),
                }))
            }
            _ => msg.into_error_response(
                &format!("Unknown command: {}", subtopic)
            ),
        }
    }

    fn shm_variable_names(&self) -> Vec<String> {
        vec![] // Return variable names if your module uses shared memory
    }

    async fn on_shm_configured(&mut self, shm_map: ShmMap) -> Result<()> {
        self.shm = Some(shm_map);
        Ok(())
    }
}
}

Step 3: Write main()

#[tokio::main]
async fn main() -> Result<()> {
    SimpleLogger::init(LevelFilter::Info, Config::default())?;

    let args = ModuleArgs::from_env()?;
    log::info!("Starting {} at {}", args.module_name, args.ipc_address);

    let handler = MyModule::new(&args.module_name);
    let client = IpcClient::connect(&args.ipc_address, handler).await?;
    client.run().await?;

    Ok(())
}

Step 4: Register the Module

Add to project.json:

{
  "modules": {
    "my_module": {
      "enabled": true,
      "config": {
        "setting1": "value1"
      }
    }
  }
}

Add to config.ini:

[modules]
my_module = /path/to/my-module/target/release/my-module

Step 5: Test

# Build the module
cargo build --release

# Restart the server (it will spawn the module automatically)
sudo systemctl restart autocore_server

# Verify the module is connected
acctl cmd my_module.status

Real-World Example: Camera Integration

The autocore-labelit module demonstrates a production-quality module pattern. It manages a Basler GigE camera for label inspection:

  • Handle/Worker split: Camera SDK calls are blocking, so they run on a dedicated OS thread. The async ModuleHandler communicates with the camera thread through channels.
  • IPC commands map to subtopics: camera_start, camera_snap, camera_shutdown, status.
  • Timeouts on every operation: Each camera operation is wrapped in tokio::time::timeout so a stuck camera cannot hang the IPC loop.
  • Graceful lifecycle: The camera worker is spawned in on_initialize() and shut down in on_finalize().

Troubleshooting

Common Issues

Control program won’t start

Symptom: acctl control start or acctl push control --start fails.

Check:

  1. Is the server running? sudo systemctl status autocore_server
  2. Is there a build error? Check the output of acctl push control for Rust compiler errors.
  3. Is the project loaded? acctl status should show your project as active.

Tick signal lost

Symptom: Control program starts but does not cycle. Logs show “Tick wait failed”.

Fix: The server should auto-reset timers. If not, restart the server:

sudo systemctl restart autocore_server

Variables not updating in the HMI

Check:

  1. Is the control program running? acctl control status
  2. Is the variable being written in process_tick? Add a log statement to verify.
  3. Is the WebSocket connected? Check the browser console for connection errors.

Hardware not responding (EtherCAT/Modbus)

Check:

  1. Is the module enabled in project.json? Check "enabled": true.
  2. Is the module executable configured in config.ini?
  3. Is the module running? acctl cmd system.get_domains lists all connected modules.
  4. Check module logs: acctl logs --follow will show output from all modules.
  5. For EtherCAT: Is the network cable connected? Is the correct interface configured?
  6. For Modbus: Can you ping the device? Is the IP address and port correct?

Build errors after changing project.json

Fix: Regenerate the global memory struct:

acctl push project
acctl codegen
acctl push control --start

“Permission denied” when starting the server

Fix: The server may need network capabilities to bind to port 80:

# If using systemd (default installation)
sudo systemctl start autocore_server

# If running manually
sudo setcap cap_net_bind_service=+ep /opt/autocore/bin/autocore_server

Diagnostic Commands

CommandWhat It Shows
acctl statusServer version, active project, available projects
acctl control statusControl program state (running/stopped/error)
acctl logs --followLive log stream from control program and modules
acctl cmd system.get_domainsAll registered domains (modules, services)
acctl cmd gm.read --name <var>Current value of a variable
sudo systemctl status autocore_serverServer process status
sudo journalctl -u autocore_server -fServer system log (systemd)

Appendix A: Variable Type Reference

TypeRust TypeSizeRangeIEC 61131-3 Equivalent
boolbool1 bytetrue / falseBOOL
u8u81 byte0 to 255USINT / BYTE
i8i81 byte-128 to 127SINT
u16u162 bytes0 to 65,535UINT / WORD
i16i162 bytes-32,768 to 32,767INT
u32u324 bytes0 to 4,294,967,295UDINT / DWORD
i32i324 bytes-2,147,483,648 to 2,147,483,647DINT
u64u648 bytes0 to 2^64 - 1ULINT / LWORD
i64i648 bytes-2^63 to 2^63 - 1LINT
f32f324 bytesIEEE 754 single precisionREAL
f64f648 bytesIEEE 754 double precisionLREAL

Appendix B: Function Block Reference

AutoCore provides standard function blocks inspired by IEC 61131-3. Import them from autocore_std::fb.

RTrig — Rising Edge Detector

Detects false to true transitions. Equivalent to R_TRIG in IEC 61131-3.

#![allow(unused)]
fn main() {
use autocore_std::fb::RTrig;

let mut trig = RTrig::new();

trig.call(false);  // returns false
trig.call(true);   // returns true  (rising edge detected)
trig.call(true);   // returns false (no transition)
trig.call(false);  // returns false
trig.call(true);   // returns true  (another rising edge)
}

FTrig — Falling Edge Detector

Detects true to false transitions. Equivalent to F_TRIG in IEC 61131-3.

#![allow(unused)]
fn main() {
use autocore_std::fb::FTrig;

let mut trig = FTrig::new();

trig.call(true);   // returns false
trig.call(false);  // returns true  (falling edge detected)
trig.call(false);  // returns false (no transition)
trig.call(true);   // returns false
trig.call(false);  // returns true  (another falling edge)
}

Ton — Timer On Delay

Output becomes true after input has been true for the specified duration. Equivalent to TON in IEC 61131-3.

#![allow(unused)]
fn main() {
use autocore_std::fb::Ton;
use std::time::Duration;

let mut timer = Ton::new();

// In process_tick:
let done = timer.call(input_signal, Duration::from_secs(5));
// done = true after input_signal has been true for 5 seconds continuously
// timer.et = elapsed time
// timer.q = same as the return value (done)

// If input_signal becomes false at any time, the timer resets
}

Fields:

FieldTypeDescription
qboolOutput — true when timer has elapsed
etDurationElapsed time since input became true

BitResetOnDelay — Auto-Reset Timer

Sets output to false after a delay. Useful for pulse outputs.

#![allow(unused)]
fn main() {
use autocore_std::fb::BitResetOnDelay;
use std::time::Duration;

let mut reset = BitResetOnDelay::new(Duration::from_millis(500));

// When you set the bit to true, it automatically resets to false after 500ms
reset.set(); // Output becomes true
// ... 500ms later, in process_tick ...
reset.call(); // Call every cycle to update
// reset.q becomes false after the delay
}

RunningAverage — Online Averaging

Computes a running average of values.

#![allow(unused)]
fn main() {
use autocore_std::fb::RunningAverage;

let mut avg = RunningAverage::new();

avg.add(10.0);
avg.add(20.0);
avg.add(30.0);
let mean = avg.average(); // 20.0
let count = avg.count();  // 3

avg.reset(); // Start over
}

Appendix C: CommandClient API Reference

The CommandClient is available in process_tick via ctx.client. All methods are non-blocking.

MethodSignatureDescription
send(&mut self, topic: &str, data: Value) -> u32Send a request. Returns the transaction_id.
poll(&mut self)Drain all available responses from the WebSocket into the buffer. Called automatically by the framework before each process_tick.
take_response(&mut self, transaction_id: u32) -> Option<CommandMessage>Take a response for a specific transaction_id. Returns None if not yet arrived.
is_pending(&self, transaction_id: u32) -> boolCheck if a request is still awaiting a response.
pending_count(&self) -> usizeNumber of outstanding requests.
response_count(&self) -> usizeNumber of buffered responses ready to be claimed.
drain_stale(&mut self, timeout: Duration) -> Vec<u32>Remove and return transaction_ids that have been pending longer than timeout.

CommandMessage response fields:

FieldTypeDescription
transaction_idu32Matches the ID returned by send()
successboolWhether the request was processed successfully
dataserde_json::ValueThe response payload (on success)
error_messageStringError description (on failure)