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 / Codesys | AutoCore | Notes |
|---|---|---|
| TwinCAT XAE (Visual Studio) | VS Code + Rust + acctl | You write code in any editor; acctl handles build and deploy |
| PLC Runtime | autocore-server | The server process that manages the control loop, I/O, and communication |
| PLC Program (ST/FBD/LD) | control/src/program.rs | Your control logic, written in Rust |
| Global Variable List (GVL) | project.json variables + gm.rs | Variables are declared in JSON; a Rust struct is auto-generated |
| I/O Configuration (XAE) | project.json modules section | EtherCAT slaves, Modbus devices, etc. are configured in JSON |
| EtherCAT Master | autocore-ethercat module | Runs as a separate process; maps I/O into shared memory |
| Modbus TCP Client | autocore-modbus module | Same pattern — separate process, shared memory I/O |
| TwinCAT HMI / Visualization | www/ directory (React app) | Web-based HMI accessible from any browser |
| ADS Protocol | WebSocket JSON API | All monitoring and HMI communication uses WebSockets |
| TcSysManager | acctl CLI | Project creation, deployment, status, log streaming |
| Scan cycle / task cycle | Tick signal | Server-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_speedaddresses themotor_speedvariable, andethercat.drive_0.rxpdo_1.controlwordaddresses 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.tomlfiles - 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.
Option 1: Use the Pre-Built Kernel (Recommended)
If you received the pre-built kernel file (bzImage) from your AutoCore distribution:
- Copy the kernel file to your Windows user folder:
# In PowerShell
copy <path-to-bzImage> $HOME\wsl_kernels\bzImage
- 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):
- 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
- 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"
- Build the kernel and modules:
make -j$(nproc)
sudo make modules_install
sudo make install
This takes 10-30 minutes depending on your machine.
- 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:8080directly from Windows. - The
acctltool 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)
-
Install usbipd-win on Windows. Download the latest
.msifrom: https://github.com/dorssel/usbipd-win/releasesRun the installer and restart if prompted.
-
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):
-
Plug in your USB-to-Ethernet adapter.
-
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
...
- 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:
- Load the USB host controller module (needed on first use after each WSL restart):
sudo modprobe vhci-hcd
If
modprobefails, you may need to manually attach. Use the IP address from the PowerShell output:sudo usbip attach -r <IP_FROM_POWERSHELL> -b 2-4
- 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
- Bring the interface up:
sudo ip link set enx6c6e0719971b up
- 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"
- Start (or restart) the EtherCAT service:
sudo systemctl restart ethercat
- 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 --shutdownor system reboot, you will need to re-run theusbipd attachcommand from PowerShell and themodprobe/ip link set upcommands 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.tomlfiles - 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
npmfor JavaScript orpipfor Python. You will usecargo buildto compile control programs andcargo installto install tools likeacctl.
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:
- autocore-server — the runtime engine
- acctl — the command-line project management tool
Installing from a Debian Package (Recommended)
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
| Setting | Description |
|---|---|
console.port | WebSocket port for CLI and web clients |
console.www_root | Path to the web console static files |
general.projects_directory | Root directory where all projects are stored |
general.port | HTTP port for the web server |
general.autocore_std_directory | Path to the autocore-std library (used for building control programs on the server) |
general.ipc_port | TCP port for module IPC communication |
general.project_name | The 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 / File | Purpose | When You Edit It |
|---|---|---|
project.json | Defines variables, hardware modules, cycle time | When adding variables, changing cycle time, or configuring hardware |
control/src/program.rs | Your control logic | This is where you spend most of your time |
control/src/main.rs | Entry point — connects to the server | Never (auto-generated) |
control/src/gm.rs | Rust struct mapping your variables | Never (auto-generated by acctl codegen) |
control/Cargo.toml | Rust dependencies | When adding external Rust libraries |
www/ | Web-based HMI | When 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:
| Field | Description | Example |
|---|---|---|
enable | Whether the control program should run | true |
source_directory | Path to the Rust source code | "./control" |
entry_point | The main Rust file | "main.rs" |
signals.tick.scan_rate_us | Cycle time in microseconds | 10000 (= 10 ms = 100 Hz) |
signals.tick.source | Where the tick comes from | "internal" (server-generated) |
Common cycle times:
| scan_rate_us | Cycle Time | Frequency | Typical Use |
|---|---|---|---|
1000 | 1 ms | 1 kHz | High-speed motion control |
2000 | 2 ms | 500 Hz | Servo drives |
5000 | 5 ms | 200 Hz | General motion |
10000 | 10 ms | 100 Hz | Process control, I/O |
50000 | 50 ms | 20 Hz | Slow 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:
- Compiles your Rust control program
- Uploads the binary to the server
- 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:
- The server generates a tick at a fixed interval (e.g., every 10 ms).
- On each tick, the control program:
- Reads all inputs from shared memory
- Executes your control logic (
process_tick) - Writes all outputs back to shared memory
- External modules (EtherCAT, Modbus) synchronize their I/O data with the same shared memory.
This is equivalent to a cyclic task in TwinCAT or a POU assigned to a periodic task in Codesys.
┌─────────────────────────────────────────┐
│ autocore-server │
│ │
Tick │ Shared Memory (autocore_cyclic) │
──────►│ ┌─────────┐ ┌──────────┐ ┌───────────┐ │
10ms │ │ Inputs │ │ Outputs │ │ Internal │ │
│ └────▲────┘ └────┬─────┘ └───────────┘ │
│ │ │ │
└───────┼───────────┼───────────────────────┘
│ │
┌──────┴───────────┴──────┐
│ Control Program │
│ (your program.rs) │
│ │
│ 1. Read inputs │
│ 2. Execute logic │
│ 3. Write outputs │
└──────────────────────────┘
Your First Control Program: A Counter
Let’s start with the simplest possible control program — a counter that increments every cycle. Open control/src/program.rs:
#![allow(unused)]
fn main() {
use autocore_std::{ControlProgram, TickContext};
use crate::gm::GlobalMemory;
pub struct MyControlProgram {
counter: u64,
}
impl MyControlProgram {
pub fn new() -> Self {
Self { counter: 0 }
}
}
impl ControlProgram for MyControlProgram {
type Memory = GlobalMemory;
fn initialize(&mut self, _mem: &mut Self::Memory) {
log::info!("Control program started!");
}
fn process_tick(&mut self, ctx: &mut TickContext<Self::Memory>) {
self.counter += 1;
// Log every 1000 cycles (= every 10 seconds at 10ms cycle time)
if self.counter % 1000 == 0 {
log::info!("Cycle count: {}", self.counter);
}
}
}
}
What is happening here:
MyControlProgramis a struct that holds your program’s state. Thecounterfield is internal to the control program — it is not shared with other processes.new()is called once at startup to create the program instance.initialize()is called once after the program connects to shared memory. Use it for one-time setup.process_tick()is called every cycle (every 10 ms by default). This is where all your control logic goes.ctx.gmgives you access to the global memory (shared variables).ctx.clientgives you access to the command client for sending messages to modules.ctx.cycleis the current cycle number (starts at 1).
Build and deploy:
acctl push control --start
acctl logs --follow
You should see "Control program started!" followed by "Cycle count: 1000" every 10 seconds.
Working with Variables
Variables are the bridge between your control program and the outside world. They are declared in project.json and automatically become fields on the GlobalMemory struct in your Rust code.
Let’s add some variables. Edit project.json:
{
"name": "my_first_machine",
"version": "0.1.0",
"description": "AutoCore project: my_first_machine",
"modules": {},
"control": {
"enable": true,
"source_directory": "./control",
"entry_point": "main.rs",
"signals": {
"tick": {
"description": "System Tick (10ms)",
"source": "internal",
"scan_rate_us": 10000
}
}
},
"variables": {
"cycle_counter": {
"type": "u32",
"direction": "status",
"description": "Number of cycles executed",
"initial": 0
},
"machine_running": {
"type": "bool",
"direction": "command",
"description": "Set to true to start the machine",
"initial": false
},
"motor_speed_setpoint": {
"type": "f32",
"direction": "command",
"description": "Desired motor speed in RPM",
"initial": 0.0
},
"motor_speed_actual": {
"type": "f32",
"direction": "status",
"description": "Current motor speed in RPM",
"initial": 0.0
}
}
}
After editing project.json, you need to regenerate the gm.rs file and re-push:
# Push the updated project.json to the server
acctl push project
# Regenerate the GlobalMemory struct from the new variables
acctl codegen
# Rebuild and restart the control program
acctl push control --start
The acctl codegen command reads the variables from the server and generates control/src/gm.rs, which contains a GlobalMemory struct with a field for each variable:
#![allow(unused)]
fn main() {
// This is auto-generated — do not edit!
#[repr(C)]
#[derive(Copy, Clone)]
pub struct GlobalMemory {
pub cycle_counter: u32,
pub machine_running: bool,
pub motor_speed_setpoint: f32,
pub motor_speed_actual: f32,
}
}
Now update your control program to use these variables:
#![allow(unused)]
fn main() {
use autocore_std::{ControlProgram, TickContext};
use crate::gm::GlobalMemory;
pub struct MyControlProgram;
impl MyControlProgram {
pub fn new() -> Self {
Self
}
}
impl ControlProgram for MyControlProgram {
type Memory = GlobalMemory;
fn initialize(&mut self, _mem: &mut Self::Memory) {
log::info!("Control program started!");
}
fn process_tick(&mut self, ctx: &mut TickContext<Self::Memory>) {
// Increment the cycle counter (visible from the web console)
ctx.gm.cycle_counter = ctx.gm.cycle_counter.wrapping_add(1);
// Only run logic when the machine is enabled
if ctx.gm.machine_running {
// Simulate motor speed ramping up to the setpoint
let error = ctx.gm.motor_speed_setpoint - ctx.gm.motor_speed_actual;
ctx.gm.motor_speed_actual += error * 0.01; // Simple first-order filter
} else {
ctx.gm.motor_speed_actual = 0.0;
}
}
}
}
You can now set machine_running to true and motor_speed_setpoint to 1500.0 from the web console or from the command line:
acctl cmd gm.write --name machine_running --value true
acctl cmd gm.write --name motor_speed_setpoint --value 1500
acctl cmd gm.read --name motor_speed_actual
Variable Types and Directions
Types
AutoCore supports the following variable types:
| Type | Size | Range | Equivalent in IEC 61131-3 |
|---|---|---|---|
bool | 1 byte | true / false | BOOL |
u8 | 1 byte | 0 to 255 | USINT / BYTE |
i8 | 1 byte | -128 to 127 | SINT |
u16 | 2 bytes | 0 to 65,535 | UINT / WORD |
i16 | 2 bytes | -32,768 to 32,767 | INT |
u32 | 4 bytes | 0 to 4,294,967,295 | UDINT / DWORD |
i32 | 4 bytes | -2,147,483,648 to 2,147,483,647 | DINT |
u64 | 8 bytes | 0 to 18,446,744,073,709,551,615 | ULINT / LWORD |
i64 | 8 bytes | -(2^63) to (2^63 - 1) | LINT |
f32 | 4 bytes | IEEE 754 single-precision float | REAL |
f64 | 8 bytes | IEEE 754 double-precision float | LREAL |
Tip: Use
u16ori16for Modbus registers (which are 16-bit). Useboolfor digital I/O. Usef32for analog values and setpoints. Useu32/i32for EtherCAT encoder positions and counters.
Directions
The direction field controls how a variable is used:
| Direction | Description | Who Writes | Who Reads | Typical Use |
|---|---|---|---|---|
"input" | Data from the field (sensors, encoders) | Hardware module | Control program | Sensor readings, encoder feedback |
"output" | Data to the field (actuators, motors) | Control program | Hardware module | Motor commands, valve outputs |
"command" | Data from the HMI or external system | HMI / Web client | Control program | Setpoints, start/stop commands |
"status" | Data to the HMI or external system | Control program | HMI / Web client | Display values, counters, states |
"internal" | Internal to the control program | Control program | Control program | Intermediate calculations |
Tip: If a variable is linked to hardware (has a
"link"field), use"input"for data coming from the device and"output"or"command"for data going to the device.
Links
A variable can be linked to a hardware I/O point. When a variable has a "link" field, the system automatically synchronizes it with the corresponding hardware register:
"motor_speed": {
"type": "u16",
"direction": "command",
"link": "modbus.vfd_01.holding_0",
"description": "Speed command to VFD"
}
The link format is: module_name.device_name.register_name.
Variables without a "link" are purely software variables — they exist in shared memory and can be read/written by the control program, the web console, or other processes, but they are not connected to any hardware.
Reading Inputs and Writing Outputs
Inside process_tick, you access variables directly as struct fields on ctx.gm:
#![allow(unused)]
fn main() {
fn process_tick(&mut self, ctx: &mut TickContext<Self::Memory>) {
// Reading an input (e.g., a sensor value)
let temperature = ctx.gm.temperature_sensor;
// Reading a command (e.g., a setpoint from the HMI)
let target_temp = ctx.gm.temperature_setpoint;
// Writing a status (e.g., for the HMI to display)
ctx.gm.temperature_error = target_temp - temperature;
// Writing an output (e.g., to a heater)
ctx.gm.heater_power = if temperature < target_temp { 100 } else { 0 };
}
}
There is no special API for reading or writing — variables are plain Rust fields. The ControlRunner handles all the shared memory synchronization before and after your process_tick call.
Using Logging
AutoCore provides a logging system that works inside the real-time control loop. Log messages are sent to the server and can be viewed with acctl logs.
#![allow(unused)]
fn main() {
fn process_tick(&mut self, ctx: &mut TickContext<Self::Memory>) {
// Available log levels (from least to most severe):
log::trace!("Very detailed debug info");
log::debug!("Debug information");
log::info!("Normal operational messages");
log::warn!("Warning: something unexpected");
log::error!("Error: something went wrong");
// Use format strings just like println!
log::info!("Temperature: {:.1}°C, Setpoint: {:.1}°C",
ctx.gm.temperature_sensor,
ctx.gm.temperature_setpoint
);
}
}
Warning: Logging inside
process_tickhappens on every cycle. At 100 Hz, logging every cycle would produce 100 messages per second. Use a counter or a condition to limit logging:#![allow(unused)] fn main() { // Log only every 5 seconds (500 cycles at 10ms) if ctx.cycle % 500 == 0 { log::info!("Status: speed={:.0} RPM", ctx.gm.motor_speed_actual); } // Log only when a state changes if ctx.gm.machine_running && !self.was_running { log::info!("Machine started"); } self.was_running = ctx.gm.machine_running; }
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 Value | Current Value | Output |
|---|---|---|
false | false | false |
false | true | true (rising edge!) |
true | true | false |
true | false | false |
How FTrig works:
| Previous Value | Current Value | Output |
|---|---|---|
true | true | false |
true | false | true (falling edge!) |
false | false | false |
false | true | false |
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:
| Input | Duration | Timer State | Output (q) | Elapsed (et) |
|---|---|---|---|---|
false | any | Reset | false | 0 |
true | 3s | Counting | false | 0..3s |
true (after 3s) | 3s | Done | true | 3s |
false (any time) | any | Reset | false | 0 |
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:
- Runs as a separate process
- Connects to your Modbus TCP devices
- Cyclically reads and writes registers
- 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:
| Field | Description |
|---|---|
modules.modbus.enabled | Set to true to enable the Modbus module |
modules.modbus.args | Must include "service" for the Modbus module |
config.devices[].name | A friendly name for the device (used in variable links) |
config.devices[].host | IP address of the Modbus TCP device |
config.devices[].port | TCP port (usually 502) |
config.devices[].slave_id | Modbus unit ID (1-247) |
config.devices[].registers[].type | "holding_register", "input_register", "coil", or "discrete_input" |
config.devices[].registers[].address | The 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 Direction | Modbus 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:
- Scans and configures slaves on startup
- Exchanges PDO data cyclically (synchronized with the server tick)
- 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 slaveson 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:
ClearPath0PpStubsis auto-generated ingm.rs. It holds CiA 402 fields that are not in the PDO mapping (likemodes_of_operation) so you don’t have to manage them manually.stubs.view(&mut ctx.gm)creates aTeknicPpViewthat theAxishelper can use.- The
let Self { axis, step, stubs } = self;destructuring is necessary so Rust’s borrow checker can see thatstubs,axis, andstepare separate borrows. axis.tick()must be called every cycle — it processes the CiA 402 state machine internally.Axisprovides 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:
| Hook | Purpose |
|---|---|
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:
| Topic | Module | Command |
|---|---|---|
labelit.status | labelit | status |
modbus.read_holding | modbus | read_holding |
python.run_script | python | run_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
| Command | Description |
|---|---|
acctl new <name> | Create a new project with the standard template |
acctl clone <host> [project] | Download a project from a server |
acctl clone <host> --list | List 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
| Command | Description |
|---|---|
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
| Command | Description |
|---|---|
acctl control start | Start the control program |
acctl control stop | Stop the control program |
acctl control restart | Restart the control program |
acctl control status | Show control program status |
Monitoring
| Command | Description |
|---|---|
acctl status | Show server status and project list |
acctl logs | Show recent control program logs |
acctl logs --follow | Stream logs in real time |
Code Generation
| Command | Description |
|---|---|
acctl codegen | Regenerate gm.rs from the server’s project.json |
acctl sync | Compare and synchronize local/remote project.json |
Configuration
| Command | Description |
|---|---|
acctl set-target <host> [--port PORT] | Set the default server address |
acctl switch <project> [--restart] | Switch the active project on the server |
Variable Management
| Command | Description |
|---|---|
acctl export-vars [--output FILE] | Export variables to CSV |
acctl import-vars [--input FILE] | Import variables from CSV |
acctl dedup-vars | Find and remove duplicate variable links |
Sending Commands
| Command | Description |
|---|---|
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.
- Allocation: When the server starts, it creates a shared memory segment called
autocore_cyclicbased on the variables inproject.json. - Mapping: The control program and all enabled modules map this segment into their own address space.
- 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:
- Is spawned as a child process by the server on startup
- Receives three CLI arguments:
--ipc-address,--module-name, and--config - Connects to the server’s IPC port (default 9100)
- Receives lifecycle commands:
initialize,configure_shm,finalize - Maps shared memory variables to exchange cyclic data
- 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
--configflag 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
ModuleHandlercommunicates 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::timeoutso a stuck camera cannot hang the IPC loop. - Graceful lifecycle: The camera worker is spawned in
on_initialize()and shut down inon_finalize().
Troubleshooting
Common Issues
Control program won’t start
Symptom: acctl control start or acctl push control --start fails.
Check:
- Is the server running?
sudo systemctl status autocore_server - Is there a build error? Check the output of
acctl push controlfor Rust compiler errors. - Is the project loaded?
acctl statusshould 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:
- Is the control program running?
acctl control status - Is the variable being written in
process_tick? Add a log statement to verify. - Is the WebSocket connected? Check the browser console for connection errors.
Hardware not responding (EtherCAT/Modbus)
Check:
- Is the module enabled in
project.json? Check"enabled": true. - Is the module executable configured in
config.ini? - Is the module running?
acctl cmd system.get_domainslists all connected modules. - Check module logs:
acctl logs --followwill show output from all modules. - For EtherCAT: Is the network cable connected? Is the correct interface configured?
- 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
| Command | What It Shows |
|---|---|
acctl status | Server version, active project, available projects |
acctl control status | Control program state (running/stopped/error) |
acctl logs --follow | Live log stream from control program and modules |
acctl cmd system.get_domains | All registered domains (modules, services) |
acctl cmd gm.read --name <var> | Current value of a variable |
sudo systemctl status autocore_server | Server process status |
sudo journalctl -u autocore_server -f | Server system log (systemd) |
Appendix A: Variable Type Reference
| Type | Rust Type | Size | Range | IEC 61131-3 Equivalent |
|---|---|---|---|---|
bool | bool | 1 byte | true / false | BOOL |
u8 | u8 | 1 byte | 0 to 255 | USINT / BYTE |
i8 | i8 | 1 byte | -128 to 127 | SINT |
u16 | u16 | 2 bytes | 0 to 65,535 | UINT / WORD |
i16 | i16 | 2 bytes | -32,768 to 32,767 | INT |
u32 | u32 | 4 bytes | 0 to 4,294,967,295 | UDINT / DWORD |
i32 | i32 | 4 bytes | -2,147,483,648 to 2,147,483,647 | DINT |
u64 | u64 | 8 bytes | 0 to 2^64 - 1 | ULINT / LWORD |
i64 | i64 | 8 bytes | -2^63 to 2^63 - 1 | LINT |
f32 | f32 | 4 bytes | IEEE 754 single precision | REAL |
f64 | f64 | 8 bytes | IEEE 754 double precision | LREAL |
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:
| Field | Type | Description |
|---|---|---|
q | bool | Output — true when timer has elapsed |
et | Duration | Elapsed 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.
| Method | Signature | Description |
|---|---|---|
send | (&mut self, topic: &str, data: Value) -> u32 | Send 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) -> bool | Check if a request is still awaiting a response. |
pending_count | (&self) -> usize | Number of outstanding requests. |
response_count | (&self) -> usize | Number 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:
| Field | Type | Description |
|---|---|---|
transaction_id | u32 | Matches the ID returned by send() |
success | bool | Whether the request was processed successfully |
data | serde_json::Value | The response payload (on success) |
error_message | String | Error description (on failure) |