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) 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.

Generating Documentation

This manual uses mdBook, the standard Rust documentation tool. The source files are individual Markdown chapters in doc/book/src/, with SUMMARY.md defining the table of contents.

Building the Website

Works on Linux, macOS, and Windows — anywhere Rust is installed:

# One-time install
cargo install mdbook

# Build the website
cd doc
mdbook build

# Or serve locally with live reload (opens browser automatically)
mdbook serve --port 3000 --open

Output: doc/dist/site/index.html

Generating a PDF

Option A: Print from browser (easiest, any platform)

The website includes a print-optimized single page at dist/site/print.html that contains the entire manual. Open it in any browser and use File > Print > Save as PDF.

cd doc
mdbook build
# Open dist/site/print.html in your browser, then File > Print > Save as PDF

Option B: mdbook-pdf (automated, requires Chrome/Chromium)

The mdbook-pdf backend generates a PDF automatically using headless Chrome:

# One-time install
cargo install mdbook-pdf

# In doc/book.toml, uncomment the [output.pdf] line, then:
cd doc
mdbook build

Output: doc/dist/site/output.pdf

Option C: pandoc (best typographic quality, Linux)

For publication-quality PDF with LaTeX typesetting:

# Install dependencies (Ubuntu/Debian)
sudo apt install pandoc texlive-latex-recommended texlive-fonts-extra texlive-latex-extra lmodern

# Generate PDF
cd doc
./build-docs.sh pdf

Output: doc/dist/pdf/autocore_user_manual.pdf

Editing the Manual

Each chapter is a separate Markdown file:

doc/book/src/
├── SUMMARY.md                              ← Table of contents
├── introduction.md                         ← Front page
├── ch01-welcome-to-autocore.md
├── ch02-generating-documentation.md        ← This chapter
├── ch03-setting-up-your-development-machine.md
├── ch09-hardware-integration-ni-daqmx.md
├── ch17-appendix-b-function-block-reference.md
└── ...

To add a new chapter:

  1. Create a new .md file in doc/book/src/
  2. Add a - [Title](filename.md) entry to SUMMARY.md
  3. Run mdbook serve to preview

To reorder chapters, edit SUMMARY.md. The file names don’t affect ordering — only the order in SUMMARY.md matters.

Rust API Documentation

For API-level documentation of autocore-std (function blocks, ControlProgram trait, CommandClient, etc.):

cd autocore-std
cargo doc --open

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
doc/mdBook user manual (build with acctl doc build)When writing documentation for your project — see Project Documentation
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",
      "description": "Number of cycles executed",
      "initial": 0
    },
    "machine_running": {
      "type": "bool",
      "description": "Set to true to start the machine",
      "initial": false
    },
    "motor_speed_setpoint": {
      "type": "f32",
      "description": "Desired motor speed in RPM",
      "initial": 0.0
    },
    "motor_speed_actual": {
      "type": "f32",
      "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

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.

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",
  "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", "description": "Start command from HMI" },
  "cmd_run":        { "type": "bool", "description": "Run command from HMI" },
  "cmd_stop":       { "type": "bool", "description": "Stop command from HMI" },
  "cmd_reset":      { "type": "bool", "description": "Reset faults from HMI" },
  "emergency_stop": { "type": "bool", "description": "Emergency stop input" },
  "motor_enable":   { "type": "bool", "description": "Motor enable output" },
  "status_code":    { "type": "i32",  "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", "description": "Start button" },
  "cmd_stop":           { "type": "bool", "description": "Stop button" },
  "cmd_reset":          { "type": "bool", "description": "Reset faults" },
  "part_present":       { "type": "bool", "description": "Part sensor at pick position" },
  "cylinder_extended":  { "type": "bool", "description": "Cylinder extended sensor" },
  "cylinder_retracted": { "type": "bool", "description": "Cylinder retracted sensor" },
  "gripper_closed":     { "type": "bool", "description": "Gripper closed sensor" },
  "extend_cylinder":    { "type": "bool", "description": "Cylinder extend solenoid" },
  "close_gripper":      { "type": "bool", "description": "Gripper close solenoid" },
  "conveyor_run":       { "type": "bool", "description": "Conveyor motor" },
  "parts_completed":    { "type": "u32",  "description": "Total parts completed" },
  "machine_state":      { "type": "i32",  "description": "Current state code" },
  "fault_active":       { "type": "bool", "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",
      "link": "modbus.sensor_unit.temperature",
      "description": "Raw temperature reading (0.1°C per count)"
    },
    "humidity_raw": {
      "type": "u16",
      "link": "modbus.sensor_unit.humidity",
      "description": "Raw humidity reading (0.1% per count)"
    },
    "setpoint_raw": {
      "type": "u16",
      "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 link determines the data flow based on the Modbus register type:

Register TypeModbus Behavior
"input_register" / "discrete_input"Module reads from the device and writes to shared memory. Your control program reads it.
"holding_register" / "coil"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",
    "link": "modbus.sensor_unit.temperature",
    "description": "Raw temperature (0.1°C per count)"
  },
  "humidity_raw": {
    "type": "u16",
    "link": "modbus.sensor_unit.humidity",
    "description": "Raw humidity (0.1% per count)"
  },
  "temperature_degc": {
    "type": "f32",
    "description": "Temperature in °C"
  },
  "humidity_pct": {
    "type": "f32",
    "description": "Humidity in %"
  },
  "temp_alarm": {
    "type": "bool",
    "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", "link": "modbus.vfd_01.control_word" },
  "vfd_speed_cmd":   { "type": "u16", "link": "modbus.vfd_01.speed_setpoint" },
  "vfd_status":      { "type": "u16", "link": "modbus.vfd_01.status_word" },
  "vfd_speed_fb":    { "type": "u16", "link": "modbus.vfd_01.speed_feedback" },
  "vfd_current":     { "type": "u16", "link": "modbus.vfd_01.current" },
  "motor_run_cmd":   { "type": "bool", "description": "Run motor from HMI" },
  "motor_speed_rpm": { "type": "f32",  "description": "Speed setpoint in RPM" },
  "motor_running":   { "type": "bool", "description": "Motor is running" },
  "motor_rpm":       { "type": "f32",  "description": "Actual speed in RPM" },
  "motor_amps":      { "type": "f32",  "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", "link": "ethercat.di_8ch.channel_1_8.input_1" },
    "di_input_2": { "type": "bool", "link": "ethercat.di_8ch.channel_1_8.input_2" },
    "di_input_3": { "type": "bool", "link": "ethercat.di_8ch.channel_1_8.input_3" },
    "di_input_4": { "type": "bool", "link": "ethercat.di_8ch.channel_1_8.input_4" },
    "di_input_5": { "type": "bool", "link": "ethercat.di_8ch.channel_1_8.input_5" },
    "di_input_6": { "type": "bool", "link": "ethercat.di_8ch.channel_1_8.input_6" },
    "di_input_7": { "type": "bool", "link": "ethercat.di_8ch.channel_1_8.input_7" },
    "di_input_8": { "type": "bool", "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.
    }
}
}

Analog Input Terminals (Strain Gauge / Load Cell)

Beckhoff EL3356 (and pin-compatible variants) measure a resistive bridge — typically a strain-gauge load cell — and expose the scaled result plus status bits over PDO. The EL3356 FB in autocore-std handles peak tracking, tare pulse timing, and SDO-based sensor calibration; this section covers just the EtherCAT-side setup needed to feed it.

Process image and GM variables

Each EL3356 slave produces four inputs and consumes one output. Map them to five GM variables using the {prefix}_* naming convention — the logical prefix (impact in the example below) is what you’ll pass to the el3356_view! macro in the control program.

{
  "modules": {
    "ethercat": {
      "config": {
        "devices": [
          {
            "name": "EL3356_0",
            "product_code": "0x0d1c3052",
            "vendor_id":    "0x00000002",
            "position":     3
          }
        ]
      }
    }
  },
  "variables": {
    "impact_load":           { "type": "f32",  "link": "ethercat.EL3356_0.load",           "description": "Scaled load (N)" },
    "impact_load_steady":    { "type": "bool", "link": "ethercat.EL3356_0.load_steady",    "description": "Steady-state flag" },
    "impact_load_error":     { "type": "bool", "link": "ethercat.EL3356_0.load_error",     "description": "Bridge error" },
    "impact_load_overrange": { "type": "bool", "link": "ethercat.EL3356_0.load_overrange", "description": "Overrange flag" },
    "impact_tare":           { "type": "bool", "link": "ethercat.EL3356_0.tare",           "description": "Tare command output" }
  }
}

The FQDN on each link must match what the EL3356’s ESI file names its PDO entries — run ethercat> scan --project_file <path> against a live bus to populate the correct product code, vendor ID, and PDO FQDNs automatically; then copy them into your variable declarations.

Calibration parameters

The terminal needs three numbers to turn raw bridge readings into engineering units:

WhatSDOSource
Sensitivity (mV/V)0x8000:0x23From the load cell’s calibration certificate
Full-scale load0x8000:0x24From the load cell’s datasheet (same units as load)
Scale factor0x8000:0x27EL3356 default is 100000.0; leave this unless the EL3356 manual calls for a different value for your setup

You have two ways to apply these:

  • At runtime from the control program — the recommended path. Call El3356::configure(client, full_scale, mv_v, scale_factor) during startup. This handles the three SDO writes and reports success/failure via the FB’s busy/error fields. See the EL3356 FB reference.
  • As startup SDOs — add entries to the device’s startup_sdo array in project.json so the ethercat module applies them before the cyclic loop starts. Use this when the values are known to be stable across deployments (e.g. a machine with a permanently mounted sensor).

Runtime configuration is preferred when the sensor can be swapped out in the field (different load cells → different mV/V). The FB stores the last-written values in configured_mv_v, configured_full_scale_load, and configured_scale_factor so the HMI can verify that calibration completed.

Minimal control program

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

pub struct MyProgram {
    load_cell: El3356,
    initialised: bool,
}

impl MyProgram {
    pub fn new() -> Self {
        Self {
            load_cell: El3356::new("EL3356_0"),
            initialised: false,
        }
    }
}

impl ControlProgram for MyProgram {
    type Memory = GlobalMemory;

    fn process_tick(&mut self, ctx: &mut TickContext<Self::Memory>) {
        if !self.initialised && !self.load_cell.busy {
            self.load_cell.configure(ctx.client, 1_000.0, 2.0, 100_000.0);
            self.initialised = true;
        }

        let mut view = el3356_view!(ctx.gm, impact);
        self.load_cell.tick(&mut view, ctx.client);
        ctx.gm.impact_peak_load = self.load_cell.peak_load;
    }
}
}

See Appendix B for the full FB API, state machine, multi-terminal setup, and TwinCAT-to-Rust porting notes.

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 add an axis entry to the axes array in the ethercat config with "type": "pp" (Profile Position mode), the code generator creates a DriveHandle struct in gm.rs (e.g., Lift). This struct bundles the Axis state machine with a vendor-neutral Cia402PpSnapshot — an owned copy of the CiA 402 PDO fields. Because the snapshot owns its data by value (no references), you can use multiple axes simultaneously without borrow conflicts.

The workflow in each tick is:

  1. sync() — copies TxPDO feedback from shared memory into the snapshot
  2. Issue commandsenable(), move_absolute(), home(), etc.
  3. tick() — advances the axis state machine and writes RxPDO outputs back to shared memory

Full Example: CiA 402 Servo (Teknic ClearPath)

This complete example walks through configuring a Teknic ClearPath servo for Profile Position mode and writing a control program that homes and moves back and forth. The same pattern works with any CiA 402 drive.

Step 1: Scan and configure the drive

With the drive powered on and connected, load device definitions and scan the bus from the autocore console:

ethercat> load
ethercat> scan --project_file ./project.json

The scan creates a project.json with a default configuration for each slave. The ClearPath will default to Cyclic Synchronous Position mode — we need to switch it to Profile Position.

List available profiles:

ethercat> configure --action list_profiles --device ClearPath_0

You should see “Profile position mode (PP)” in the list.

Select the PP profile and add the startup SDO:

ethercat> configure --action select_profile --device ClearPath_0 --profile "Profile position mode (PP)"
ethercat> configure --action add_startup --device ClearPath_0 --index 0x6060 --sub 0 --value 0x01 --comment "Motion Mode"

Verify:

ethercat> configure --action show --device ClearPath_0

Verify you see RxPDO 5 / TxPDO 5 with Profile Velocity, Profile Acceleration, and Profile Deceleration in the output entries.

Step 2: Add axis definition

Edit project.json and add an axes array to the ethercat config (alongside slaves). This tells the code generator to create a DriveHandle for this drive:

"config": {
    "axes": [
        {
            "name": "Lift",
            "link": "ClearPath_0",
            "type": "pp",
            "options": {
                "positive_limit": "ls_clearpath_pos",
                "negative_limit": "ls_clearpath_neg",
                "error_code": "clearpath_0_txpdo_5_error_code",
                "maximum_pos_limit": "lift_max_position_limit",
                "minimum_pos_limit": "lift_min_position_limit"
            },
            "outputs": {
                "position": "lift_position",
                "speed": "lift_speed",
                "is_busy": "lift_busy",
                "is_error": "lift_error",
                "error_message": "lift_error_msg",
                "motor_on": "lift_motor_on"
            }
        }
    ],
    "slaves": [...]
}
  • name — your axis name, used to generate the DriveHandle struct (e.g., "Lift" generates Lift). Choose a name that describes the axis function, not the hardware.

  • link — the slave name this axis is bound to (must match a slave in the slaves array). If you swap the motor to a different drive, change link — the control program doesn’t need to change.

  • options — sensor wiring, diagnostics, and motion settings (all optional):

    FieldTypeDefaultDescription
    positive_limitstringGlobalMemory bool for positive limit switch
    negative_limitstringGlobalMemory bool for negative limit switch
    home_sensorstringGlobalMemory bool for home reference sensor
    error_codestringGlobalMemory u16 for drive error code
    maximum_pos_limitstringGlobalMemory numeric (f32/f64/int) variable supplying a dynamic maximum software position limit, in user units
    minimum_pos_limitstringGlobalMemory numeric variable supplying a dynamic minimum software position limit, in user units
    invert_directionboolfalseNegate position targets and feedback (reverses motor direction in software)

    Software position limits (maximum_pos_limit / minimum_pos_limit). When set, the named GM variable is read every tick and used as a software envelope for the axis. Two protections apply:

    1. Move rejection. Any move_absolute or move_relative whose target would land outside the range is rejected before any PDO is touched, and the axis enters its error state. A move away from a violated limit is always allowed so you can recover.
    2. In-flight quick-stop. If the actual position passes a limit while the axis is moving toward it, control word bit 8 (halt) is asserted and the op is marked in error — the same mechanism used for hardware limit switches.

    Because the limits live in GlobalMemory, operators can tune them at runtime (web console, recipe loads, nonvolatile init) without rebuilding. If the same axis also has static limits set in AxisConfig (enable_max_position_limit / max_position_limit and the corresponding min pair), both sources are evaluated and the most restrictive value wins — the dynamic limit cannot widen a static envelope, only tighten it. Omitting the option leaves the snapshot field at None and falls back to the static config (or to no limit at all).

    Software limits only protect once the axis is homed — position_actual is meaningless before homing — so wire these alongside your homing routine.

  • outputs — axis status values published to GlobalMemory each tick (all optional, omit any you don’t need):

    FieldGM typeDescription
    positionf64Position in user units
    raw_positioni64Position in encoder counts
    speedf64Speed in user units/s
    is_busyboolAny operation in progress
    is_errorboolFault or error occurred
    error_codeu32/i32Drive error code
    error_messagestringError description
    motor_onboolDrive enabled
    in_motionboolMove in progress
    moving_positiveboolMoving in positive direction
    moving_negativeboolMoving in negative direction
    at_max_limitboolAt positive software limit
    at_min_limitboolAt negative software limit
    at_positive_limit_switchboolPositive hardware limit active
    at_negative_limit_switchboolNegative hardware limit active
    home_sensorboolHome sensor active

    The generated tick() method writes these values after advancing the axis state machine.

    Tip: Auto-Generation You do not need to manually create these variables in your project.json file. Simply define your desired variable names in the outputs and options blocks, then run the ethercat.generate_variables command. AutoCore will automatically scan your axes and scaffold all missing variables into your project with the correct data types.

Step 3: Generate variables, sync, and build

# Generate the missing variables defined in the axes output/options blocks
acctl ethercat.generate_variables

acctl sync        # push project.json to server, regenerate gm.rs
acctl push control --start   # build and deploy the control program

Step 4: Write the control program

control/src/program.rs:

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

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

pub struct MyControlProgram {
    drive: Lift,
    step: Step,
}

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 {
            drive: Lift::new(config),
            step: Step::Home,
        }
    }
}

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>) {
        // Read feedback from shared memory
        self.drive.sync(&ctx.gm);

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

        // Advance state machine and write outputs back to shared memory
        self.drive.tick(&mut ctx.gm, &mut ctx.client);
    }
}
}

Key points:

  • Lift is a DriveHandle auto-generated in gm.rs. It bundles the Axis state machine with a Cia402PpSnapshot that holds PDO field copies by value. Fields not in the PDO mapping (like modes_of_operation) are managed internally as stubs.
  • sync() copies TxPDO feedback from shared memory — call it at the start of each tick.
  • tick() advances the CiA 402 state machine and writes RxPDO outputs back — call it at the end of each tick.
  • Between sync() and tick(), issue commands freely: home(), enable(), move_absolute(), reset_faults().
  • For software homing to a limit switch or sensor, specify the GlobalMemory variable names in options in the axis config. The generated sync() method will automatically wire them each tick. See Appendix B: DriveHandle for a complete homing example.
  • Status is available via methods: is_busy(), is_error(), motor_on(), position(), error_message().
  • The DriveHandle is vendor-neutral — switching from Teknic to Yaskawa only changes the struct name and project.json configuration.

Scanning and Configuring a Network from the Console

Instead of writing project.json by hand, you can scan the physical bus and build the configuration interactively.

Step 1: Load device definitions

Device definitions describe the PDO layout, available profiles, and modular slot options for each EtherCAT slave. They are generated from ESI (EtherCAT Slave Information) XML files shipped by manufacturers.

ethercat> load

This loads device_definitions.json into memory. To regenerate definitions from ESI XML files:

ethercat> generate --source ./esi

Step 2: Scan the bus

Power on all slaves and run:

ethercat> scan --project_file ./project.json

This queries every slave on the bus for its identity (vendor ID, product code, revision) and writes a starter project.json with one entry per slave. Device names are auto-assigned based on the device definition database.

Step 3: Configure devices

After scanning, each device has a default configuration. Use the configure command to customize PDO profiles, modular slots, and startup SDOs.

List available PDO profiles for a device:

ethercat> configure --action list_profiles --device EL1008_0

Select a profile:

ethercat> configure --action select_profile --device EL1008_0 --profile "Default"

For modular devices (e.g., IO-Link masters), list and assign slots:

ethercat> configure --action list_slots --device IOLink_0
ethercat> configure --action select_slot --device IOLink_0 --slot 0 --module SomeModuleId --name sensor_1

Import an IODD file for an IO-Link port:

ethercat> configure --action import_iodd --device IOLink_0 --slot 0 --file ./sensor.iodd

Add a startup SDO (CoE initialization command):

ethercat> configure --action add_startup --device EL3064_0 --index 0x8000 --sub 1 --value 0x03 --comment "Set range to 0-10V"

Remove a startup SDO:

ethercat> configure --action rm_startup --device EL3064_0 --index 0x8000 --sub 1

List all FQDNs for a device:

ethercat> configure --action list_fqdns --device EL1008_0

Configuring CiA 402 Drives (Teknic ClearPath, Yaskawa, etc.)

For CiA 402 servo drives, you must select the correct PDO profile and add a startup SDO before the drive will work with the DriveHandle. See the full Teknic ClearPath example above for a complete walkthrough.

Common mistake: If you skip profile selection, the drive defaults to Cyclic Synchronous Position (CSP) mode. Moves will fail with overspeed faults (0x80BD) because the Axis helper sends position commands as PP set-points, but the drive interprets them as cyclic position targets — causing sudden jumps and triggering the overspeed protection.

Step 4: Generate variables and activate

# Auto-generate project variables from PDO entries and configured axes
ethercat> generate_variables

# Start the EtherCAT runtime
ethercat> activate

Step 5: Validate the configuration

After activation, verify the physical bus matches the project configuration:

ethercat> validate

This compares each slave’s identity against what project.json expects and reports mismatches.

Distributed Clocks

Some EtherCAT slaves — most servo drives in CSP/CSV mode, and any slave that does precise sampling or motion control — require Distributed Clocks (DC). DC is a hardware mechanism in which the master distributes a single reference time across the bus so every slave fires its Sync0 (and optionally Sync1) interrupt at the same phase of the cycle. Without DC, a DC-capable slave will typically refuse the SAFEOP → OP transition and report:

EtherCAT ERROR 0-N: Failed to set OP state, slave refused state change (SAFEOP + ERROR).
EtherCAT ERROR 0-N: AL status message 0x0027: "Freerun not supported".

AL status 0x0027 (“Freerun not supported”) is the slave’s way of saying: I need DC sync signals and the master isn’t configuring them.

Enabling DC on a slave

The easiest way is the configure command, which edits project.json for you:

ethercat> configure --device SV660_0 set_dc --assign-activate 0x0300

With no --sync0-cycle-ns, the Sync0 cycle defaults to the project’s runtime_settings.cycle_time_us × 1000, which is what you want almost every time. To override or to enable Sync1 as well:

ethercat> configure --device SV660_0 set_dc \
    --assign-activate 0x0700 \
    --sync0-cycle-ns 1000000 \
    --sync1-cycle-ns 1000000

To disable DC on a slave:

ethercat> configure --device SV660_0 clear_dc

As with the other configure actions, changes are persisted to project.json but the EtherCAT runtime must be restarted to pick them up.

If you prefer to edit project.json directly, each slave’s config block has six DC fields. By default they are all zero / false. To enable DC manually, set:

"config": {
  "dc_enabled": true,
  "dc_assign_activate": "0x0300",
  "dc_sync0_cycle_ns": 10000000,
  "dc_sync0_shift_ns": 0,
  "dc_sync1_cycle_ns": 0,
  "dc_sync1_shift_ns": 0
}
FieldMeaning
dc_enabledMaster runs DC setup for this slave and drives the bus time each cycle.
dc_assign_activate16-bit bitmask from the slave’s ESI <OpMode>/<AssignActivate>. Typical values: 0x0300 (Sync0 only, most servo drives), 0x0700 (Sync0 + Sync1), 0x0730 (some high-end drives). Must match what the ESI specifies for the operating mode you’re using.
dc_sync0_cycle_nsSync0 period in nanoseconds. Must equal the EtherCAT cycle time. For a 10 ms cycle, use 10_000_000. For 1 ms, 1_000_000.
dc_sync0_shift_nsPhase offset of Sync0 relative to the start of the cycle. Leave at 0 unless the drive manual specifies otherwise.
dc_sync1_cycle_nsSync1 period in nanoseconds. 0 disables Sync1. When non-zero, it is usually the same as dc_sync0_cycle_ns.
dc_sync1_shift_nsPhase offset of Sync1.

When any slave has dc_enabled: true, the master automatically takes on three extra per-cycle responsibilities: it sets its application time, synchronises the reference clock, and distributes that time to the other slaves. No code changes are needed in your control program — the runtime handles it.

Which slaves need DC?

  • Servo drives in CSP, CSV, or CST mode (Cyclic Synchronous Position/Velocity/Torque): almost always require DC. This includes Inovance SV660 / SV630, Yaskawa Σ-7/Σ-X, Delta ASDA-A3, Beckhoff AX5000, Omron 1S, and most CiA 402 drives when run in cyclic-synchronous modes.
  • Servo drives in PP or PV mode (Profile Position / Profile Velocity): DC is sometimes still required by the firmware even though the mode is not strictly synchronous — check the slave’s ESI. The Inovance SV660 is an example: it declares AssignActivate=0x0300 and will reject OP if DC isn’t configured.
  • Sampling I/O that needs timestamp correlation (e.g. Beckhoff EL3632 vibration, EL1252 timestamp inputs).
  • Simple I/O couplers, digital I/O, and analog I/O without sync requirements (EK1100, EL1008, EL2008, EL3356, IFM IMPACT67): leave dc_enabled: false.

When in doubt, open the ESI XML in a text editor and look for an <OpMode> element with a non-zero <AssignActivate>. If it’s present, the slave supports DC; if the ESI marks it as required, you must enable it.

Example: Inovance SV660 servo drive

The SV660 is a CiA 402 drive common on Chinese industrial machinery. It requires DC even in PP mode. A minimal slave entry for a 10 ms control cycle:

{
  "name": "SV660_0",
  "position": 4,
  "device_id": {
    "vendor_id": 1048576,
    "product_code": 786701,
    "revision_number": 65536
  },
  "config": {
    "dc_enabled": true,
    "dc_assign_activate": "0x0300",
    "dc_sync0_cycle_ns": 10000000,
    "dc_sync0_shift_ns": 0,
    "dc_sync1_cycle_ns": 0,
    "dc_sync1_shift_ns": 0,
    "watchdog_ms": 100
  },
  "sync_managers": [ /* ... PDO mappings ... */ ]
}

Vendor ID 1048576 (0x100000) is Inovance. The same entry works for multiple SV660s on the bus — just change name, position, and the PDO mapping.

Troubleshooting

SymptomLikely cause
AL 0x0027 “Freerun not supported”Slave needs DC but dc_enabled is false. Enable it.
AL 0x0030 “Invalid DC sync configuration”dc_assign_activate is wrong for this slave/mode. Try 0x0700 or check the ESI.
AL 0x002D “Sync manager watchdog” after enabling DCdc_sync0_cycle_ns doesn’t match runtime_settings.cycle_time_us × 1000.
Drive enters OP but position jitter is highdc_sync0_shift_ns may need tuning — consult the drive manual for recommended shift.
Only some DC slaves make it to OPUsually a cycle-time mismatch. Every DC slave on the bus must use the same dc_sync0_cycle_ns.

Check dmesg | grep EtherCAT after a failed activation — the IgH master logs each slave’s DC configuration attempt, which makes it easy to spot which slave rejected the transition and why.

SDO Access (CoE)

Read and write CANopen-over-EtherCAT objects at runtime for diagnostics and parameter tuning:

# Read an SDO
ethercat> read_sdo --device RC8_0 --index 0x1001 --sub 0

# Write an SDO
ethercat> write_sdo --device RC8_0 --index 0x8000 --sub 1 --value 0x03

Index values use hexadecimal format with the 0x prefix.

Runtime Monitoring

# Module and master status
ethercat> get_status

# Per-slave AL state, error flags, and identity
ethercat> get_slave_status

# Cycle time, link status, and performance counters
ethercat> get_network_stats

Status FQDNs are also available for live monitoring from the HMI:

FQDNDescription
ethercat.module.stateModule state: Idle, Configuring, Op
ethercat.module.cycle_timeEtherCAT cycle time in microseconds
ethercat.master.stateMaster state: init, preop, safeop, op, mixed, disconnected

EtherCAT Module Command Reference

All commands use the ethercat. topic prefix.

Network Scanning & Discovery

CommandArgumentsDescription
scan--project_file (optional), --esi_source (optional)Scan the bus and write starter project.json
validate--project_file (optional), --instance_name (optional)Compare physical bus against project config
get_slave_statusPer-slave AL state, error flags, and identity
get_network_statsCycle time, link status, performance counters

Device Definitions

CommandArgumentsDescription
generate--source (optional, default: ./esi), --target (optional)Generate device definitions from ESI XML files
load--source (optional)Load device definition database into memory

Configuration Management

CommandArgumentsDescription
show_config--device (optional), --project_file (optional)Show active project configuration as JSON
list_devices--project_file (optional)List all configured devices
list_pdos--device (optional), --project_file (optional)List PDO entries with FQDNs, types, and offsets
generate_variables--project_file (optional)Auto-generate project variables from PDO entries and configured axes

Device Configuration

All configure actions require --device. Use --action to select the operation:

ActionExtra ArgumentsDescription
showShow current device configuration
list_profilesList available PDO profiles
select_profile--profile (required)Select a PDO profile
list_modulesList available modules for a modular device
list_slots--verbose (optional)List slots for a modular device
select_slot--slot, --module (required), --name (optional)Assign a module to a slot
import_iodd--slot, --file (required), --module (optional), --name (optional)Import IODD file for IO-Link configuration
add_startup--index, --sub, --value (required), --comment (optional)Add a startup SDO
rm_startup--index, --sub (required)Remove a startup SDO
list_fqdnsList all FQDNs for the device
set_dc--assign-activate (required); --sync0-cycle-ns, --sync0-shift-ns, --sync1-cycle-ns, --sync1-shift-ns (optional)Enable distributed clocks for the slave
clear_dcDisable distributed clocks for the slave

SDO Access

CommandArgumentsDescription
read_sdo--device, --index, --sub (all required)Read a CoE object from a slave
write_sdo--device, --index, --sub, --value (all required)Write a CoE object to a slave

Runtime Control

CommandArgumentsDescription
activate--project_file (optional)Start the EtherCAT runtime
stopStop the EtherCAT runtime
get_statusMaster connection and runtime status
helpShow all available commands
get_catalog--project_file (optional)List all available FQDN endpoints

Hardware Integration: NI DAQmx

NI DAQmx Overview

The autocore-ni module integrates National Instruments DAQmx data acquisition hardware with AutoCore. It runs as an external module process, connects to autocore-server via IPC, and provides:

  • Live streaming via shared memory (one segment per task, updated every callback)
  • Triggered capture via shared memory (one segment per DAQ config, written on trigger)
  • Scalar statistics via Global Memory variables (value, min, max, rate per channel)
  • Console commands for building and managing configurations interactively

The module supports any mix of NI DAQmx channel types: analog voltage, strain gage, accelerometer, force sensor, linear/angular encoder, frequency, and edge counting.

Initial Setup

Before using the NI module, register its executable in config.ini:

[modules]
ni = /opt/autocore/bin/modules/autocore-ni

Then start the module from the console (no project.json entry needed yet):

ni> system.load_module --name ni

The module is now running and addressable. You can configure it interactively, then save to project.json so it starts automatically on future server restarts.

Registering Hardware on Linux

On Windows, NI MAX automatically discovers and registers network cDAQ chassis. On Linux there is no NI MAX, so devices must be registered manually by importing a .ini configuration file via nidaqmxconfig. The NI module can generate this file for you.

Step 1: Discover chassis on the network

The chassis must be powered on and network-reachable. Run:

ni> discover_devices

This calls nilsdev --verbose and returns a JSON array with each chassis’s name, product type, serial number, IP address, MAC address, and hostname. Make note of the device name — you will need it in the next step.

Step 2: Add the device to your configuration

Use add_device with the chassis name from step 1 and a modules array describing which NI modules are installed in each slot:

ni> add_device --name cdaq-3814 --modules '[{"product_type":"NI 9218","slot_num":1},{"product_type":"NI 9401","slot_num":2}]'

Each module entry requires product_type (the NI model, e.g. “NI 9218”) and slot_num (1-based physical slot). You can also provide an optional name field — if omitted, modules are named <device_name>-<slot_num> (e.g. cdaq-3814-1).

By default, reserve is set to true, which gives autocore-ni exclusive access to the chassis. Set --reserve false if you need to share the device.

Step 3: Generate the .ini and import it

ni> generate_device_config --import true

This combines the device configuration from step 2 with the runtime information from nilsdev to produce a .ini file, then imports it via nidaqmxconfig --import --replace. The generated file is saved alongside your project.json as ni_devices.ini.

To generate without importing (for review):

ni> generate_device_config

Step 4: Save and verify

ni> save_config

Verify the device is registered:

nilsdev --verbose

You should see your chassis and modules listed. The device is now ready for DAQmx tasks.

Complete example: register a cDAQ-9181 with two modules

# Start the module
ni> system.load_module --name ni

# See what's on the network
ni> discover_devices

# Register the chassis with a force sensor module (slot 1) and digital I/O (slot 2)
ni> add_device --name cdaq-3814 --modules '[{"product_type":"NI 9218","slot_num":1},{"product_type":"NI 9401","slot_num":2,"name":"digital-io"}]'

# Generate and import
ni> generate_device_config --import true

# Now configure tasks and channels as usual
ni> new_project --task_name AnalogInput
ni> add_channel --task AnalogInput --name load --physical_channel cdaq-3814Mod1/ai0 --type voltage --min_val -5 --max_val 5
ni> save_config
ni> start

The devices array is persisted in project.json alongside tasks and DAQ configs:

{
  "modules": {
    "ni": {
      "config": {
        "devices": [
          {
            "name": "cdaq-3814",
            "reserve": true,
            "modules": [
              { "product_type": "NI 9218", "slot_num": 1 },
              { "product_type": "NI 9401", "slot_num": 2, "name": "digital-io" }
            ]
          }
        ],
        "tasks": [ ... ],
        "daq": [ ... ]
      }
    }
  }
}

Configuring a Project from the Console

The fastest way to set up a new NI configuration is through console commands. This example creates a project with one analog voltage channel:

ni> new_project --task_name AnalogInput
ni> add_channel --task AnalogInput --name load --physical_channel Dev1/ai0 --type voltage --min_val -5 --max_val 5
ni> save_config
ni> start

Step by step:

  1. new_project creates a minimal config with one empty task (1 kHz, 1000 sample buffer)
  2. add_channel adds a channel using the voltage preset. The --type flag selects sensible defaults; --min_val and --max_val override specific parameters.
  3. save_config writes the config into the currently loaded project.json. On future server restarts, the module loads this config automatically.
  4. start begins hardware acquisition.

To modify the task parameters (sample rate, buffer size, etc.), use add_task with explicit values:

ni> new_project
ni> remove_task --name Task
ni> add_task --name AnalogInput --sample_rate 100000 --samples_per_channel 10000 --samples_per_event 1000
ni> add_channel --task AnalogInput --name load --physical_channel cDAQ1Mod1/ai0 --type force_bridge --min_val -2224 --max_val 2224 --voltage_excit_val 3.3
ni> save_config
ni> restart

Channel Type Presets

The --type flag on add_channel maps to a DAQmx channel creation function with sensible defaults. Use ni.list_channel_types to see all available presets.

TypeDAQmx FunctionDescription
voltageCreateAIVoltageChanAnalog voltage input
strain_gageCreateAIStrainGageChanStrain gage (quarter/half/full bridge)
accelerometerCreateAIAccelChanIEPE accelerometer
force_bridgeCreateAIForceBridgeTwoPointLinChanForce sensor via bridge two-point linear calibration
force_iepeCreateAIForceIEPEChanIEPE force sensor (e.g. PCB impact hammer)
linear_encoderCreateCILinEncoderChanQuadrature linear encoder (one per task)
angular_encoderCreateCIAngEncoderChanQuadrature angular/rotary encoder (one per task)
count_edgesCreateCICountEdgesChanCounter edge counting
frequencyCreateCIFreqChanFrequency measurement

Any additional --key value flags are passed through as parameter overrides on top of the preset defaults. For example, --type voltage --min_val -10 --max_val 10 uses the voltage preset but changes the input range.

For advanced use, you can bypass presets entirely with --create_function and --create_args:

ni> add_channel --task AI --name ch0 --physical_channel Dev1/ai0 --create_function CreateAIVoltageChan --create_args '{"terminal_config":10106,"min_val":-5,"max_val":5}'

Multi-Task Configurations (Encoders)

DAQmx counter input channels (encoders, frequency, edge counting) each require their own task. To synchronize them with an analog input task, set clock_type to external and parent_task to the master task name:

ni> new_project --task_name AnalogInput
ni> remove_task --name AnalogInput
ni> add_task --name AnalogInput --sample_rate 100000 --samples_per_channel 10000 --samples_per_event 1000
ni> add_channel --task AnalogInput --name load --physical_channel cDAQ1Mod1/ai0 --type force_bridge --min_val -2224 --max_val 2224

ni> add_task --name Encoder1 --sample_rate 100000 --samples_per_channel 10000 --samples_per_event 1000 --clock_type external --parent_task AnalogInput
ni> add_channel --task Encoder1 --name pos_x --physical_channel cDAQ1Mod2/ctr0 --type linear_encoder --dist_per_pulse 0.000004

ni> add_task --name Encoder2 --sample_rate 100000 --samples_per_channel 10000 --samples_per_event 1000 --clock_type external --parent_task AnalogInput
ni> add_channel --task Encoder2 --name pos_y --physical_channel cDAQ1Mod2/ctr1 --type linear_encoder --dist_per_pulse 0.000004

ni> add_task --name Encoder3 --sample_rate 100000 --samples_per_channel 10000 --samples_per_event 1000 --clock_type external --parent_task AnalogInput
ni> add_channel --task Encoder3 --name angle --physical_channel cDAQ1Mod2/ctr2 --type angular_encoder --pulses_per_rev 2048

ni> save_config
ni> restart

When parent_task is set, the module automatically synchronizes the clocks: it copies the master’s timebase, sets the arm start trigger, and starts slave tasks before the master. All channels across all synchronized tasks are sample-aligned.

Adding Triggered Capture (DAQ)

A DAQ configuration captures a fixed window of data from one or more channels when a trigger condition is met. Channels can span multiple tasks (as long as they share a clock via parent_task).

ni> add_daq --name impact --capture_length 10000 --pre_trigger_samples 100 --channels '["load","pos_x"]' --trigger '{"type":"rising_edge","source_channels":["load"],"level":50.0,"hysteresis":2.0}'
ni> save_config
ni> restart

The source_channels field accepts an array of one or more channel names. When multiple channels are specified, their sample values are summed before the trigger condition is evaluated. This is useful for force plates and multi-sensor setups where the trigger should fire on the combined signal regardless of which sensor sees the event:

ni> add_daq --name impact --capture_length 10000 --pre_trigger_samples 100 \
    --channels '["s1","s2","s3","s4","pos_x"]' \
    --trigger '{"type":"rising_edge","source_channels":["s1","s2","s3","s4"],"level":50.0,"hysteresis":2.0}'

All source channels must be in the same task.

To arm the trigger at runtime:

ni> impact.arm

When the trigger fires, the module writes the capture data to shared memory and sets the impact_data_ready GM variable.

Available trigger types: rising_edge, falling_edge, rising_window, falling_window.

Full Example: Load Cell + Three Encoders

This example configures a complete impact test system with a force sensor and three position encoders, all synchronized at 100 kHz:

ni> new_project
ni> remove_task --name Task

# Master task: analog input with load cell
ni> add_task --name AnalogInput --sample_rate 100000 --samples_per_channel 10000 --samples_per_event 1000
ni> add_channel --task AnalogInput --name load --physical_channel cDAQ1Mod1/ai0 --type force_bridge --min_val -2224 --max_val 2224 --voltage_excit_val 3.3

# Three encoder tasks, each synced to AnalogInput
ni> add_task --name EncX --sample_rate 100000 --samples_per_channel 10000 --samples_per_event 1000 --clock_type external --parent_task AnalogInput
ni> add_channel --task EncX --name pos_x --physical_channel cDAQ1Mod2/ctr0 --type linear_encoder --dist_per_pulse 0.000004

ni> add_task --name EncY --sample_rate 100000 --samples_per_channel 10000 --samples_per_event 1000 --clock_type external --parent_task AnalogInput
ni> add_channel --task EncY --name pos_y --physical_channel cDAQ1Mod2/ctr1 --type linear_encoder --dist_per_pulse 0.000004

ni> add_task --name EncZ --sample_rate 100000 --samples_per_channel 10000 --samples_per_event 1000 --clock_type external --parent_task AnalogInput
ni> add_channel --task EncZ --name pos_z --physical_channel cDAQ1Mod2/ctr2 --type linear_encoder --dist_per_pulse 0.000004

# Triggered capture across tasks
ni> add_daq --name impact --capture_length 10000 --pre_trigger_samples 100 --channels '["load","pos_x","pos_y","pos_z"]' --trigger '{"type":"rising_edge","source_channels":["load"],"level":50.0,"hysteresis":2.0}'

# Save and start
ni> save_config
ni> restart

# Check status
ni> status

# Arm the trigger
ni> impact.arm

After save_config, the full configuration is persisted in project.json. On future server restarts, the module starts automatically and begins acquisition (set auto_start: true in the config for production).

Modifying Configuration In-Place

You don’t need to remove and re-add a task or channel to change a single field. Use set_task and set_channel to update fields directly, and reset_task_field / reset_channel_field to revert them to defaults.

Updating task fields

Any task field except name can be updated:

# Make Encoder1 a slave of AnalogInput
ni> set_task --name Encoder1 --parent_task AnalogInput --clock_type external

# Change the sample rate on the master
ni> set_task --name AnalogInput --sample_rate 50000 --samples_per_event 500

# Apply changes
ni> restart

Updating channel fields

Top-level channel fields (physical_channel, create_function, value_aggregation, compute_rate, create_args) are set by name. Any unrecognized key is treated as a create_args sub-field, so you can set hardware parameters directly:

# Change the input range on a voltage channel
ni> set_channel --task AnalogInput --name load --min_val -500 --max_val 500

# Change the encoder resolution
ni> set_channel --task Encoder1 --name pos_x --dist_per_pulse 0.000002

# Switch aggregation mode
ni> set_channel --task AnalogInput --name load --value_aggregation rms

Resetting fields to defaults

Use reset_task_field and reset_channel_field to revert a field. For tasks, resettable fields are timeout_ms, phase_offset_samples, clock_type, clock_source, and parent_task. For channels, resettable fields are value_aggregation, compute_rate, and create_args (or any create_args sub-field by name):

# Remove clock sync — make this an independent task again
ni> reset_task_field --name Encoder1 --field parent_task
ni> reset_task_field --name Encoder1 --field clock_type

# Remove a create_args override so the preset default applies
ni> reset_channel_field --task Encoder1 --name pos_x --field z_index_enable

# Reset aggregation back to auto-inferred default
ni> reset_channel_field --task AnalogInput --name load --field value_aggregation

Inspecting configuration

# Full config as JSON
ni> get_config

# List all tasks with channels and actual timing
ni> list_tasks

# Detailed view of one task (includes create_args for each channel)
ni> task --name AnalogInput

When the worker is running, list_tasks and task include actual_sample_rate and time_increment fields read back from the hardware. These values are also written to shared memory as <task>_actual_sample_rate (f64) and <task>_time_increment (f64) so control programs can read them.

NI Module Command Reference

All commands use the ni. topic prefix. Use ni.help for a summary or ni.help --command <name> for detailed argument information.

Acquisition Control

CommandArgumentsDescription
startStart DAQmx acquisition
stopStop DAQmx acquisition
restartStop and restart (applies config changes)
statusShow module status, channel values, and errors

Configuration Inspection

CommandArgumentsDescription
get_configShow current in-memory configuration as JSON
show_configAlias for get_config
describeHuman-readable summary of tasks, channels, DAQ, and timing
list_tasksList all tasks with channels and actual timing info (JSON)
task--name (required)Show full configuration for a specific task (JSON)

Project Lifecycle

CommandArgumentsDescription
new_project--task_name (optional, default: “Task”)Create a minimal starter configuration
save_config--path (optional), --generate_variables (optional, bool)Save config to project.json. --generate_variables true also writes variable declarations.
generate_variables--path (optional)Generate project.json variable declarations for all NI channels

Task Management

CommandArgumentsDescription
add_task--name, --sample_rate, --samples_per_channel, --samples_per_event (all required); --timeout_ms (default: 2500), --clock_type (default: “internal”), --clock_source, --parent_taskAdd a new DAQmx task
set_task--name (required), plus any field --key value pairsUpdate fields on an existing task
remove_task--name (required)Remove a task by name
reset_task_field--name (required), --field (required)Reset a task field to its default value
duplicate_task--name (required), --new_name (required)Clone a task with a new name (channels not copied)
rename_task--name (required), --new_name (required)Rename a task (updates parent_task references)
calc_timing--name (required), --callback_hz (default: 100), --buffer_seconds (default: 1.0), --apply (default: true)Calculate and set samples_per_event and samples_per_channel from target callback rate

Channel Management

CommandArgumentsDescription
add_channel--task, --name, --physical_channel (all required); --type or --create_function (one required). Extra --key value flags override preset defaults.Add a channel to a task
set_channel--task, --name (required), plus any --key value pairs. Unrecognized keys become create_args sub-fields.Update fields on an existing channel
remove_channel--task (required), --name (required)Remove a channel from a task
reset_channel_field--task, --name, --field (all required)Reset a channel field to default, or remove a create_args sub-field
rename_channel--task, --name, --new_name (all required)Rename a channel (updates DAQ channel lists and trigger references)
list_channel_typesList available channel type presets with default parameters

DAQ (Triggered Capture) Management

CommandArgumentsDescription
add_daq--name, --capture_length, --channels (JSON array), --trigger (JSON object) (all required); --pre_trigger_samples (default: 0)Add a triggered capture configuration
remove_daq--name (required)Remove a DAQ configuration

Device Management (Linux)

CommandArgumentsDescription
discover_devicesRun nilsdev --verbose and return discovered chassis info
add_device--name (required), --modules (JSON array), --reserve (default: true)Add a cDAQ chassis device configuration
remove_device--name (required)Remove a device configuration
set_device--name (required), plus --key value pairsUpdate device fields (reserve, modules)
generate_device_config--path (optional), --import (default: false)Generate .ini from config + nilsdev and optionally import

Runtime Monitoring

CommandArgumentsDescription
reset_minmax--channel (optional, omit for all)Reset min/max tracking
list_devicesList connected NI DAQmx devices
clear_errorsClear the error log
<channel>Read all fields for a channel (value, min, max, rate, data_received)
<channel>.valueRead only the current value (scalar f64)
<channel>.minRead only the running minimum
<channel>.maxRead only the running maximum
<channel>.rateRead only the rate
<daq>.armArm a DAQ trigger
<daq>.disarmDisarm a DAQ trigger

Individual channel sub-fields (ni.ai0.value, ni.ai0.rate, etc.) return scalar values and can be used as link targets in project.json variable declarations.

Help and Discovery

CommandArgumentsDescription
help--command (optional)Show command list, or detailed help for one command
get_catalog--detailed (optional, bool)List all available endpoints

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, the library sends a CommandMessage to the server using the topic-based protocol:

{
  "transaction_id": 1,
  "topic": "gm.motor_speed_actual",
  "message_type": 4,
  "data": {}
}

The message_type values correspond to operations: 2 = Read, 3 = Write, 4 = Subscribe, 5 = Unsubscribe. The topic field is the FQDN of the resource (e.g., gm.motor_speed_actual).

The server then pushes updates whenever the value changes:

{
  "transaction_id": 0,
  "topic": "gm.motor_speed_actual",
  "message_type": 6,
  "data": 1247.5,
  "success": true
}

Here message_type: 6 is a Broadcast — an unsolicited push from the server.

Sending Commands from the HMI

To write a variable value:

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

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

const result = await sendCommand('gm.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
system.full_shutdownsystemfull_shutdown
system.cancel_full_shutdownsystemcancel_full_shutdown

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 is the command-line tool for managing AutoCore projects. It handles project creation, deployment, monitoring, and sending commands to the server and its modules.

Configuration

acctl reads server connection settings from acctl.toml in the project directory (created by acctl clone or acctl set-target), falling back to ~/.acctl.toml for global defaults.

[server]
host = "192.168.1.100"
port = 11969

[build]
release = true

Global flags --host and --port override all config files for a single invocation:

acctl --host 192.168.1.200 status

acctl Command Reference

Project Creation

CommandDescription
acctl new <name>Create a new project from the standard template (Rust control program + React web UI)
acctl clone <host> [project] [-P port] [-d dir]Clone a project from a remote server
acctl clone <host> --listList available projects on a server
acctl new my_machine
acctl clone 192.168.1.100 my_machine
acctl clone 192.168.1.100 --list

Project Inspection

CommandDescription
acctl infoShow a human-readable project summary (modules, variables, control program, www status)
acctl validateCheck project.json for errors (syntax, types, duplicate variables, broken links)
acctl statusShow server status, control program state, and project list (requires server connection)
acctl info         # Local — reads project.json, no server needed

Offline Code Generation

The autocore_server executable can generate the gm.rs and results.ts files directly without needing to start the full system (useful for CI/CD or development machines lacking physical hardware like EtherCAT).

# Generate gm.rs and results.ts offline
cargo run --bin autocore_server -- --generate /path/to/project.json

This bypasses the background servelets and exits immediately with code 0 on success. acctl validate # Local — checks for errors before deploying acctl status # Remote — queries the running server


`acctl validate` checks:
- JSON syntax
- Required fields on variables (`type`)
- Valid type values (`f64`, `bool`, `u64`, etc.)
- Duplicate variable names
- Variable `link` targets reference configured module domains

#### Server Configuration

| Command | Description |
|---|---|
| `acctl set-target <host> [--port PORT]` | Save the server address to `acctl.toml` |
| `acctl switch <project> [--restart]` | Switch the active project on the server |

#### Deployment

| Command | Flags | Description |
|---|---|---|
| `acctl push project` | `--restart` | Upload project.json to the server |
| `acctl push www` | `--no-build`, `--source` | Build (`npm run build`) and upload web HMI. `--no-build` skips the build. `--source` pushes full `www/` instead of `www/dist/`. |
| `acctl push control` | `--start`, `--no-build`, `--source`, `--force` | Build (`cargo build`) and upload the control binary. `--start` starts it after upload. `--source` pushes full source for remote build. `--force` skips project.json sync check. |
| `acctl push doc` | `--no-build` | Build (`acctl doc build`) and upload the generated documentation. `--no-build` uploads an existing `doc/book/` without rebuilding (fails if missing). |
| `acctl pull` | `--extract` | Download the active project as a zip |
| `acctl upload <file>` | `--dest PATH` | Upload an arbitrary file to the project directory (default: `lib/<filename>`) |

```bash
# Typical deploy workflow
acctl push project
acctl push www
acctl push control --start

# Or individually with options
acctl push www --no-build        # Skip npm build, push existing dist/
acctl push control --no-build    # Skip cargo build, push existing binary

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 state and PID

Monitoring

CommandDescription
acctl statusServer status, control program state, project list
acctl logsShow recent control program log output
acctl logs --followStream logs in real time (colorized by level)

Log levels are colorized: ERROR (red), WARN (yellow), INFO (green), DEBUG (blue), TRACE (dimmed).

Code Generation and Sync

CommandDescription
acctl codegenRegenerate control/src/gm.rs from the server’s project.json (shared memory bindings). Requires a running server.
acctl codegen-tags [--force]Regenerate www/src/AutoCoreTags.ts from the local project.json. Pure local operation — no server connection needed.
acctl syncCompare local vs server project.json interactively. Options: pull, push, or skip per-section. Auto-runs codegen after sync.
acctl diff(Planned) Show what would change on push

After adding or removing variables in project.json, always run acctl codegen to update the Rust shared memory bindings before rebuilding the control program.

acctl codegen-tags — web UI tag generation

Each variable in project.json supports an optional boolean field ux. When "ux": true, acctl codegen-tags emits a record for that variable into the generated block of www/src/AutoCoreTags.ts, giving the React web UI a typed handle (tagName, fqdn, valueType) for subscriptions and controls. Variables without ux: true are ignored.

"variables": {
  "lift_axis_position": { "type": "f64", "ux": true,  "description": "Lift axis position (mm)" },
  "internal_watchdog":  { "type": "u32", "ux": false, "description": "Never shown in HMI" },
  "req_start_auto":     { "type": "bool", "ux": true }
}

Type mapping from project.json to TypeScript’s valueType:

project.json typeTS valueType
bool"boolean"
u8u64, i8i64, f32, f64"number"
string"string"
anything else(warns and skips)

Tag names are derived from the variable name by snake_case → camelCase conversion (lift_axis_positionliftAxisPosition). The FQDN is always gm.<variable_name>.

Output file layout

The generated file contains two arrays combined into the exported acTagSpec:

// autocore-codegen:generated-start
// DO NOT EDIT: this block is regenerated by `acctl codegen-tags`.
export const acTagSpecGenerated = [
    { "tagName": "liftAxisPosition", "fqdn": "gm.lift_axis_position", "valueType": "number" },
    { "tagName": "reqStartAuto",     "fqdn": "gm.req_start_auto",     "valueType": "boolean" },
    // ... one record per variable with ux: true ...
] as const satisfies readonly TagConfig[];
// autocore-codegen:generated-end

// Hand-written tags and per-tag overrides — safe to edit.
export const acTagSpecCustom = [
    { tagName: "liftPosition", fqdn: "gm.lift_axis_position", valueType: "number",
      subscriptionOptions: { sampling_interval_ms: 300 }, scale: "position" },
    // ...
] as const satisfies readonly TagConfig[];

export const acTagSpec = [
    ...acTagSpecGenerated,
    ...acTagSpecCustom,
] as const satisfies readonly TagConfig[];

Put anything that needs subscriptionOptions, scale, or any other TagConfig property into acTagSpecCustom — the generated block only carries the three basic fields. The sentinel comments // autocore-codegen:generated-start and // autocore-codegen:generated-end delimit the replaceable region.

Regeneration behavior

On each run, acctl codegen-tags decides whether to replace only the generated block or rewrite the whole file:

SituationAction
www/src/AutoCoreTags.ts doesn’t existWrite full file from template.
File exists, has both sentinel comments and acTagSpecCustomReplace only the generated block; acTagSpecCustom is preserved.
File exists but missing a sentinel or acTagSpecCustomFull rewrite from template. Old file is saved to AutoCoreTags.ts.bak.
--force is passedFull rewrite regardless. Old file saved to .bak.

A .bak sibling is only ever produced when the tool actually overwrites a hand-edited file — routine in-place updates leave nothing on disk besides the new AutoCoreTags.ts.

Workflow
# Mark variables for the UI in project.json (editor or acctl import-vars)
# Then:
acctl codegen-tags            # → www/src/AutoCoreTags.ts
cd www && npm run dev         # React picks up the updated tag list immediately

Run codegen-tags any time you flip ux on/off or add/rename variables. The React side has no caching — refreshing the dev server or the built app picks up the new list on next load.

Variable Management

CommandDescription
acctl export-vars [--output FILE]Export variables to CSV (default: variables.csv)
acctl import-vars [--input FILE]Import variables from CSV (default: variables.csv)
acctl dedup-varsFind and interactively resolve variables with duplicate hardware links

CSV columns: name, type, link, description, initial.

acctl export-vars --output variables.csv
# Edit in spreadsheet...
acctl import-vars --input variables.csv
acctl dedup-vars   # Check for conflicts

Project Documentation

Every project created by acctl new includes a doc/ directory with an mdBook-based user manual. The acctl doc subcommands build, serve, and keep that manual in sync with project.json and the control program source.

CommandFlagsDescription
acctl doc init--forceScaffold doc/ (book.toml + the five starter Markdown files) in an existing project. Skips files that already exist; --force overwrites. Use this to add the doc directory to projects created before acctl doc support.
acctl doc buildBuild static HTML output at doc/book/. Runs generate-vars and cargo doc automatically.
acctl doc serve--port PORT (default 4444)Serve the book locally with live reload.
acctl doc generate-varsRegenerate doc/src/variables.md from project.json (hardware-linked / bit-mapped / plain tables).
acctl doc cleanRemove doc/book/ and doc/src/rustdoc/.
acctl doc init                     # Scaffold doc/ if the project doesn't have one yet
acctl doc serve                    # http://localhost:4444 with live reload
acctl doc serve --port 8080        # Custom port
acctl doc build                    # One-shot build → doc/book/index.html

New projects created by acctl new already contain a scaffolded doc/, so you only need acctl doc init when retrofitting an older project or when you’ve deleted doc/ and want to start over. The command reads the project name from project.json to populate the book title and introduction page, and never overwrites files by default — safe to run repeatedly.

On first use, if mdbook is not on your PATH, acctl installs it automatically via cargo install mdbook --locked (one-time, ~60s). cargo doc ships with every Rust toolchain, so no additional installation is needed for the Rustdoc section.

Distribution

The output at doc/book/ is a self-contained static site. You have three distribution options:

  1. Push to the serveracctl push doc builds the book and uploads it to the active project on the server. autocore-server automatically serves the active project’s documentation on its documentation port (default 4444, configurable in config.ini):

    http://<server-ip>:4444/
    

    Operators get up-to-date docs as a side effect of deployment — no separate hosting required. When no documentation has been pushed, the port serves a placeholder page explaining how to run acctl push doc. Switching the active project on the server automatically swaps the served docs to the new project’s book.

  2. Zip and sharedoc/book/ is fully self-contained. Zip it, email it, or drop it on a shared drive. Recipients unzip and open index.html directly from the filesystem.

  3. Host elsewhere — Push doc/book/ to any static web host (GitHub Pages, S3, internal nginx, etc.). All links are relative.

Configuring the documentation port

The server reads the doc port from config.ini:

[general]
port = 80          # Main HMI port
doc_port = 4444    # Documentation port (this)

Omit doc_port to use the default of 4444. Set it to a different value if 4444 is already in use on the target.

Note: As of this writing, autocore-server’s HTTP endpoints — including port 4444 — are unauthenticated. If your project documentation contains sensitive information, keep the server on a trusted network until the forthcoming authentication gate ships.

Sending Commands to Modules

CommandDescription
acctl cmd <topic> [args...]Send a command to the server (same as the AutoCore console)

The topic format is domain.command. Arguments are parsed as --key value pairs. Values are auto-detected as numbers, booleans, JSON objects/arrays, or strings.

# System commands
acctl cmd system.get_domains
acctl cmd system.list_modules
acctl cmd system.load_module --name ni
acctl cmd system.new_project --project_name my_machine

# Global Memory (read/write variables)
acctl cmd gm.read --name motor_speed
acctl cmd gm.write --name motor_speed_setpoint --value 1500

# Module commands (NI example)
acctl cmd ni.status
acctl cmd ni.describe
acctl cmd ni.add_channel --task AnalogInput --name ai0 --physical_channel Dev1/ai0 --type voltage
acctl cmd ni.save_config --generate_variables true

# Any module that implements CommandRegistry
acctl cmd modbus.status
acctl cmd labelit.camera_start --ip 192.168.1.50

Working with Multiple Projects

Each AutoCore server can host multiple projects, but only one is active at a time.

acctl status                                    # See all projects and which is active
acctl switch other_project --restart            # Switch to a different project
acctl cmd system.new_project --project_name new_machine  # Create a new project on the server

Deploying to a Remote Server

# 1. Set the target server (saved to acctl.toml)
acctl set-target 192.168.1.100

# 2. Verify the connection
acctl status

# 3. Validate before deploying
acctl validate

# 4. Deploy
acctl push project
acctl push www
acctl push control --start

# 5. Monitor remotely
acctl logs --follow

Importing and Exporting Variables

For large projects, manage variables in a spreadsheet and import them:

acctl export-vars --output variables.csv
# Edit the CSV in your spreadsheet application...
acctl import-vars --input variables.csv
acctl dedup-vars   # Resolve any duplicate links

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

Writing Project Documentation

The project’s doc/ directory is an mdBook — the same tool used for this manual. Source files live in doc/src/ as Markdown; the table of contents is doc/src/SUMMARY.md.

Default layout (created by acctl new, or by acctl doc init on an existing project):

doc/
├── book.toml              # mdBook configuration
└── src/
    ├── SUMMARY.md         # Table of contents
    ├── introduction.md    # Edit this — your project overview
    ├── variables.md       # Auto-generated — do not edit
    └── control_api.md     # Links to the Rustdoc section

Retrofitting older projects. If your project was created before acctl doc support and doc/book.toml doesn’t exist, run acctl doc init from the project root. It scaffolds the same five files that acctl new would have produced, pulling the project name from project.json for the book title. Existing files are left untouched; pass --force to overwrite.

What Gets Auto-Generated

Two parts of the book are regenerated every time you run acctl doc build or acctl doc serve — do not edit them by hand:

  • doc/src/variables.md — a grouped FQDN table of every entry in project.json’s variables map. Three sections are emitted when non-empty: Hardware-Linked (entries with a link field), Bit-Mapped (entries with source + bit), and Other. Columns include FQDN, type, description, and the relevant linkage fields.
  • doc/src/rustdoc/ — a copy of cargo doc --no-deps output from control/. The default control_api.md chapter links into rustdoc/index.html. Doc comments (///) in your control program source become a browsable API reference.

Typical Authoring Workflow

# Start the live-reload server while you write
acctl doc serve

# In another shell, edit doc/src/introduction.md and any other chapters
# Add new chapters by creating new .md files and listing them in SUMMARY.md

# Once happy, produce a static build to hand off
acctl doc build
# → doc/book/index.html

Because generate-vars and cargo doc run on every build, changes to project.json variables or doc comments in control/ appear in the book without any extra step.

Distribution

doc/book/ is a standalone static site — no runtime dependencies, all links relative. Typical distribution options:

  • Zip doc/book/ and email or share the archive; recipients unzip and open index.html directly.
  • Push doc/book/ to any static web host (GitHub Pages, S3, an internal nginx, etc.).
  • Commit doc/book/ to a docs branch for versioned online access.

Add doc/book/ and doc/src/rustdoc/ to your .gitignore if you prefer to keep only sources in version control — both are fully reproducible from the sources plus project.json and control/.


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.

The CommandMessage Protocol

All communication in AutoCore — between web clients and the server, between the CLI and the server, and between modules and the server — uses the CommandMessage protocol. Understanding this protocol helps you debug communication issues and write effective HMI code.

A CommandMessage is a JSON object with the following fields:

{
  "transaction_id": 101,
  "timecode": 1768960000000,
  "topic": "gm.motor_speed",
  "message_type": 2,
  "data": null,
  "crc": 0,
  "success": false,
  "error_message": ""
}
FieldTypeDescription
transaction_idnumberUnique ID for matching responses to requests. The server echoes this back. For broadcasts, this is 0.
timecodenumberTimestamp in milliseconds since UNIX epoch.
topicstringThe FQDN (Fully Qualified Domain Name) of the resource. The first segment routes to the appropriate module or servelet (e.g., gm, modbus, ethercat, datastore).
message_typenumberThe operation to perform (see table below).
dataanyThe payload. For a Write, this is the value to set. For a Read Response, this is the value retrieved.
crcnumberOptional CRC32 checksum for message integrity verification. Defaults to 0.
successbooleantrue if the operation succeeded, false if it failed. Only meaningful in responses.
error_messagestringHuman-readable error description if success is false. Otherwise empty.

Message Types

NameValueDescription
NoOp0No operation. Used for connection testing / ping.
Response1Reply to a previous request. The transaction_id matches the original.
Read2Request to read the current value of topic.
Write3Request to update the value of topic.
Subscribe4Request to receive updates whenever topic changes.
Unsubscribe5Stop receiving updates for topic.
Broadcast6Unsolicited push from server to client (live variable update).
Heartbeat7Keepalive signal.
Control8System control message (initialize, finalize, configure).
Request10Generic RPC call. The topic implies the action, data contains arguments.

The protocol follows a REST-like pattern: the topic is the resource (like a URL path), and the message_type is the verb (like an HTTP method).

Common Workflows

Reading a variable:

// Request (Client → Server)
{ "transaction_id": 101, "topic": "gm.motor_speed", "message_type": 2, "data": null }

// Response (Server → Client)
{ "transaction_id": 101, "topic": "gm.motor_speed", "message_type": 1, "data": 1500, "success": true }

Writing a variable:

// Request
{ "transaction_id": 102, "topic": "gm.motor_speed_setpoint", "message_type": 3, "data": 1200 }

// Response
{ "transaction_id": 102, "topic": "gm.motor_speed_setpoint", "message_type": 1, "success": true }

Subscribing to live updates:

// Subscribe request
{ "transaction_id": 103, "topic": "gm.motor_speed", "message_type": 4, "data": {} }

// Confirmation
{ "transaction_id": 103, "topic": "gm.motor_speed", "message_type": 1, "success": true }

// Subsequent broadcasts (sent automatically when value changes)
{ "transaction_id": 0, "topic": "gm.motor_speed", "message_type": 6, "data": 1485, "success": true }

FQDN Routing

The topic string determines where a message is routed. The first segment (before the first .) is the domain, which maps to a servelet or module:

DomainRoutes ToExample Topics
gmGlobal Memory serveletgm.motor_speed, gm.cycle_counter
systemSystem serveletsystem.get_domains, system.new_project, system.full_shutdown
datastoreDatastore serveletdatastore.calibration.offset
modbusModbus modulemodbus.vfd_01.speed_setpoint
ethercatEtherCAT moduleethercat.clearpath_0.rxpdo_5.controlword
pythonPython serveletpython.run_script

Glossary

TermDefinition
FQDNFully Qualified Domain Name. A dot-separated hierarchical address for any resource in the system. Example: ethercat.servo_drive.rxpdo_1.controlword
PDOProcess Data Object. The cyclic data image exchanged with fieldbus devices every scan cycle.
SDOService Data Object. A request/response protocol for reading or writing individual configuration parameters from a device. Used for acyclic (on-demand) access.
Cyclic dataData exchanged at a fixed interval (every tick). PDO data from EtherCAT slaves is cyclic. Requires deterministic timing.
Acyclic dataData exchanged on demand or at variable intervals. Modbus register reads, SDO access, and CommandMessage requests are acyclic.
Process imageThe complete set of input and output data for all devices on a fieldbus, updated each scan cycle.
Scan cycleOne complete exchange of process data with all fieldbus devices. At a 1 ms cycle time, there are 1,000 scan cycles per second.
ServeletAn internal module within autocore-server that handles a specific domain of messages (e.g., GM servelet, Datastore servelet, System servelet).

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().

Standardized Results System

The Standardized Results System provides a unified way to collect, store, and export test data across the entire AutoCore stack. By defining your test schema in project.json, you automatically get type-safe Rust code for your control program and dynamic forms for your React HMI.

Architecture Overview

The system is designed for high-performance industrial environments:

  1. Schema Definition: All test structures are defined in project.json.
  2. Code Generation: Auto-generates typed Rust structs and TypeScript interfaces.
  3. Real-Time Collection: The control program pushes cycle data via IPC (non-blocking).
  4. Asynchronous Storage: The ResultsServelet handles disk I/O, UTC timestamping, and checksumming.
  5. Filesystem-Based: Data is stored as standard JSON and JSONL files for maximum portability.

Configuration (project.json)

Test definitions are added to the test_definitions block. You can link fields to Global Memory (GM) variables using the source property to enable automatic data fetching.

{
  "test_definitions": {
    "impact_test": {
      "project_fields": [
        { "name": "customer", "type": "string", "required": true }
      ],
      "config_fields": [
        { "name": "drop_height", "type": "f32", "units": "mm", "source": "gm.drop_height_mm" },
        { "name": "specimen_id", "type": "string", "required": true }
      ],
      "cycle_fields": [
        { "name": "drop_index", "type": "u32", "source": "gm.cycle_count" },
        { "name": "peak_g", "type": "f32", "source": "gm.total_peak_load" },
        { "name": "judgement", "type": "string" }
      ],
      "results_fields": [
        { "name": "avg_peak_g", "type": "f32" }
      ]
    }
  }
}

Field Properties

PropertyDescription
nameThe internal key for the data field.
typeData type (e.g., f32, u32, string, bool).
unitsOptional unit string for display in the UI.
requiredIf true, the UI will prevent starting a test until a value is entered.
sourceLink to a GM FQDN. The code generator will auto-fetch this value during add_cycle().

Storage Structure

Results are stored in the datastore/results/ directory, organized by Project ID and Test ID (ISO Time String).

  • test.json: Metadata, config values, and a snapshot of the schema.
  • cycles.jsonl: Append-only JSON Lines file containing all cycle data.
  • raw_data/: Subdirectory for heavy arrays (e.g., high-speed DAQ traces).

Control Program Usage

The code generator creates a specific TestManager for every definition.

// 1. Initialize in your struct
struct MyProgram {
    test_manager: ImpactTestManager,
}

// 2. Start a test (locks in metadata and config)
self.test_manager.start_test("my_project_123", ctx.client);

// 3. Push a cycle
// Fields with a "source" are fetched automatically from ctx.gm!
self.test_manager.add_cycle("GOOD".to_string(), ctx);

// 4. Update test-wide results
self.test_manager.update_results(11.2, ctx);

React HMI Integration

The @adcops/autocore-react library provides dynamic components that read the generated schema:

  • <TestSetupForm />: Automatically renders input boxes and handles validation.
  • <ResultHistoryTable />: Fetches summaries and displays them in a grid.
  • <ExportButton />: Triggers server-side generation of CSV or PDF reports.

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 system.full_shutdownSchedule a full PC shutdown (15 s delay)
acctl cmd system.cancel_full_shutdownCancel a pending full shutdown
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

Scalar Types

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

String Type

TypeRust TypeSizeRange
stringFixedString<N>N bytesUTF-8 text, zero-padded to capacity

Fixed-length strings are stored as zero-padded byte arrays in shared memory. The max_length field sets the capacity in bytes (default: 64, maximum: 255).

"info_test_id":      { "type": "string", "description": "Test identifier" },
"info_specimen_name": { "type": "string", "max_length": 128, "description": "Specimen name" },
"info_notes":        { "type": "string", "max_length": 255, "description": "Operator notes" }

In the control program, string variables appear as FixedString<N> fields:

// Read a string
let test_id = ctx.gm.info_test_id.as_str();
log::info!("Test: {}", test_id);

// Write a string
ctx.gm.info_test_id.set("TEST-001");

// Check if empty
if ctx.gm.info_notes.is_empty() {
    log::warn!("No notes entered");
}

Strings longer than the capacity are silently truncated at a valid UTF-8 character boundary.

Bit-Mapped Variables

Bool variables can be mapped to individual bits of an integer variable using the source and bit fields. This is useful for EtherCAT devices that pack multiple digital I/O channels into a single word.

"impact67_0_digital_inputs":  { "type": "u16", "link": "ethercat.impact67_0.digital_inputs" },
"impact67_0_digital_outputs": { "type": "u16", "link": "ethercat.impact67_0.digital_outputs" },
"ls_centering_neg": { "type": "bool", "source": "impact67_0_digital_inputs", "bit": 0 },
"ls_centering_pos": { "type": "bool", "source": "impact67_0_digital_inputs", "bit": 1 },
"ls_lift_neg":      { "type": "bool", "source": "impact67_0_digital_inputs", "bit": 2 },
"cr_lift_brake":    { "type": "bool", "source": "impact67_0_digital_outputs", "bit": 0 }

The code generator produces unpack_bits() and pack_bits() methods on GlobalMemory that are called automatically by the framework each cycle:

  1. After reading shared memory: unpack_bits() extracts each bool from its source word
  2. Your process_tick() runs — read and write the individual bools naturally
  3. Before writing shared memory: pack_bits() inserts each bool back into its source word

Unmapped bits in the source word are preserved.

Rules:

  • source must name another variable in the same project
  • source must be an integer type (u8, u16, u32, u64, i8, i16, i32, i64)
  • bit is 0-based (0 = LSB) and must be within the source type’s bit width
  • The bit-mapped variable must be type bool
  • source and bit must always be specified together

In the control program, bit-mapped variables are ordinary bools — no special access pattern:

// Read individual digital inputs
if ctx.gm.ls_centering_neg {
    log::info!("Centering negative limit reached");
}

// Set individual digital outputs
ctx.gm.cr_lift_brake = true;

Variable Configuration Fields

FieldTypeRequiredDescription
typestringyesData type (see tables above)
linkstringnoHardware FQDN link (e.g., "ethercat.el2004.output1")
descriptionstringnoHuman-readable description
initialanynoInitial value (as JSON, parsed based on type)
nonvolatileboolnoWhen true, value persists across restarts
max_lengthnumbernoString capacity in bytes (default: 64, max: 255)
sourcestringnoSource variable for bit-mapped bools
bitnumbernoBit position in source (0 = LSB)

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

Toggles its output on and off at a fixed frequency (0.5 seconds on, 0.5 seconds off) while the input is true.

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

let mut blink = Blink::new();

// Oscillates automatically while input is true
let q = blink.call(true); // Goes true immediately
// After 500ms... q becomes false
// After 1000ms... q becomes true

// Reset and output false
let q = blink.call(false); 
}

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
}

Shutdown — System Shutdown Controller

Initiates or cancels a full system shutdown via IPC. The server delays the actual power-off by 15 seconds, giving the control program (or a human operator) time to cancel. All methods are non-blocking — the block tracks the IPC transaction internally and updates its output flags each scan cycle.

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

struct MyProgram {
    shutdown: Shutdown,
    shutdown_trigger: RTrig,
    cancel_trigger: RTrig,
}

impl MyProgram {
    fn new() -> Self {
        Self {
            shutdown: Shutdown::new(),
            shutdown_trigger: RTrig::new(),
            cancel_trigger: RTrig::new(),
        }
    }
}
}

In process_tick:

#![allow(unused)]
fn main() {
// Always call once per cycle to poll for server responses
self.shutdown.call(ctx.client);

// Initiate shutdown on rising edge of a button
if self.shutdown_trigger.call(ctx.gm.shutdown_button) {
    self.shutdown.initiate(ctx.client);
}

// Cancel shutdown on rising edge of an abort button
if self.cancel_trigger.call(ctx.gm.abort_button) {
    self.shutdown.cancel(ctx.client);
}

// React to results
if self.shutdown.done {
    log::info!("Server confirmed the command");
}
if self.shutdown.error {
    log::error!("Shutdown error: {}", self.shutdown.error_message);
}
}

Methods:

MethodSignatureDescription
new() -> SelfCreate in idle state
call(&mut self, client: &mut CommandClient)Poll for response — call every scan cycle
initiate(&mut self, client: &mut CommandClient)Send system.full_shutdown. No-op while busy.
cancel(&mut self, client: &mut CommandClient)Send system.cancel_full_shutdown. No-op while busy.
is_initiating(&self) -> boolA shutdown initiation is pending
is_cancelling(&self) -> boolA shutdown cancellation is pending

Output fields:

FieldTypeDescription
busybooltrue while waiting for the server to respond
donebooltrue for one cycle after the server confirms the command
errorbooltrue for one cycle after the server returns an error
error_messageStringError description from the server (empty when no error)

Server broadcasts: When a shutdown is scheduled, the server sends system.shutdown_pending to all connected clients. If cancelled, it sends system.shutdown_cancelled. After the 15-second delay elapses, it sends system.shutdown_executing just before powering off.

Typical flow:

Cycle 1:  initiate()          → busy=true
Cycle 2:  call()              → (waiting for response)
Cycle 3:  call()              → done=true, busy=false  (server accepted)
          ... 15 seconds pass on the server ...
          Server powers off the PC

Cancellation flow:

Cycle 1:  initiate()          → busy=true
Cycle 2:  call()              → done=true  (shutdown scheduled)
Cycle 3:  call()              → done cleared
Cycle 4:  cancel()            → busy=true
Cycle 5:  call()              → done=true  (shutdown cancelled)

ni::DaqCapture — NI DAQ Triggered Capture

Manages the full lifecycle of a triggered DAQ capture: arm the trigger, wait for the hardware event, and retrieve the captured data. All communication is via IPC commands to the autocore-ni module — the control program does not need to interact with capture shared memory directly.

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

struct MyProgram {
    daq: DaqCapture,
    arm_trigger: RTrig,
}

impl MyProgram {
    fn new() -> Self {
        Self {
            daq: DaqCapture::new("ni.impact"),  // matches the DAQ name in NI config
            arm_trigger: RTrig::new(),
        }
    }
}
}

In process_tick:

#![allow(unused)]
fn main() {
// Call every cycle with the execute signal and a timeout (ms).
// Rising edge on the first argument triggers a new capture sequence.
self.daq.call(ctx.gm.arm_request, 5000, ctx.client);

// Check results
if self.daq.error {
    log::error!("Capture error: {}", self.daq.error_message);
}

if !self.daq.busy && !self.daq.error {
    if let Some(data) = &self.daq.data {
        // Capture complete — process the data
        log::info!(
            "Captured {} channels, {} samples/ch at {} Hz",
            data.channel_count, data.actual_samples, data.sample_rate,
        );

        // Access channel data: data.channels[ch_idx][sample_idx]
        let ch0_peak = data.channels[0].iter().cloned().fold(f64::MIN, f64::max);
        log::info!("Channel 0 peak: {:.2}", ch0_peak);

        // Trigger point is at sample index data.pre_trigger_samples
        let trigger_sample = data.pre_trigger_samples;
        log::info!("Trigger at sample {}", trigger_sample);
    }
}
}

Constructor:

MethodSignatureDescription
new(daq_fqdn: &str) -> SelfCreate a new capture FB. daq_fqdn is the FQDN prefix for the DAQ, e.g. "ni.impact".

Call signature:

#![allow(unused)]
fn main() {
pub fn call(&mut self, execute: bool, timeout_ms: u32, client: &mut CommandClient)
}

Call once per cycle. A rising edge on execute starts a new capture sequence. The FB internally handles edge detection, arming, polling for completion, reading the data, and timeout tracking.

Output fields:

FieldTypeDescription
busybooltrue from the rising edge of execute until capture completes or an error/timeout occurs
activebooltrue while the DAQ is armed and waiting for the hardware trigger event
errorbooltrue when an error or timeout occurs. Stays true until the next rising edge of execute.
error_messageStringError description (empty when no error)
dataOption<CaptureData>Some(...) after a successful capture, None otherwise

CaptureData fields:

FieldTypeDescription
channelsVec<Vec<f64>>Sample data per channel. channels[ch_idx][sample_idx].
channel_countusizeNumber of channels in the capture
capture_lengthusizeConfigured post-trigger samples per channel
pre_trigger_samplesusizeConfigured pre-trigger samples per channel
actual_samplesusizeTotal samples per channel actually written (pre + post)
sample_ratef64Sample rate in Hz
timestamp_nsu64UNIX timestamp (nanoseconds) of the trigger event
sequenceu64Monotonically increasing capture sequence number

State machine:

Idle ──(rising edge on execute)──> Arming        (busy=true)
Arming ──(arm confirmed)────────> Waiting        (active=true)
Waiting ──(data ready)──────────> Reading Data
Waiting ──(timeout)─────────────> Idle           (error=true)
Reading Data ──(data received)──> Idle           (data=Some(...))
Reading Data ──(error)──────────> Idle           (error=true)

IPC commands used internally: <daq_fqdn>.arm, <daq_fqdn>.capture_status, <daq_fqdn>.read_capture. These are handled by the autocore-ni module.

Complete example — impact test with force plate:

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

pub struct ImpactTest {
    daq: DaqCapture,
    peak_force: f64,
}

impl ImpactTest {
    pub fn new() -> Self {
        Self {
            daq: DaqCapture::new("ni.impact"),
            peak_force: 0.0,
        }
    }
}

impl ControlProgram for ImpactTest {
    type Memory = GlobalMemory;

    fn process_tick(&mut self, ctx: &mut TickContext<Self::Memory>) {
        // Arm on rising edge of the HMI button, 10 second timeout
        self.daq.call(ctx.gm.arm_request, 10000, ctx.client);

        // Write status to HMI
        ctx.gm.capture_busy = self.daq.busy;
        ctx.gm.capture_active = self.daq.active;
        ctx.gm.capture_error = self.daq.error;

        // Process completed capture
        if !self.daq.busy && !self.daq.error {
            if let Some(data) = &self.daq.data {
                // Sum all force channels to get total force per sample
                let num_samples = data.actual_samples;
                let mut total_force = vec![0.0f64; num_samples];
                for ch in &data.channels {
                    for (i, &v) in ch.iter().enumerate() {
                        total_force[i] += v;
                    }
                }

                // Find peak total force
                self.peak_force = total_force.iter().cloned().fold(f64::MIN, f64::max);
                ctx.gm.peak_force = self.peak_force;

                log::info!("Impact captured: peak force = {:.1} N", self.peak_force);
            }
        }
    }
}
}

beckhoff::El3356 — Beckhoff EL3356 Strain-Gauge Terminal

Function block for the Beckhoff EL3356 single-channel strain-gauge evaluation terminal (and pin-compatible variants). Handles three things:

  1. Peak tracking — maintains a running largest-magnitude peak_load that resets on tare or reset_peak().
  2. Tare — pulses the terminal’s tare output bit high for 100 ms and zeros the peak.
  3. Load-cell calibration — writes the three SDO parameters the EL3356 needs to scale raw bridge readings into engineering units (sensitivity, full-scale load, scale factor).

All IPC traffic is non-blocking. The FB owns an internal SdoClient scoped to the EtherCAT device name you pass to new().

Project.json prerequisites

Before writing a control program, project.json must declare five GM variables linked to the EL3356’s PDOs. Using a logical prefix of impact:

"variables": {
  "impact_load":           { "type": "f32",  "link": "ethercat.EL3356_0.load",           "description": "Scaled load (N)" },
  "impact_load_steady":    { "type": "bool", "link": "ethercat.EL3356_0.load_steady",    "description": "Steady-state flag" },
  "impact_load_error":     { "type": "bool", "link": "ethercat.EL3356_0.load_error",     "description": "Bridge error bit" },
  "impact_load_overrange": { "type": "bool", "link": "ethercat.EL3356_0.load_overrange", "description": "Overrange flag" },
  "impact_tare":           { "type": "bool", "link": "ethercat.EL3356_0.tare",           "description": "Tare command output" }
}

The {prefix}_* naming convention is required — the el3356_view! macro derives all five field names by concatenation. Replace impact with any prefix you like (e.g. load_cell, fz_sensor) and use that same identifier when invoking the macro. See Chapter 8 — Analog Input Terminals for the EtherCAT-side hardware configuration that produces these FQDNs.

Basic usage

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

pub struct LoadCellDemo {
    load_cell: El3356,
    manual_tare_edge: RTrig,
}

impl LoadCellDemo {
    pub fn new() -> Self {
        Self {
            load_cell: El3356::new("EL3356_0"),  // ethercat device name
            manual_tare_edge: RTrig::new(),
        }
    }
}

impl ControlProgram for LoadCellDemo {
    type Memory = GlobalMemory;

    fn process_tick(&mut self, ctx: &mut TickContext<Self::Memory>) {
        // Rising edge on an HMI button tares the load cell
        if self.manual_tare_edge.call(ctx.gm.manual_tare) {
            self.load_cell.tare();
        }

        // Build the view from the linked GM fields and run the FB
        let mut view = el3356_view!(ctx.gm, impact);
        self.load_cell.tick(&mut view, ctx.client);

        // Expose peak to the HMI
        ctx.gm.impact_peak_load = self.load_cell.peak_load;
    }
}
}

tick() must be called every scan — it’s what actually writes the tare bit to view.tare, updates peak_load, and advances any in-flight SDO sequence.

Constructor

MethodSignatureDescription
new(device: &str) -> SelfCreate a new FB scoped to an EtherCAT device name (matches the devices[].name entry in the ethercat config — e.g. "EL3356_0").

Methods

Lifecycle & tare
MethodSignatureNon-blockingDescription
tick(&mut self, view: &mut El3356View, client: &mut CommandClient)yesCall every scan. Updates peak_load from view.load, releases the 100 ms tare pulse, and progresses any active SDO sequence.
tare(&mut self)yesStart a 100 ms pulse on view.tare and zero peak_load. Subsequent tick() calls hold view.tare high until the window expires, then clear it. Calling tare() while a pulse is already active restarts the window.
reset_peak(&mut self)yesZero peak_load. No IPC.
reset(&mut self)yesFull reset: clear error, cancel in-flight SDO, release tare pulse, discard last sdo_read result. Does not zero peak_load or configured_* fields.
clear_error(&mut self)yesClear error and error_message.
Calibration (three-step SDO sequences on 0x8000)
MethodSignatureNon-blockingDescription
configure(&mut self, client: &mut CommandClient, full_scale_load: f32, sensitivity_mv_v: f32, scale_factor: f32)yesStart a three-step SDO write sequence to subs 0x23, 0x24, 0x27. Sets busy=true. No-op (warning logged) if already busy. Clears error at start.
read_configuration(&mut self, client: &mut CommandClient)yesStart a three-step SDO read sequence that pulls mV/V, full-scale, and scale factor from the terminal’s non-volatile memory and populates the configured_* fields. Sets busy=true. No-op if already busy. Clears error and resets all three configured_* fields to None at the start.
Filter / ADC mode configuration

These write to sub-indices of 0x8000 via the generic sdo_write machinery — each sets busy=true; wait for it to clear (or is_error()) before issuing the next. All are no-op (with a warning log) if called while busy.

MethodSignatureWrites subDescription
set_mode0_filter_enabled(&mut self, client, enable: bool)0x01Enable/disable the software filter in Mode 0 (10.5 kSps, high-precision). Default TRUE.
set_mode1_filter_enabled(&mut self, client, enable: bool)0x02Enable/disable the software filter in Mode 1 (105.5 kSps, fast). Default TRUE.
set_mode0_averager_enabled(&mut self, client, enable: bool)0x03Enable/disable the 4-sample hardware averager in Mode 0 (~0.14 ms added latency). Default TRUE.
set_mode1_averager_enabled(&mut self, client, enable: bool)0x05Enable/disable the 4-sample hardware averager in Mode 1 (~0.014 ms added latency). Default TRUE.
set_mode0_filter(&mut self, client, filter: El3356Filters)0x11Select the Mode 0 software filter. See El3356Filters.
set_mode1_filter(&mut self, client, filter: El3356Filters)0x12Select the Mode 1 software filter.
Low-level / accessors
MethodSignatureNon-blockingDescription
sdo_write(&mut self, client, index: u16, sub_index: u8, value: serde_json::Value)yesWrite an arbitrary SDO. Runs through the FB’s busy/state machine (sets busy=true). Does not touch the configured_* calibration fields — orthogonal to configure().
sdo_read(&mut self, client, index: u16, sub_index: u8)yesRead an arbitrary SDO. Response lands in the internal result buffer; retrieve via result() / result_as_* once busy clears. Also does not touch configured_*.
is_busy(&self) -> boolSame as reading the busy field directly.
is_error(&self) -> boolSame as reading the error field directly.
result(&self) -> serde_json::ValueFull response payload from the last sdo_read — the object with value, value_hex, size, raw_bytes, etc. Prefer the typed accessors for scalar reads.
result_as_f64(&self) -> Option<f64>value field of the last sdo_read, coerced to f64. None before any read.
result_as_i64(&self) -> Option<i64>value field as i64. For REAL32 SDOs this returns the raw u32 bit pattern; use result_as_f32 instead.
result_as_f32(&self) -> Option<f32>value field reinterpreted as IEEE-754 REAL32 via f32::from_bits. Correct accessor for REAL32 SDOs such as the EL3356’s calibration parameters.

Output fields

FieldTypeDescription
peak_loadf32Largest absolute load seen since the last tare or reset_peak(). The signed value at the peak is stored.
busybooltrue during a configure() or read_configuration() sequence.
errorboolSticky — set on any SDO failure. Cleared by clear_error() or the start of the next configure() / read_configuration() call.
error_messageStringDescription of the most recent error (empty when none).
configured_mv_vOption<f32>Current sensitivity (sub 0x23) — set by a successful configure() or read_configuration(). Reset to None at the start of each such call.
configured_full_scale_loadOption<f32>Current full-scale load (sub 0x24). Same lifecycle as configured_mv_v.
configured_scale_factorOption<f32>Current scale factor (sub 0x27). Same lifecycle as configured_mv_v.

Configure state machine

configure() writes three SDOs in sequence. Each transition happens on a subsequent tick() after the IPC response lands:

Idle ──(configure called)───> WritingMvV         (busy=true, writes sub 0x23)
WritingMvV ──(OK)───────────> WritingFullScale    (writes sub 0x24)
WritingMvV ──(Err/Timeout)──> Idle                (error=true, busy=false)
WritingFullScale ──(OK)─────> WritingScaleFactor  (writes sub 0x27)
WritingFullScale ──(Err)────> Idle                (error=true, busy=false)
WritingScaleFactor ──(OK)───> Idle                (busy=false, all three configured_* set)
WritingScaleFactor ──(Err)──> Idle                (error=true, busy=false)

SDO timeout is 3 seconds per write.

Read-configuration state machine

read_configuration() reads the same three SDOs. The response payload’s value field is the IEEE-754 REAL32 bit pattern as a u32, which the FB converts back to f32 via f32::from_bits before populating the configured_* field.

Idle ──(read_configuration called)─> ReadingMvV         (busy=true, reads sub 0x23)
ReadingMvV ──(OK)─────────────────> ReadingFullScale     (reads sub 0x24)
ReadingMvV ──(Err/Timeout/parse)──> Idle                 (error=true, busy=false)
ReadingFullScale ──(OK)───────────> ReadingScaleFactor   (reads sub 0x27)
ReadingFullScale ──(Err)──────────> Idle                 (error=true, busy=false)
ReadingScaleFactor ──(OK)─────────> Idle                 (busy=false, all three configured_* set)
ReadingScaleFactor ──(Err)────────> Idle                 (error=true, busy=false)

A partial failure leaves all three configured_* fields at None — after a failed read the control program should treat the device’s parameters as unknown, not trust the fields that did come back before the failure.

SDO timeout is 3 seconds per read.

Verifying non-volatile parameters at startup

The EL3356 stores sensitivity, full-scale, and scale factor in non-volatile memory, so a just-powered-up card carries whatever was last written — not necessarily what the current control program expects. The canonical startup pattern is: read, compare against expected, re-configure only if they don’t match.

use autocore_std::{ControlProgram, TickContext};
use autocore_std::fb::beckhoff::El3356;
use autocore_std::el3356_view;
use crate::gm::GlobalMemory;

const EXPECTED_FULL_SCALE: f32 = 1_000.0;
const EXPECTED_MV_V:       f32 = 2.0;
const EXPECTED_SCALE:      f32 = 100_000.0;

#[derive(PartialEq)]
enum StartupPhase { ReadPending, Check, Writing, Ready, Failed }

pub struct ImpactStation {
    load_cell: El3356,
    phase: StartupPhase,
}

impl ImpactStation {
    pub fn new() -> Self {
        Self {
            load_cell: El3356::new("EL3356_0"),
            phase: StartupPhase::ReadPending,
        }
    }
}

impl ControlProgram for ImpactStation {
    type Memory = GlobalMemory;

    fn process_tick(&mut self, ctx: &mut TickContext<Self::Memory>) {
        // Always tick the FB (peak tracking, tare pulse, SDO progress).
        let mut view = el3356_view!(ctx.gm, impact);
        self.load_cell.tick(&mut view, ctx.client);

        match self.phase {
            StartupPhase::ReadPending => {
                if !self.load_cell.busy {
                    self.load_cell.read_configuration(ctx.client);
                    self.phase = StartupPhase::Check;
                }
            }
            StartupPhase::Check => {
                if self.load_cell.error {
                    log::error!("Load cell config read failed: {}", self.load_cell.error_message);
                    self.phase = StartupPhase::Failed;
                } else if !self.load_cell.busy {
                    let ok = self.load_cell.configured_mv_v == Some(EXPECTED_MV_V)
                        && self.load_cell.configured_full_scale_load == Some(EXPECTED_FULL_SCALE)
                        && self.load_cell.configured_scale_factor == Some(EXPECTED_SCALE);
                    if ok {
                        log::info!("Load cell already calibrated correctly — no write needed");
                        self.phase = StartupPhase::Ready;
                    } else {
                        log::warn!(
                            "Load cell parameters differ from expected (got mV/V={:?}, full_scale={:?}, scale={:?}) — rewriting",
                            self.load_cell.configured_mv_v,
                            self.load_cell.configured_full_scale_load,
                            self.load_cell.configured_scale_factor,
                        );
                        self.load_cell.configure(
                            ctx.client, EXPECTED_FULL_SCALE, EXPECTED_MV_V, EXPECTED_SCALE,
                        );
                        self.phase = StartupPhase::Writing;
                    }
                }
            }
            StartupPhase::Writing => {
                if self.load_cell.error {
                    log::error!("Load cell configure failed: {}", self.load_cell.error_message);
                    self.phase = StartupPhase::Failed;
                } else if !self.load_cell.busy {
                    self.phase = StartupPhase::Ready;
                }
            }
            StartupPhase::Ready | StartupPhase::Failed => {
                // Normal operation below — see the main usage example.
            }
        }

        ctx.gm.impact_peak_load     = self.load_cell.peak_load;
        ctx.gm.impact_calibrated    = self.phase == StartupPhase::Ready;
        ctx.gm.impact_startup_error = self.phase == StartupPhase::Failed;
    }
}

This verify-then-write pattern avoids unnecessary EEPROM wear on the EL3356 — writes only happen when the stored values actually differ from the expected calibration.

The el3356_view! macro

let mut view = el3356_view!(ctx.gm, impact);

Expands to an El3356View with references to ctx.gm.impact_tare, ctx.gm.impact_load, ctx.gm.impact_load_steady, ctx.gm.impact_load_error, and ctx.gm.impact_load_overrange. Use a different prefix per terminal when you have multiple — each call to the macro produces a fresh view bound to that prefix’s fields.

El3356View fields

FieldTypeDirectionDescription
tare&mut booloutputTare command bit. Written by tick().
load&f32inputScaled load value from the terminal.
load_steady&boolinputSteady-state indicator. true when the signal has been stable within the configured band.
load_error&boolinputGeneral error flag.
load_overrange&boolinputSignal exceeds configured range.

El3356Filters enum

Passed to [set_mode0_filter] and [set_mode1_filter]. Selects which software filter runs on the ADC output before the process value is published. The enum is #[repr(u16)] so the discriminant matches the CoE register layout exactly.

VariantRegister valueCutoff~Step-response latencyTypical use
FIR50Hz050 Hz notch~13 msSuppress 50 Hz mains hum
FIR60Hz160 Hz notch~16 msSuppress 60 Hz mains hum
IIR12~2000 Hz~0.3 msVery fast tracking, minimal smoothing
IIR23~500 Hz~0.8 msLight smoothing
IIR34~125 Hz~3.5 msFast machinery tracking
IIR45~30 Hz~14 msModerate mechanical vibration rejection
IIR56~8 Hz~56 msSlower processes
IIR67~2 Hz~225 msHeavy smoothing, mostly static loads
IIR78~0.5 Hz~900 msVery heavy smoothing
IIR89~0.1 Hz~3600 msMaximum damping, fully static measurement
DynamicIIR10variable~0.3 ms – ~3600 msAuto-switches between IIR1 and IIR8 based on signal change rate — good for dosing/filling (fast track + static precision)
PDOFilterFrequency11variabledependsFIR notch with PDO-driven frequency (0.1 Hz to 200 Hz); use for vibration suppression at a known, variable frequency
Filter chain — why both averager and software filter

The EL3356’s signal path is:

Raw ADC → Hardware 4-sample averager → Software filter (FIR/IIR) → PDO

They attack different noise types. The hardware averager is the optimal tool for random Gaussian “white” noise (electrical interference in the sensor wires) and adds almost no latency — ~0.14 ms in Mode 0, ~0.014 ms in Mode 1. Leave it on in almost every case.

The software filters target specific lower-frequency phenomena: FIR notches kill mains hum (50/60 Hz), IIR low-pass filters damp mechanical vibration (hopper swing, force-plate ring-out, etc.).

Running both is the right default. The averager clips random spikes before they reach the IIR filter — important because IIR filters “ring” on sharp spikes, causing an exponential tail that skews readings. With the averager feeding the IIR a clean baseline, you can often drop to a weaker, faster IIR level (IIR3 instead of IIR5) and save tens to hundreds of milliseconds of total latency while still rejecting the mechanical noise you care about.

Mode 0 vs Mode 1

The EL3356 has two ADC modes selected by the Sample Mode bit of the Control Word:

ModeADC rateHardware latencyTypical filter pairingTypical use
0 — High Precision (default)10.5 kSps~7.2 msStrong IIR (IIR5–IIR8)Static weighing, high-accuracy calm readings
1 — High Speed105.5 kSps~0.72 msWeak or off (IIR1, or filter disabled)Impact capture, high-speed dosing, fast transients

set_mode0_filter and set_mode1_filter configure each mode independently, so you can have both pre-loaded and switch between them in-flight via the Control Word without a reconfigure round-trip.

Complete example — calibrate at startup, then run

The typical workflow: configure the load cell once when the system comes up, then run the main process loop. Use a simple state flag to sequence startup before normal operation.

use autocore_std::{ControlProgram, TickContext};
use autocore_std::fb::beckhoff::El3356;
use autocore_std::fb::RTrig;
use autocore_std::el3356_view;
use crate::gm::GlobalMemory;

pub struct ImpactStation {
    load_cell: El3356,
    configured: bool,
    tare_edge: RTrig,
}

impl ImpactStation {
    pub fn new() -> Self {
        Self {
            load_cell: El3356::new("EL3356_0"),
            configured: false,
            tare_edge: RTrig::new(),
        }
    }
}

impl ControlProgram for ImpactStation {
    type Memory = GlobalMemory;

    fn process_tick(&mut self, ctx: &mut TickContext<Self::Memory>) {
        // 1. One-shot calibration on first tick
        if !self.configured && !self.load_cell.busy {
            self.load_cell.configure(
                ctx.client,
                /* full_scale_load */ 1_000.0,   // N (sensor rating)
                /* sensitivity     */     2.0,   // mV/V (sensor datasheet)
                /* scale_factor    */ 100_000.0, // EL3356 default
            );
            self.configured = true;
        }

        // 2. Normal operation: manual tare from HMI, tick the FB
        if self.tare_edge.call(ctx.gm.manual_tare) {
            self.load_cell.tare();
        }

        let mut view = el3356_view!(ctx.gm, impact);
        self.load_cell.tick(&mut view, ctx.client);

        // 3. Publish state
        ctx.gm.impact_peak_load       = self.load_cell.peak_load;
        ctx.gm.impact_calibrated      = self.load_cell.configured_mv_v.is_some();
        ctx.gm.impact_calibration_err = self.load_cell.error;

        if self.load_cell.error {
            log::warn!("Load cell: {}", self.load_cell.error_message);
            // Optional: retry on next tick by flipping `configured` back to false
            // after clear_error(), or require operator acknowledgement.
        }
    }
}

Auto-tare after calibration

If you want the terminal to re-zero automatically as soon as calibration completes, watch the configured_scale_factor field for a transition from None to Some(_):

let was_configured = self.load_cell.configured_scale_factor.is_some();
// ... call self.load_cell.tick() ...
let now_configured = self.load_cell.configured_scale_factor.is_some();
if !was_configured && now_configured {
    self.load_cell.tare();  // fire once on the completion edge
}

Multiple load cells

Each terminal gets its own FB instance, view prefix, and SDO client. Their calls are independent — SDO sequences can run in parallel:

pub struct DualStation {
    fx: El3356,  // station 1
    fy: El3356,  // station 2
}

impl DualStation {
    pub fn new() -> Self {
        Self {
            fx: El3356::new("EL3356_0"),
            fy: El3356::new("EL3356_1"),
        }
    }
}

impl ControlProgram for DualStation {
    type Memory = GlobalMemory;

    fn process_tick(&mut self, ctx: &mut TickContext<Self::Memory>) {
        let mut fx_view = el3356_view!(ctx.gm, fx);
        let mut fy_view = el3356_view!(ctx.gm, fy);
        self.fx.tick(&mut fx_view, ctx.client);
        self.fy.tick(&mut fy_view, ctx.client);
    }
}

Porting notes for TwinCAT users

If you’re migrating from a TwinCAT-style EL3356 FB, here’s the direct mapping:

TwinCAT conceptRust equivalent
AT %I* inputs (fLoad, bLoadSteady, bLoadError, bLoadOverrange)&T fields on El3356View
AT %Q* output (bTare)&mut bool on El3356View
nCommandCode / nStatusCode handshakeMethod calls (.tare(), .configure()) + pub busy: bool field
rtTare / ftTare edge triggers on a manual-tare buttonRTrig from autocore_std::fb, invoked on the HMI field; call .tare() on the rising edge
writeMvV(... bExecute := bWriteSdo)First step of configure() — writes 0x8000:0x23 internally
writeFullLoad(... )Second step of configure()0x8000:0x24
writeScale(..., SCALE_FACTOR, ...)Third step of configure()0x8000:0x27. Pass the scale factor as the third arg.
CASE state.index OF ... T#100MS TON for tare pulse timingInternal to the FB — tick() clears the tare bit automatically 100 ms after tare() is called.
stEL3356.fPeakLoad := fLoad peak updateAutomatic at the top of every tick().

There is no equivalent to the PLC’s E_LoadCellCommand enum; you call the Rust methods directly. There’s also no status-ack round-trip — busy simply reflects whether an operation is in progress, and control programs poll it from their own state machines.


Motion

Import motion function blocks from autocore_std::motion.

SeekProbe — Jog to Sensor

Jogs an axis in the negative direction until a sensor triggers, then halts. The axis must be enabled and at a position > 0 before executing.

Jog velocity, acceleration, and deceleration are taken from the axis configuration (jog_speed, jog_accel, jog_decel).

use autocore_std::motion::{AxisConfig, SeekProbe};
use crate::gm::AxisLift;

struct MyProgram {
    lift_axis: AxisLift,
    seek_ball: SeekProbe,
}

impl MyProgram {
    fn new() -> Self {
        let config = AxisConfig::new(12_800).with_user_scale(100.0);
        Self {
            lift_axis: AxisLift::new(config),
            seek_ball: SeekProbe::new(),
        }
    }
}

In process_tick:

// Read feedback
self.lift_axis.sync(&ctx.gm);

// Outer State Machine logic
match self.state {
    State::StartSeek => {
        self.seek_ball.start();
        self.state = State::WaitSeek;
    }
    State::WaitSeek => {
        // Run the seek probe state machine
        self.seek_ball.tick(&mut self.lift_axis, ctx.gm.ball_sensor);

        if self.seek_ball.done {
            log::info!("Probe found at position {:.3}", self.lift_axis.position());
            self.state = State::Done;
        } else if self.seek_ball.is_error() {
            log::error!("Seek failed: code={}", self.seek_ball.error_code());
            self.state = State::Error;
        }
    }
}

// Write outputs
self.lift_axis.tick(&mut ctx.gm, &mut ctx.client);

Methods:

MethodSignatureDescription
new() -> SelfCreate in idle state
start(&mut self)Start the seek operation on the next tick
tick(&mut self, handle: &mut impl AxisHandle, sensor: bool)Execute one scan cycle.
reset(&mut self, handle: &mut impl AxisHandle)Halt axis immediately and return FB to idle state
is_busy(&self) -> booltrue while seek operation is in progress
is_error(&self) -> booltrue if an error occurred during the seek
error_code(&self) -> i32Returns the error code from the state machine

Output fields:

FieldTypeDescription
donebooltrue for one cycle when the probe is found and axis has stopped
errorbooltrue when an error occurs
stateStateMachineInternal state machine with index, error_code, error_message

Error codes:

CodeMeaning
1Abort/Reset called while motion was active
100Axis position is not > 0 at start
120Axis error or control disabled during motion
200Axis reported error when stopping

State diagram:

┌──────────┐  execute ↑  ┌────────────┐ position>0  ┌────────────────┐
│ 10: Idle │──────────►│ 100: Start │────────────►│ 120: Jogging   │
└──────────┘           └────────────┘             │  (negative)    │
      ▲                      │ pos<=0             └───────┬────────┘
      │                      ▼                   sensor │  │ axis error
      │                 error_code=100                   ▼  ▼
      │                                          ┌────────────────┐
      │◄──── done=true ◄─────────────────────────│ 200: Stopping  │
      │◄──── error=true ◄────────────────────────│                │
      │                                          └────────────────┘
      │◄──── error=true ◄── 250: Motion Error

Complete example — ball detect on a linear slide:

use autocore_std::{ControlProgram, TickContext};
use autocore_std::motion::{AxisConfig, SeekProbe};
use crate::gm::{GlobalMemory, Slide};

pub struct BallDetect {
    drive: Slide,
    seek: SeekProbe,
}

impl BallDetect {
    pub fn new() -> Self {
        let config = AxisConfig::new(12_800)
            .with_user_scale(100.0);  // mm per rev
        Self {
            drive: Slide::new(config),
            seek: SeekProbe::new(),
        }
    }
}

impl ControlProgram for BallDetect {
    type Memory = GlobalMemory;

    fn process_tick(&mut self, ctx: &mut TickContext<Self::Memory>) {
        self.drive.sync(&ctx.gm);

        self.seek.call(
            &mut self.drive.axis,
            &mut self.drive.snapshot,
            ctx.gm.start_button,
            ctx.gm.proximity_sensor,
        );

        ctx.gm.seek_busy = self.seek.is_busy();

        if self.seek.done {
            ctx.gm.probe_position = self.drive.position();
            log::info!("Ball detected at {:.3} mm", self.drive.position());
        }
        if self.seek.error {
            ctx.gm.error_code = self.seek.state.error_code;
            log::error!("Seek error {}: {}",
                self.seek.state.error_code,
                self.seek.state.error_message);
        }

        // Abort on E-stop
        if ctx.gm.estop {
            self.seek.abort(&mut self.drive.axis, &mut self.drive.snapshot);
        }

        self.drive.tick(&mut ctx.gm, &mut ctx.client);
    }
}

PressureControl — Closed-loop force control

A closed-loop PID pressure/force controller for Profile Position (PP) axes.

This function block uses an Exponential Moving Average (EMA) filter to smooth incoming load cell data. It calculates a PID output which is clamped to a safe maximum step size and issued as a small, incremental absolute target to the drive every cycle. It is designed to safely apply a consistent load to a material at high tick rates (1-3ms).

use autocore_std::motion::{PressureControl, PressureControlConfig};

Configuration:

The controller requires a PressureControlConfig struct to dictate tuning and safety bounds:

FieldTypeDefaultDescription
kpf640.0Proportional gain.
kif640.0Integral gain.
kdf640.0Derivative gain.
feed_forwardf640.0Feed forward value added directly to the output.
max_stepf640.005Maximum allowed position delta (in user units) per call/tick. Critical safety limit to prevent crushing.
max_integralf64100.0Maximum accumulated integral windup.
filter_alphaf640.5EMA filter coefficient (0.0 to 1.0). 1.0 = No filtering (raw data), 0.1 = Heavy filtering.
invert_directionboolfalseSet to true if moving the axis negative increases compression (e.g., a downward Z-axis).
tolerancef641.0Acceptable load error window to be considered “in tolerance” (e.g., +/- 2.0 lbs).
settling_timef640.1How long the load must remain within tolerance before reporting in_tolerance = true.

Execution:

You must call the function block every tick while active. On the rising edge of execute, it will engage the PID loop. On the falling edge, it will automatically halt the axis and reset its internal state.

pub fn call(
    &mut self,
    axis: &mut impl AxisHandle,
    execute: bool,
    target_load: f64,
    current_load: f64,
    config: &PressureControlConfig,
    dt: f64,
)

Output fields:

FieldTypeDescription
activebooltrue when the block is actively executing and controlling the axis.
in_tolerancebooltrue when the current load has been within config.tolerance for at least config.settling_time seconds.
errorbooltrue if a fault occurred (e.g., axis error). Check state.error_code.
stateStateMachineInternal state machine for operation sequencing and error reporting.

Example:

use autocore_std::{ControlProgram, TickContext};
use autocore_std::motion::{AxisConfig, PressureControl, PressureControlConfig};
use crate::gm::{GlobalMemory, PressAxis};

pub struct MyPressProgram {
    drive: PressAxis,
    pressure_fb: PressureControl,
    config: PressureControlConfig,
}

impl MyPressProgram {
    pub fn new() -> Self {
        Self {
            drive: PressAxis::new(AxisConfig::new(10_000)),
            pressure_fb: PressureControl::new(),
            config: PressureControlConfig {
                kp: 0.05,
                filter_alpha: 0.1, // Smooth noisy load cell
                invert_direction: true, // Z-axis presses down (negative)
                max_step: 0.002, // Never move more than 0.002 units per ms
                tolerance: 2.5,
                settling_time: 0.5,
                ..Default::default()
            },
        }
    }
}

impl ControlProgram for MyPressProgram {
    type Memory = GlobalMemory;

    fn process_tick(&mut self, ctx: &mut TickContext<Self::Memory>) {
        self.drive.sync(&ctx.gm);

        // dt is the cycle time in seconds (e.g., 0.001 for 1ms tick)
        let dt = ctx.cycle_time_us as f64 / 1_000_000.0;

        self.pressure_fb.call(
            &mut self.drive.axis,
            ctx.gm.engage_press, // execute
            ctx.gm.target_pressure,
            ctx.gm.load_cell_value,
            &self.config,
            dt,
        );

        ctx.gm.press_active = self.pressure_fb.active;
        ctx.gm.press_in_tolerance = self.pressure_fb.in_tolerance;

        if self.pressure_fb.error {
            log::error!("Press fault: {}", self.pressure_fb.state.error_message);
            ctx.gm.engage_press = false; // Reset command
        }

        self.drive.tick(&mut ctx.gm, &mut ctx.client);
    }
}

MoveToLoad — Move until load is reached

Moves an axis towards a target load (e.g., from a load cell) and stops as quickly as possible once the edge of that load is reached. It does not average the input, making it highly responsive for edge detection.

  • If current_load > target_load, it moves in the negative direction.
  • If current_load < target_load, it moves in the positive direction.

It accepts a position_limit safety envelope and a hysteresis value (minimum 1.0) to prevent premature stopping from noise spikes.

use autocore_std::motion::MoveToLoad;

Execution:

You must call the function block every tick while active. On the rising edge of execute, it will determine the direction and issue a move. On the falling edge, it will automatically halt the axis and reset its internal state.

pub fn call(
    &mut self,
    axis: &mut impl AxisHandle,
    execute: bool,
    target_load: f64,
    current_load: f64,
    position_limit: f64,
    hysteresis: f64,
)

Output fields:

FieldTypeDescription
donebooltrue when the target load edge has been reached and the axis has halted.
activebooltrue when the block is actively executing motion.
errorbooltrue if a fault occurred (e.g., reached position limit, axis error). Check state.error_code.
stateStateMachineInternal state machine for operation sequencing and error reporting.

Error codes:

CodeMeaning
1Abort called
110Axis already past position limit before starting
120Axis is in an error state
150Reached position limit without hitting target load

Example:

use autocore_std::{ControlProgram, TickContext};
use autocore_std::motion::{AxisConfig, MoveToLoad};
use crate::gm::{GlobalMemory, PressAxis};

pub struct MyPressProgram {
    drive: PressAxis,
    move_to_load_fb: MoveToLoad,
}

impl MyPressProgram {
    pub fn new() -> Self {
        Self {
            drive: PressAxis::new(AxisConfig::new(10_000)),
            move_to_load_fb: MoveToLoad::new(),
        }
    }
}

impl ControlProgram for MyPressProgram {
    type Memory = GlobalMemory;

    fn process_tick(&mut self, ctx: &mut TickContext<Self::Memory>) {
        self.drive.sync(&ctx.gm);

        self.move_to_load_fb.call(
            &mut self.drive.axis,
            ctx.gm.start_move, // execute
            -50.0,             // target_load (50 lbs compression)
            ctx.gm.load_cell,  // current_load
            -10.0,             // position_limit (never move past -10.0)
            2.0,               // hysteresis
        );

        if self.move_to_load_fb.done {
            log::info!("Load edge reached!");
            ctx.gm.start_move = false;
        }
        
        if self.move_to_load_fb.error {
            log::error!("Move to load failed: {}", self.move_to_load_fb.state.error_message);
            ctx.gm.start_move = false;
        }

        self.drive.tick(&mut ctx.gm, &mut ctx.client);
    }
}

Import Banner device helpers from autocore_std::banner::wls15.

Controls a Banner WLS15P multi-color light strip via IO-Link. Each output field corresponds to a PDO byte that should be linked to the device’s IO-Link process data. Use the preset methods for common animations, or set the fields directly for full control.

use autocore_std::banner::wls15::{Wls15RunMode, Color, ColorIntensity, Speed};

let mut light = Wls15RunMode::new();

// Solid green
light.steady(Color::Green, ColorIntensity::High);

// Red alert — scrolls out from center
light.alert(Color::Red, ColorIntensity::High, Speed::Medium);

// Knight Rider scanner effect
light.knight_rider(Color::Red);

// Breathing pulse
light.pulse(Color::Blue, ColorIntensity::High, Speed::Slow);

// Rainbow spectrum
light.spectrum(Speed::Fast);

// Turn off
light.off();

Preset methods:

MethodSignatureDescription
new() -> SelfCreate with all outputs at zero (off)
off(&mut self)Turn the light off
steady(&mut self, color, intensity)Solid single color
flash(&mut self, color, intensity, speed)Single color flashing
alert(&mut self, color, intensity, speed)Center-scroll alert pattern
knight_rider(&mut self, color)Bouncing scanner with tail
pulse(&mut self, color, intensity, speed)Smooth breathing effect
spectrum(&mut self, speed)Rainbow sweep across the strip

PDO output fields (all u8):

FieldTypeDescription
animationu8Animation mode (see Animation enum)
color1u8Primary color (see Color enum)
color1_intensityu8Primary color intensity (see ColorIntensity enum)
color2u8Secondary color
color2_intensityu8Secondary color intensity
speedu8Animation speed (see Speed enum)
pulse_patternu8Pulse pattern (see PulsePattern enum)
scroll_bounce_styleu8Scroll/bounce style (see ScrollStyle enum)
percent_width_color1u8Color 1 width percentage (0-100)
directionu8Direction: 0=Up, 1=Down

Enums:

All enums are #[repr(u8)] and map directly to hardware PDO values.

EnumValues
AnimationOff(0), Steady(1), Flash(2), TwoColorFlash(3), TwoColorShift(4), EndsSteady(5), EndsFlash(6), Scroll(7), CenterScroll(8), Bounce(9), CenterBounce(10), IntensitySweep(11), TwoColorSweep(12), Spectrum(13), SingleEndSteady(14), SingleEndFlash(15)
ColorGreen(0), Red(1), Orange(2), Amber(3), Yellow(4), LimeGreen(5), SpringGreen(6), Cyan(7), SkyBlue(8), Blue(9), Violet(10), Magenta(11), Rose(12), DaylightWhite(13), Custom1(14), Custom2(15), IncandescentWhite(16), WarmWhite(17), FluorescentWhite(18), NeutralWhite(19), CoolWhite(20)
ColorIntensityHigh(0), Low(1), Medium(2), Off(3), Custom(4)
SpeedMedium(0), Fast(1), Slow(2), CustomFlashRate(3)
PulsePatternNormal(0), Strobe(1), ThreePulse(2), Sos(3), Random(4)
ScrollStyleSolid(0), Tail(1), Ripple(2)
DirectionUp(0), Down(1)

Complete example — machine status indicator:

use autocore_std::{ControlProgram, TickContext};
use autocore_std::banner::wls15::{Wls15RunMode, Color, ColorIntensity, Speed};
use crate::gm::GlobalMemory;

pub struct StatusLight {
    light: Wls15RunMode,
}

impl StatusLight {
    pub fn new() -> Self {
        Self { light: Wls15RunMode::new() }
    }
}

impl ControlProgram for StatusLight {
    type Memory = GlobalMemory;

    fn process_tick(&mut self, ctx: &mut TickContext<Self::Memory>) {
        if ctx.gm.fault_active {
            self.light.alert(Color::Red, ColorIntensity::High, Speed::Fast);
        } else if ctx.gm.cycle_running {
            self.light.steady(Color::Green, ColorIntensity::High);
        } else if ctx.gm.waiting_for_part {
            self.light.pulse(Color::Yellow, ColorIntensity::High, Speed::Slow);
        } else {
            self.light.off();
        }

        // Write PDO outputs to global memory
        ctx.gm.wls15_animation = self.light.animation;
        ctx.gm.wls15_color1 = self.light.color1;
        ctx.gm.wls15_color1_intensity = self.light.color1_intensity;
        ctx.gm.wls15_color2 = self.light.color2;
        ctx.gm.wls15_color2_intensity = self.light.color2_intensity;
        ctx.gm.wls15_speed = self.light.speed;
        ctx.gm.wls15_pulse_pattern = self.light.pulse_pattern;
        ctx.gm.wls15_scroll_bounce_style = self.light.scroll_bounce_style;
        ctx.gm.wls15_percent_width = self.light.percent_width_color1;
        ctx.gm.wls15_direction = self.light.direction;
    }
}

Wls15Digital — WLS15P Two-Wire Digital Control

Controls a Banner WLS15P using two digital outputs (Q1, Q2) for simple color selection with optional blinking. No IO-Link required — connect two wires directly to the light strip inputs.

use autocore_std::banner::wls15::Wls15Digital;
use std::time::Duration;

let mut light = Wls15Digital::new();

// Set colors
light.green();    // Q1=false, Q2=true
light.red();      // Q1=true,  Q2=false
light.blue();     // Q1=true,  Q2=true
light.off();      // Q1=false, Q2=false

// Enable blinking at 500ms interval
light.blink_on(Duration::from_millis(500));
light.call(); // must call every scan cycle

// Disable blinking
light.blink_off();

Color mapping:

Q1Q2Color
falsefalseOff
truefalseRed
falsetrueGreen
truetrueBlue

Methods:

MethodSignatureDescription
new() -> SelfCreate with outputs off, no blink
off(&mut self)Set color to off
red(&mut self)Set color to red
green(&mut self)Set color to green
blue(&mut self)Set color to blue
blink_on(&mut self, interval: Duration)Enable blinking at the specified interval
blink_off(&mut self)Disable blinking — outputs follow color directly
call(&mut self)Update outputs — call every scan cycle

Output fields:

FieldTypeDescription
q1boolDigital output 1 — connect to light strip channel 1
q2boolDigital output 2 — connect to light strip channel 2

Complete example — error blinker:

use autocore_std::{ControlProgram, TickContext};
use autocore_std::banner::wls15::Wls15Digital;
use std::time::Duration;
use crate::gm::GlobalMemory;

pub struct ErrorBlinker {
    light: Wls15Digital,
}

impl ErrorBlinker {
    pub fn new() -> Self {
        Self { light: Wls15Digital::new() }
    }
}

impl ControlProgram for ErrorBlinker {
    type Memory = GlobalMemory;

    fn process_tick(&mut self, ctx: &mut TickContext<Self::Memory>) {
        if ctx.gm.fault_active {
            self.light.red();
            self.light.blink_on(Duration::from_millis(250));
        } else if ctx.gm.cycle_running {
            self.light.green();
            self.light.blink_off();
        } else {
            self.light.off();
            self.light.blink_off();
        }

        self.light.call();

        // Write outputs to global memory (mapped to digital outputs)
        ctx.gm.light_q1 = self.light.q1;
        ctx.gm.light_q2 = self.light.q2;
    }
}

EtherCAT

Import EtherCAT helpers from autocore_std::ethercat.

SdoClient — Non-Blocking SDO Access

Provides an ergonomic, handle-based interface for runtime SDO (Service Data Object) operations over CoE (CANopen over EtherCAT). Create one per device, issue reads/writes from your control loop, and check results by handle on subsequent ticks.

Use SdoClient for runtime SDO access — reading diagnostic registers, changing operating parameters on the fly, or any CoE transfer that happens after the cyclic loop is running. For SDOs that must be applied before the cyclic loop starts (e.g. setting modes_of_operation), use the startup_sdo array in project.json instead.

use autocore_std::ethercat::{SdoClient, SdoResult};
use serde_json::json;
use std::time::Duration;

let mut sdo = SdoClient::new("ClearPath_0");

// Issue an SDO write (from process_tick):
let tid = sdo.write(ctx.client, 0x6060, 0, json!(1));

// Check result on subsequent ticks:
match sdo.result(ctx.client, tid, Duration::from_secs(3)) {
    SdoResult::Pending => { /* keep waiting */ }
    SdoResult::Ok(_) => { log::info!("SDO write confirmed"); }
    SdoResult::Err(e) => { log::error!("SDO error: {}", e); }
    SdoResult::Timeout => { log::error!("SDO timed out"); }
}

Methods:

MethodSignatureDescription
new(device: &str) -> SelfCreate a client scoped to a device (e.g. "ClearPath_0")
write(&mut self, client, index: u16, sub_index: u8, value: Value) -> u32Issue SDO write; returns transaction handle
read(&mut self, client, index: u16, sub_index: u8) -> u32Issue SDO read; returns transaction handle
result(&mut self, client, tid: u32, timeout: Duration) -> SdoResultCheck result of in-flight request
drain_stale(&mut self, client, timeout: Duration)Remove requests pending longer than timeout
pending_count(&self) -> usizeNumber of in-flight SDO requests

SdoResult variants:

VariantDescription
PendingNo response yet — check again next tick
Ok(Value)Success; contains the read value or null for writes
Err(String)Server/EtherCAT error with message (e.g. "SDO abort: 0x06090011")
TimeoutNo response within caller-specified deadline

IPC topics used internally:

OperationTopicPayload
Writeethercat.{device}.sdo_write{"index": "0x6060", "sub": 0, "value": 1}
Readethercat.{device}.sdo_read{"index": "0x6064", "sub": 0}

Complete example — runtime parameter change with state machine:

use autocore_std::{ControlProgram, TickContext};
use autocore_std::ethercat::{SdoClient, SdoResult};
use autocore_std::fb::StateMachine;
use serde_json::json;
use std::time::Duration;
use crate::gm::GlobalMemory;

pub struct ConfigWriter {
    sm: StateMachine,
    sdo: SdoClient,
    write_tid: Option<u32>,
}

impl ConfigWriter {
    pub fn new() -> Self {
        Self {
            sm: StateMachine::new(),
            sdo: SdoClient::new("ClearPath_0"),
            write_tid: None,
        }
    }
}

impl ControlProgram for ConfigWriter {
    type Memory = GlobalMemory;

    fn process_tick(&mut self, ctx: &mut TickContext<Self::Memory>) {
        self.sm.call();
        match self.sm.index {
            10 => {
                // Send SDO write: set modes_of_operation to Profile Position (1)
                self.write_tid = Some(
                    self.sdo.write(ctx.client, 0x6060, 0, json!(1))
                );
                self.sm.index = 20;
            }
            20 => {
                // Wait for response
                let tid = self.write_tid.unwrap();
                match self.sdo.result(ctx.client, tid, Duration::from_secs(3)) {
                    SdoResult::Pending => {}
                    SdoResult::Ok(_) => {
                        log::info!("modes_of_operation set to PP");
                        self.sm.index = 30;
                    }
                    SdoResult::Err(e) => {
                        log::error!("SDO write failed: {}", e);
                        self.sm.set_error(1, "SDO write failed");
                    }
                    SdoResult::Timeout => {
                        log::error!("SDO write timed out");
                        self.sm.set_error(2, "SDO timeout");
                    }
                }
            }
            30 => {
                // Done — continue with normal operation
            }
            _ => {}
        }
    }
}

DriveHandle — CiA 402 Servo Drive Interface

A generated per-drive struct that bundles the Axis state machine with a Cia402PpSnapshot (an owned copy of the CiA 402 PDO fields). Works with any CiA 402 servo drive (Teknic, Yaskawa, Beckhoff, etc.).

When you add an axis entry with "type": "pp" to the axes array in the ethercat config, the code generator creates a DriveHandle struct named after the axis (e.g., ClearPath0, Servo1). Use sync() to read feedback, issue commands, then tick() to advance the state machine and write outputs:

use crate::gm::{GlobalMemory, ClearPath0};

// In your program struct:
drive: ClearPath0,

// In process_tick:
self.drive.sync(&ctx.gm);

// Issue commands
self.drive.enable();
self.drive.move_absolute(100.0, 50.0, 100.0, 100.0);

// Check status
if !self.drive.is_busy() {
    log::info!("Move complete, position: {:.1}", self.drive.position());
}

// Advance state machine and write outputs
self.drive.tick(&mut ctx.gm, &mut ctx.client);

Command methods:

MethodDescription
enable()Start the enable sequence (Axis handles CiA 402 transitions)
disable()Disable the drive
move_absolute(target, vel, accel, decel)Absolute move in user units
move_relative(distance, vel, accel, decel)Relative move in user units
halt()Decelerate to stop
home(method)Start homing with the given HomingMethod
reset_faults()Clear drive faults
set_position(user_units)Set current position as the given value
set_software_max_limit(user_units)Set positive software limit
set_software_min_limit(user_units)Set negative software limit
sdo_write(client, index, sub_index, value)Write an SDO to the drive
sdo_read(client, index, sub_index) -> u32Start an SDO read (returns transaction ID)
sdo_result(client, tid) -> SdoResultCheck result of a previous SDO read

Status methods:

MethodReturnsDescription
position()f64Current position in user units
raw_position()i64Current position in encoder counts
speed()f64Current speed in user units/s (absolute)
is_busy()boolAny operation in progress
is_error()boolDrive fault or operation error
error_code()u32Drive error code
error_message()&strHuman-readable error description
motor_on()boolDrive in Operation Enabled state
in_motion()boolMove specifically in progress
moving_positive()boolVelocity is positive
moving_negative()boolVelocity is negative
at_max_limit()boolAt positive software limit
at_min_limit()boolAt negative software limit
at_positive_limit_switch()boolPositive hardware limit active
at_negative_limit_switch()boolNegative hardware limit active
home_sensor()boolHome sensor active

Public fields:

FieldTypeDescription
axisAxisThe underlying axis state machine (for advanced use with SeekProbe, etc.)
snapshotCia402PpSnapshotThe PDO snapshot (for advanced low-level access)

Multiple axes: Because the DriveHandle owns its data by value (no references into GlobalMemory), you can use multiple axes without borrow conflicts:

self.lift.sync(&ctx.gm);
self.centering.sync(&ctx.gm);

if !self.lift.is_busy() {
    self.lift.move_absolute(100.0, 50.0, 100.0, 100.0);
}

self.lift.tick(&mut ctx.gm, &mut ctx.client);
self.centering.tick(&mut ctx.gm, &mut ctx.client);

Homing with Axis:

The DriveHandle’s home() method delegates to the Axis struct, which handles the full homing sequence: SDO writes for method/speed/acceleration, mode switching, triggering, and home offset capture. This is the recommended approach.

There are two categories of homing method (see HomingMethod enum):

  • Integrated methods (IntegratedLimitSwitchNeg, HardStopPos, CurrentPosition, etc.) delegate to the drive’s built-in CiA 402 homing mode. The Axis writes SDO 0x6098 (method), 0x6099 (speeds), 0x609A (acceleration), then triggers the drive’s internal homing.
  • Software methods (LimitSwitchNegPnp, HomeSensorPosPnp, etc.) are implemented by the Axis itself. It puts the drive in Profile Position mode and monitors sensor signals. When the sensor triggers, the axis halts and captures the home position. Specify your sensor variables in options in the axis config:
"axes": [{
    "name": "Servo1",
    "link": "MyDrive_1",
    "type": "pp",
    "options": {
        "positive_limit": "ls_servo1_pos",
        "negative_limit": "ls_servo1_neg"
    }
}]

The generated sync() method automatically copies the named GlobalMemory variables into the snapshot each tick. Available options fields:

FieldTypeDefaultDescription
positive_limitstringGlobalMemory bool for positive limit switch
negative_limitstringGlobalMemory bool for negative limit switch
home_sensorstringGlobalMemory bool for home reference sensor
error_codestringGlobalMemory u16 for drive error code
invert_directionboolfalseNegate position targets and feedback (reverses motor direction in software)

Inverting direction: Some drives don’t support reversing the counting direction internally. Set "invert_direction": true to flip the sign of all position targets (absolute and relative) and all position/speed feedback. The control program sees the axis moving in the logical positive direction even though the motor counts negative. Limit switches, homing, and software limits all respect the inversion automatically — no code changes needed.

Auto-publishing axis status to GlobalMemory:

Add an outputs block to the axis config to automatically write axis status values to GlobalMemory each tick. This eliminates manual ctx.gm.my_var = self.drive.position() boilerplate:

{
    "name": "Lift",
    "link": "ClearPath_2",
    "type": "pp",
    "options": { ... },
    "outputs": {
        "position": "lift_position",
        "speed": "lift_speed",
        "is_busy": "lift_busy",
        "is_error": "lift_error",
        "error_message": "lift_error_msg",
        "motor_on": "lift_motor_on"
    }
}

Only list the fields you need. The generated tick() writes them after advancing the axis state machine. Available output fields:

FieldGM typeDescription
positionf64Position in user units
raw_positioni64Position in encoder counts
speedf64Speed in user units/s
is_busyboolAny operation in progress
is_errorboolFault or error occurred
error_codeu32/i32Drive error code
error_messagestringError description
motor_onboolDrive enabled
in_motionboolMove in progress
moving_positiveboolMoving in positive direction
moving_negativeboolMoving in negative direction
at_max_limitboolAt positive software limit
at_min_limitboolAt negative software limit
at_positive_limit_switchboolPositive hardware limit active
at_negative_limit_switchboolNegative hardware limit active
home_sensorboolHome sensor active

The referenced GM variables must exist in your project’s variables section with compatible types.

If homing_speed and homing_accel are both 0 (default), the SDO writes for speed/accel are skipped — useful when those parameters are pre-configured via startup_sdo in project.json.

HomingMethod variants:

VariantKindDescription
HardStopPosIntegratedHard stop positive direction (torque foldback)
HardStopNegIntegratedHard stop negative direction
IntegratedLimitSwitchPosIntegratedDrive’s positive limit switch (CiA 402 code 18)
IntegratedLimitSwitchNegIntegratedDrive’s negative limit switch (CiA 402 code 17)
IntegratedHomeSensorPosRtIntegratedDrive’s home sensor, positive, rising edge
IntegratedHomeSensorNegRtIntegratedDrive’s home sensor, negative, rising edge
CurrentPositionIntegratedSet current position as home (no movement)
Integrated(i8)IntegratedArbitrary CiA 402 code (vendor-specific)
LimitSwitchPosPnpSoftwareMove positive, home on positive limit (PNP: true = detected)
LimitSwitchNegPnpSoftwareMove negative, home on negative limit (PNP: true = detected)
LimitSwitchPosNpnSoftwareMove positive, home on positive limit (NPN: false = detected)
LimitSwitchNegNpnSoftwareMove negative, home on negative limit (NPN: false = detected)
HomeSensorPosPnpSoftwareMove positive, home on home sensor (PNP: true = detected)
HomeSensorNegPnpSoftwareMove negative, home on home sensor (PNP: true = detected)
HomeSensorPosNpnSoftwareMove positive, home on home sensor (NPN: false = detected)
HomeSensorNegNpnSoftwareMove negative, home on home sensor (NPN: false = detected)

Complete example — home to limit switch then move to position:

use autocore_std::{ControlProgram, TickContext};
use autocore_std::motion::{AxisConfig, HomingMethod};
use crate::gm::{GlobalMemory, Servo1};

#[derive(Debug, Clone, Copy, PartialEq)]
enum Step {
    Home,
    WaitHomed,
    Enable,
    WaitEnabled,
    MoveToWork,
    WaitMove,
    Done,
    Reset,
    WaitReset,
}

pub struct HomeThenMove {
    drive: Servo1,
    step: Step,
}

impl HomeThenMove {
    pub fn new() -> Self {
        let mut config = AxisConfig::new(12_800)
            .with_user_scale(100.0);  // 100 mm per rev
        config.homing_speed = 25.0;   // mm/s
        config.homing_accel = 100.0;  // mm/s²

        Self {
            drive: Servo1::new(config),
            step: Step::Home,
        }
    }
}

impl ControlProgram for HomeThenMove {
    type Memory = GlobalMemory;

    fn process_tick(&mut self, ctx: &mut TickContext<Self::Memory>) {
        // sync() copies TxPDO feedback AND sensor inputs (from axis options) automatically
        self.drive.sync(&ctx.gm);

        match self.step {
            Step::Home => {
                // Home to the negative limit switch (rising edge trigger).
                // The Axis will jog negative and stop when negative_limit goes true.
                self.drive.home(HomingMethod::LimitSwitchNegPnp);
                log::info!("Homing: seeking negative limit switch");
                self.step = Step::WaitHomed;
            }
            Step::WaitHomed => {
                if !self.drive.is_busy() {
                    if !self.drive.is_error() {
                        log::info!("Homed at {:.1} mm", self.drive.position());
                        self.step = Step::Enable;
                    } else {
                        log::error!("Homing failed: {}", self.drive.error_message());
                        self.step = Step::Reset;
                    }
                }
            }
            Step::Enable => {
                self.drive.enable();
                self.step = Step::WaitEnabled;
            }
            Step::WaitEnabled => {
                if !self.drive.is_busy() {
                    if self.drive.motor_on() {
                        self.step = Step::MoveToWork;
                    } else {
                        log::error!("Enable failed: {}", self.drive.error_message());
                        self.step = Step::Reset;
                    }
                }
            }
            Step::MoveToWork => {
                // Move to 50 mm at 100 mm/s, 200 mm/s² accel and decel
                self.drive.move_absolute(50.0, 100.0, 200.0, 200.0);
                log::info!("Moving to work position");
                self.step = Step::WaitMove;
            }
            Step::WaitMove => {
                if !self.drive.is_busy() {
                    if !self.drive.is_error() {
                        log::info!("At work position: {:.1} mm", self.drive.position());
                        self.step = Step::Done;
                    } else {
                        log::error!("Move failed: {}", self.drive.error_message());
                        self.step = Step::Reset;
                    }
                }
            }
            Step::Done => {
                // Ready for application logic
            }
            Step::Reset => {
                self.drive.reset_faults();
                self.step = Step::WaitReset;
            }
        self.drive.tick(&mut ctx.gm, &mut ctx.client);
    }
}

Integrations

Function blocks for integrating with external AutoCore modules and the core server services via IPC.

DAQ Capture (ni::DaqCapture)

Manages the lifecycle of a triggered NI DAQ capture: arms the trigger, waits for the capture to complete, and retrieves the captured data — all via IPC commands to the autocore-ni module.

use autocore_std::fb::ni::DaqCapture;

struct MyProgram {
    daq: DaqCapture,
}

impl MyProgram {
    fn new() -> Self {
        Self {
            daq: DaqCapture::new("ni.impact"),
        }
    }
}

In process_tick:

match self.state {
    State::StartCapture => {
        self.daq.start(ctx.client);
        self.state = State::WaitCapture;
    }
    State::WaitCapture => {
        // Poll DAQ with a 5000ms timeout
        self.daq.tick(5000, ctx.client);
        
        if !self.daq.is_busy() {
            if self.daq.is_error() {
                log::error!("DAQ failed: {}", self.daq.error_message);
            } else if let Some(data) = &self.daq.data {
                log::info!("Captured {} samples!", data.actual_samples);
            }
            self.state = State::Idle;
        }
    }
}

Methods:

MethodSignatureDescription
new(daq_fqdn: &str) -> SelfCreates the FB to command the specified module (e.g. "ni.impact")
start(&mut self, client: &mut CommandClient)Send the arm command to the DAQ on the next tick
tick(&mut self, timeout_ms: u32, client: &mut CommandClient)Execute one scan cycle. Handles async IPC polling.
reset(&mut self)Cancel the FB and return to idle state
is_busy(&self) -> booltrue while arming, waiting, or reading data
is_error(&self) -> booltrue if an error occurred during the capture

Data Object (CaptureData):

When a capture succeeds, self.data will contain a populated CaptureData struct featuring:

  • channels: Vec<Vec<f64>>: The raw samples. channels[channel_index][sample_index].
  • channel_count: usize
  • actual_samples: usize
  • sample_rate: f64

Datastore & MemoryStore

Function blocks for asynchronous storage operations across the IPC bridge. These blocks make it trivial to persist configurations, logs, or giant raw data arrays generated by DAQ or Vision systems without blocking the high-speed real-time loop.

DataStoreRead & DataStoreWrite (datastore::*)

Reads and writes JSON payloads to the persistent Datastore asynchronously.

use autocore_std::fb::datastore::{DataStoreRead, DataStoreWrite};
use serde_json::json;

// Start a read
self.ds_read.start("calibration.json", ctx.client);

// Start a write (creates directories if missing)
self.ds_write.start("captures/trace_1.json", json!({"data": 123}), json!({"create_dirs": true}), ctx.client);

In process_tick:

self.ds_read.tick(5000, ctx.client);

if self.ds_read.done {
    if let Some(val) = self.ds_read.data.take() {
        // Handle read JSON Value
    }
    self.ds_read.reset();
} else if self.ds_read.is_error() {
    log::error!("Read failed: {}", self.ds_read.error_message);
    self.ds_read.reset();
}

MemoryStoreRead & MemoryStoreWrite (memorystore::*)

Reads and writes JSON payloads to the volatile MemoryStore asynchronously via IPC. The API exactly mirrors Datastore FBs.

use autocore_std::fb::memorystore::{MemoryStoreRead, MemoryStoreWrite};
use serde_json::json;

// Start a read/write to the MemoryStore key "config.camera"
self.mem_write.start("config.camera", json!({"exposure": 100}), ctx.client);
self.mem_read.start("config.camera", ctx.client);

Results System

Test Manager (*TestManager)

Specific Test Manager function blocks are automatically generated by codegen.rs based on the test_definitions in project.json. They provide a high-level, type-safe interface for logging test cycles and managing test state.

// Example usage of a generated 'ImpactTestManager'
match self.state {
    State::BeginTest => {
        self.test_manager.start_test("my_project_123", ctx.client);
        self.state = State::WaitCycle;
    }
    State::CycleComplete => {
        // Source-linked fields (like gm.peak_load) are auto-fetched!
        self.test_manager.add_cycle("PASS".to_string(), ctx);
    }
}

Methods:

MethodSignatureDescription
new() -> SelfCreate a new manager instance
start_test(&mut self, project_id: &str, client: &mut CommandClient)Start a new test sequence
add_cycle(&mut self, manual_fields..., ctx: &mut TickContext)Log a cycle. Manual fields are those without a source in project.json
update_results(&mut self, results_fields..., ctx: &mut TickContext)Update test-wide aggregate results
add_raw_data(&mut self, name: &str, data: Value, ctx: &mut TickContext)Link heavy raw arrays to the current cycle
tick(&mut self, client: &mut CommandClient)Execute one scan cycle to handle IPC comms

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)

Appendix D: CommandMessage Helper Methods

When writing external modules (see Writing External Modules), these helper methods on CommandMessage from the mechutil crate are available:

Constructors:

MethodDescription
CommandMessage::new()Empty message with defaults
CommandMessage::request(topic, data)Generic Request (message_type = 10)
CommandMessage::read(topic)Read request (message_type = 2)
CommandMessage::write(topic, data)Write request (message_type = 3)
CommandMessage::subscribe(topic)Subscribe request (message_type = 4)
CommandMessage::broadcast(topic, data)Broadcast message (message_type = 6)
CommandMessage::response(tid, data)Success response
CommandMessage::error_response(tid, err)Error response
CommandMessage::control(type, data)Control message (message_type = 8)

Mutation methods (used inside handle_message):

MethodDescription
msg.set_success(data)Mark as successful, set response data
msg.set_error("reason")Mark as failed, set error message
msg.into_response(data)Consume and return a success response
msg.into_error_response("reason")Consume and return an error response

Query methods:

MethodDescription
msg.domain()First segment of topic (e.g., "gm" from "gm.motor_speed")
msg.subtopic()Everything after the first dot (e.g., "motor_speed" from "gm.motor_speed")
msg.is_response()true if message_type == Response
msg.is_request()true if Read, Write, Subscribe, Unsubscribe, or Request
msg.is_broadcast()true if message_type == Broadcast