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.


Configuring ssh

It’s often necessary to use ssh to access the remote machine for installing updates or maintenance tasks. ssh is enabled on our target systems, and we can configure our development computers to make using ssh easier.

Disable strict checking on Windows

Windows enables strict checking by default, which means the first time you connect to a target IP 192.168.127.1, it will log a fingerprint for that system. The next time you try to connect to a different 192.168.127.1 system, the fingerprint won’t match, and ssh will fail. You’ll need to manually open a file and delete the fingerprint every time you connect to a different target, even one you may have connected to before.

Or you can disable strict checking on the IP range:

Edit \Users(USER NAME).ssh\config (create the file if it doesn’t exist):

Host 192.168.127.*
    StrictHostKeyChecking no
    UserKnownHostsFile /dev/null
    LogLevel ERROR

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

See also: Chapter 8b — Motion Axes is the full reference for creating and managing axes: the acctl add-axis command, the complete schema, drive-behavior defaults (including the AKD/Inovance halt_blocks_setpoint_ack halt-timeout fix), virtual/simulated axes, and the end-to-end workflow. The section below is the EtherCAT-specific walkthrough.

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)
    halt_blocks_setpoint_ackboolfalseDrive trait: the drive won’t acknowledge a set-point while Halt is asserted, so the halt cancel handshake must clear halt. Set true for the Kollmorgen AKD / Inovance SV660N. See Chapter 8b.
    soft_home_methodint37CiA-402 homing method (0x6098) for “current position = home”. E.g. 35 for the Inovance SV660N.

    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 acctl codegen. AutoCore automatically scaffolds all missing axis variables into the shared-memory layout with the correct data types (for axes in both the ethercat and neutral modules.motion homes). (This is now part of acctl codegen; the older ethercat.generate_variables command handles only PDO variables.)

Step 3: Generate variables, sync, and build

acctl codegen     # scaffold the axis output/option variables + regenerate gm.rs
acctl sync        # push project.json to the server
acctl push control --start   # build and deploy the control program

acctl codegen injects the variables named in the axes outputs/options blocks into the shared-memory layout automatically — you don’t create them by hand. See Chapter 8b for the full axis workflow, drive-behavior defaults, and virtual axes.

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

Absolute encoder note. The inovance_sv660n.json device profile sets 0x2002:02 = 1 (Absolute encoder system mode = linear) on startup. In this mode the drive automatically writes the encoder offset (2005-2Fh/31h) to EEPROM whenever a CiA 402 homing cycle completes — so calling axis.home(...) with config.home_position = X non-volatilely sets “current position reads as X.” Also set config.soft_home_method = 35 for SV660N axes (its 6098h value range is 1–35, so the default of 37 will be rejected). See Appendix B → Setting position non-volatilely on an absolute encoder for details.

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

Network Readiness FQDNs

The EtherCAT module publishes four scalar FQDNs describing how far the network has made it through bring-up. Control programs must gate any SDO traffic (calibration reads, tare clears, configuration writes) on ethercat.network_ready, otherwise the control program’s SDOs collide with the master’s own init SDOs and slaves downstream of the collision stay stuck in PREOP.

FQDNTypeMeaning
ethercat.network_readybooltrue when every configured slave is in OP and no slave has its error_flag set. Flips back to false the moment any slave drops out of OP.
ethercat.slaves_op_countu32How many configured slaves are currently in OP. Lets the HMI show “3 / 8 slaves online” during bring-up.
ethercat.slaves_totalu32Number of slaves configured in project.json. Constant once the module loads.
ethercat.errorboolMaster disconnected, or any slave reports error_flag != 0. Independent of network_ready so subscribers can distinguish “still booting” from “something is broken.”

These are published two ways by a background task in autocore-ethercat:

  • Direct SHM writes at the FQDN key on every poll (~500 ms). This is the path that updates any GM variable linked with "link": "ethercat.xxx" — the ethercat module’s SHM map includes pointers for the four status FQDNs alongside its PDOs, so writes land directly in the shared memory the control program reads via ctx.gm.ec_network_ready.
  • IPC broadcasts on transition + a ~5 s heartbeat. This is the path the autocore console and acctl monitor see, and the path pub/sub HMI subscribers consume.

Control programs should rely on the GM path (SHM-backed) — it’s polled every scan, no broadcast parsing required.

Linking into GM

Add matching variables to project.json:

"variables": {
  "ec_network_ready":   { "type": "bool", "link": "ethercat.network_ready",   "ux": true },
  "ec_slaves_op_count": { "type": "u32",  "link": "ethercat.slaves_op_count", "ux": true },
  "ec_slaves_total":    { "type": "u32",  "link": "ethercat.slaves_total",    "ux": true },
  "ec_error":           { "type": "bool", "link": "ethercat.error",           "ux": true }
}

Mark them "ux": true to expose them as HMI tags via acctl codegen-tags.

Gating a control program’s startup

Gate your process_tick at the top so nothing downstream runs until the network is fully up:

fn process_tick(&mut self, ctx: &mut TickContext<Self::Memory>) {
    if !ctx.gm.ec_network_ready {
        // Network still bringing up — do not issue SDOs yet, do not
        // start motion, do not process test cycles.
        return;
    }

    self.process_zforce.tick(ctx);
    self.process_main.tick(ctx);
    // ... etc
}

Why this matters concretely: during bring-up the autocore-ethercat cyclic task walks every slave through init → preop → safeop → op, sending a burst of SDO init commands. If your control program’s process_tick starts issuing its own SDOs while this is in progress (e.g. El3356::read_configuration on startup, or the zforce module’s calibration read), the two SDO streams contend for the master and some slaves get stuck in SAFEOP or PREOP. Symptom: ethercat slaves shows a clean cutoff after some point in the chain, everything downstream stuck in PREOP. Gating on ec_network_ready avoids this entirely.

Showing progress in the HMI

During bring-up, ec_slaves_op_count / ec_slaves_total gives you the obvious “N / M online” display. Example React hook:

const { value: ready }    = AutoCoreHooks.useAutoCoreTag('ecNetworkReady');
const { value: opCount }  = AutoCoreHooks.useAutoCoreTag('ecSlavesOpCount');
const { value: total }    = AutoCoreHooks.useAutoCoreTag('ecSlavesTotal');
const { value: ecError }  = AutoCoreHooks.useAutoCoreTag('ecError');

const status = ecError ? 'EtherCAT error'
             : ready   ? `EtherCAT ready (${total} slaves OP)`
             :           `Bringing up EtherCAT… ${opCount} / ${total}`;

Planned follow-up

A future autocore-server lifecycle change will turn per-module readiness signals like this into a server-level gate: autocore-server will wait for every module’s on_initialize to report ready before it starts the control program’s cyclic task at all, removing the need for control programs to gate themselves at the top of process_tick. Until that lands, every control program on a real EtherCAT network should include the explicit if !ctx.gm.ec_network_ready { return; } guard above.

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

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

Motion Axes: Creating and Managing

Getting a motor to move is, for most people, the hardest part of bringing up a new machine. This chapter is the authoritative guide to axes in AutoCore: what they are, how to create them, how to configure their behavior, and how to avoid the handful of mistakes that cost people a day each. Chapter 8 walks through the EtherCAT-specific scan/PDO steps with a concrete Teknic example; this chapter is the reference that example points back to.

What an axis is

An axis is a high-level motion abstraction. It pairs:

  • a motion core — the CiA-402 state machine plus scaling, homing, and limit configuration (autocore_std::motion::Axis + AxisConfig), and
  • a backend — where the drive actually lives.

The motion core is fieldbus-agnostic. The generic Axis controller talks to its drive through a narrow AxisView trait (control word, status word, target, feedback, modes), so the same control-program code drives:

  • an EtherCAT servo (the AxisView is generated PDO wiring), or
  • a virtual / simulated drive (the AxisView is an in-process SimDrive), with no hardware at all.

This is why an axis is defined separately from the slave. The slave entry describes the physical EtherCAT device and its PDOs; the axis entry describes the motion abstraction layered on top. Swapping the motor to a different drive is a one-line change to the axis (link), and the control program never changes.

Key idea. You write your process logic against the axis, not the drive. The axis is the stable contract; the backend is an implementation detail.

Where axes live

Axes are listed in an axes array. There are two homes, both read by codegen:

LocationForNotes
modules.ethercat.config.axesEtherCAT-backed axesThe legacy/default home. Every existing project uses it. An untagged axis here is an EtherCAT axis.
modules.motion.config.axesBackend-neutral axes (e.g. virtual)The home for axes that aren’t tied to the EtherCAT bus.

You normally don’t choose by hand — acctl add-axis puts each axis in the right place. EtherCAT axes can stay untagged in the ethercat config (fully back-compatible); virtual axes carry an explicit backend tag.

Creating an axis

The fast way: acctl add-axis

# An EtherCAT axis bound to a scanned slave:
acctl add-axis --name Press --link AKD_3

# A virtual / simulated axis (no fieldbus):
acctl add-axis --name SimShuttle --backend virtual
FlagDefaultMeaning
--name(required)Axis name → the generated handle struct (e.g. Press). Describe the function, not the hardware.
--linkSlave name to bind to. Required for ethercat, omitted for virtual.
--typeppCiA-402 profile type (Profile Position).
--backendethercatethercat or virtual.

The command is idempotent on --name and writes the axis into the correct home. It does not seed drive-behavior defaults — see Drive-behavior defaults below — because the CLI can’t read the EtherCAT device library; the IDE or a hand edit fills those in.

After adding an axis, run acctl codegen to generate its drive handle.

By hand

Add an object to the appropriate axes array. A minimal EtherCAT axis:

"modules": { "ethercat": { "config": {
    "axes": [
        { "name": "Press", "link": "AKD_3", "type": "pp" }
    ],
    "slaves": [ /* ... */ ]
} } }

A virtual axis goes in the neutral home and carries the backend tag:

"modules": { "motion": { "enabled": false, "config": {
    "axes": [
        { "name": "SimShuttle", "type": "pp", "backend": { "kind": "virtual" } }
    ]
} } }

modules.motion is a config-only namespace, not a runtime module — set "enabled": false. Codegen reads its config.axes, but there is no motion module binary; leaving it enabled (the default) makes the server’s supervisor try to spawn a nonexistent “motion” executable. acctl add-axis sets this for you.

The axis schema

FieldTypeRequiredDescription
namestringyesAxis name → generated handle struct.
linkstringethercat onlySlave name this axis binds to (matches a slaves entry).
typestringyesCiA-402 profile, currently "pp".
backendobjectno{ "kind": "ethercat" } (default if omitted) or { "kind": "virtual" }. An optional link inside the ethercat backend overrides the top-level link.
optionsobjectnoSensor/limit wiring and drive-behavior traits — see below.
outputsobjectnoAxis status values published to GlobalMemory each tick.

options

FieldTypeDefaultDescription
positive_limitstringGM bool for the positive limit switch
negative_limitstringGM bool for the negative limit switch
home_sensorstringGM bool for the home reference sensor
error_codestringGM u16 for the drive error code
maximum_pos_limitstringGM numeric: dynamic max software position limit (user units)
minimum_pos_limitstringGM numeric: dynamic min software position limit (user units)
invert_directionboolfalseNegate position targets and feedback (reverse direction in software)
halt_blocks_setpoint_ackboolfalseDrive trait. See Drive-behavior defaults.
soft_home_methodint37CiA-402 homing method (0x6098) for “current position = home”. E.g. 35 for the Inovance SV660N.

The string-valued options name GM variables; the bool/int options are baked directly into the generated handle’s AxisConfig. Software-position-limit behavior is covered in Chapter 8.

outputs

Each field names a GM variable that the generated tick() writes every cycle (all optional):

FieldGM typeFieldGM type
positionf64in_motionbool
raw_positioni64moving_positivebool
speedf64moving_negativebool
is_busyboolat_max_limitbool
is_errorboolat_min_limitbool
error_codeu32/i32at_positive_limit_switchbool
error_messagestringat_negative_limit_switchbool
motor_onboolhome_sensorbool

You do not hand-create these variables. Name them in outputs/options, and acctl codegen scaffolds the missing ones into the shared-memory layout with the right types (see The workflow).

Drive-behavior defaults (the thing that bites people)

Different CiA-402 drives disagree on subtle protocol behavior. The one that costs people a day is the halt cancel handshake, surfaced as halt_blocks_setpoint_ack.

Symptom

You command a stop (e.g. move-to-load reaches its target and calls halt()). The motor physically stops, but the axis reports an error:

Halt timeout: cancel not acknowledged

Cause

To cleanly close out a halt, the axis issues a new set-point (current position, zero velocity) and waits for the drive to acknowledge it (status-word bit 12). Drives disagree on whether they’ll acknowledge a set-point while Halt (control word bit 8) is still asserted:

  • Kollmorgen AKD, Inovance SV660N — will not acknowledge while halted, so the handshake must clear halt first. This is the CiA-402-standard behavior; set halt_blocks_setpoint_ack: true.
  • Teknic ClearPath — acknowledges while halted, and resumes the prior move if you clear halt early, so it needs halt held through the handshake. It is the oddball; leave halt_blocks_setpoint_ack: false.

The default is false — chosen as the safe fallback, not the common case. On an unknown drive, a wrong false merely times out with the motor already stopped (loud and safe); a wrong true could provoke unexpected motion. So if you hit the halt timeout on an AKD/Inovance, the fix is to set the option true.

soft_home_method

The CiA-402 method (0x6098) used when declaring “current position = home”. The default 37 works on modern drives (Teknic ClearPath); the Inovance SV660N’s range stops at 35. Set it per drive when homing won’t latch.

Seeding defaults instead of memorizing them

These values are properties of the drive model, recorded in the EtherCAT device library (device_definitions.json, via ext_definitions/<vendor>.json). You don’t have to remember which drive needs what:

  • In the IDE — run autocore: Seed axis drive defaults. It asks the live target (ethercat.list_devices) for each bound drive’s profile and writes the matching options into project.json. It is sparse (only writes what the drive declares — Teknic gets nothing, so it stays at the safe default) and non-destructive (never overwrites a value you set, so re-running is safe).
  • By hand — set halt_blocks_setpoint_ack / soft_home_method in the axis options per the table above.

Either way the value ends up in project.json options and is baked into the generated handle by acctl codegen.

Virtual / simulated axes

A virtual axis has no fieldbus. Its AxisView is an in-process SimDrive (autocore_std::motion::sim::SimDrive) that emulates a CiA-402 Profile Position drive: the enable state machine, the set-point-acknowledge handshake, and motion integration. Because Axis is generic over AxisView, your process code is identical whether the axis is real or simulated.

acctl add-axis --name SimPress --backend virtual
acctl codegen

Use them to:

  • develop and test process logic with no hardware (CI-friendly), and
  • A/B a process against sim vs. a real drive by swapping the axis backend.

Notes and limits:

  • The generated virtual handle’s sync() advances the sim by a fixed SIM_DT (default 10 ms). Match it to your control program’s scan period if timing matters.
  • SDO and persistent-position methods are omitted on the virtual handle (they’re meaningless without a fieldbus).
  • Virtual axes require autocore-std ≥ 3.3.55 (the version that introduced SimDrive). acctl codegen checks this for you — see The workflow.

The workflow

acctl add-axis --name Press --link AKD_3   # 1. create the axis
#   (IDE: "Seed axis drive defaults")       # 2. seed drive-behavior options
acctl codegen                               # 3. generate gm.rs + scaffold vars
acctl push control --start                  # 4. build + deploy

What acctl codegen does for axes, server-side:

  • Generates the drive handle in control/src/gm.rs (an EtherCAT DriveHandle or a SimDrive-backed handle), baking in the options (invert_direction, halt_blocks_setpoint_ack, soft_home_method).

  • Scaffolds the axis variables. The outputs/options variable names are injected into the shared-memory layout automatically (via the server’s Project::normalize), for axes in both homes. You do not run ethercat.generate_variables for axis variables — that command now handles only PDO variables.

  • Checks the autocore-std floor. Codegen emits constructs that need a minimum autocore-std (GmCompat → 3.3.52, halt_blocks_setpoint_ack → 3.3.54, SimDrive → 3.3.55). If control/Cargo.lock pins an older version, acctl codegen stops before writing gm.rs with an actionable message rather than letting you hit a cryptic Rust compile error:

    ✗ autocore-std too old for this server's codegen output.
      control/Cargo.lock pins 3.3.51, but the generated gm.rs needs ≥ 3.3.55.
      Bump it, then re-run codegen:
          (cd control && cargo update -p autocore-std --precise 3.3.55)
    

Using the generated handle

The handle bundles the Axis state machine with its backend (a PDO snapshot or a SimDrive). Each tick:

  1. sync(gm) — pull feedback (EtherCAT: TxPDO from shared memory; virtual: advance the sim).
  2. Issue commandsenable(), move_absolute(pos, vel, accel, decel), move_relative(...), home(method), halt(), reset_faults(), set_position(...), disable().
  3. tick(gm, client) — advance the state machine and publish outputs.

Status reads mirror the outputs: position(), speed(), is_busy(), is_error(), error_code(), error_message(), motor_on(), in_motion(). See Chapter 8 for a full control-program example.

Managing axes

  • Swap the drive — change the axis link to the new slave name and re-run acctl codegen. The control program is untouched.
  • Change behavior — edit options and re-run acctl codegen. Re-running the IDE seed command won’t clobber a value you set by hand.
  • Rename — changing name renames the generated handle struct; update the control program’s references.
  • Remove — delete the axis object and re-run acctl codegen. (Orphaned output variables can be removed from variables if you no longer want them.)

Adding, removing, or re-binding an axis does not change the shared-memory layout hash unless it adds or removes output variables — so an axis edit is generally a recompile, not a server-restart.

Common pitfalls

SymptomCauseFix
Halt timeout: cancel not acknowledged (motor stops, axis errors)Drive won’t ack a set-point while halted (AKD/Inovance)Set halt_blocks_setpoint_ack: true (or run the IDE seed command).
Homing never latchesWrong CiA-402 homing method for the driveSet soft_home_method (e.g. 35 for SV660N).
gm.<axis var> not found when buildingAxis output variable not scaffoldedRun acctl codegen (it injects them). Confirm the outputs name is spelled the same everywhere.
cannot find SimDrive / no field halt_blocks_setpoint_ack / GmCompat not foundControl program’s autocore-std is older than the codegen output needsBump autocore-std (the acctl codegen gate prints the exact --precise version).
Move rejected / quick-stop near a limitDynamic software position limit trippedCheck maximum_pos_limit / minimum_pos_limit GM values; a move away from the limit is always allowed.

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.

Preliminary Steps: Configure the Hardware and add to Linux

autocore can’t do anything with the NI hardware until it’s visisble to the Linux operating system.

The first step is to use a windows PC with NI Max to assign a fixed IP address to the Chassis and device names to the chassis and sub-modules. In this case, we’ll be using a cDAQ-9185 with a single sub-module in Slot 1, our most common use-case on Linux.

We tend to use IP address 192.168.127.5 for the chassis. For device name, name use:

  • Chassis: cdaq-${4 digit serial}
    • example: cdaq-1234
  • Each module: ${chassis name}-mod${slot numer}
    • example: cdaq-1234-mod1

For standard products that are sharing project configurations, we may use a different chassis name, like cdaq-tt for the Traction tester.

  • cdaq-tt cdaq-tt-mod1

In any case, once the static IP and device name is configured in NI Max on Windows, un-reserve the device and connect to the Linux PC network.

ssh into the linux device and make sure you can ping the cDaq:

user@ADC-SN-3833:~$ ping 192.168.127.5
PING 192.168.127.5 (192.168.127.5) 56(84) bytes of data.
64 bytes from 192.168.127.5: icmp_seq=1 ttl=64 time=0.366 ms
64 bytes from 192.168.127.5: icmp_seq=2 ttl=64 time=0.208 ms

Now we need to add and reserve our chassis to the Linux system.

nidaqmxconfig --add-net-dev 192.168.127.5 --reserve
Success
Added: cdaq-tt

user@ADC-SN-3833:~$ nilsdev --verbose
cdaq-tt
   BusType: TCP/IP
   DevSerialNum: 0x259AEA9
   ProductType: cDAQ-9185
   TCPIP.DevIsReserved: 0
   TCPIP.EthernetIP: 192.168.127.5
   TCPIP.EthernetMAC: 00:80:2F:43:54:CB
   TCPIP.EthernetMDNSServiceInstance: NI cDAQ-9185 0259AEA9
   TCPIP.Hostname: cdaq-tt.local

That gets us the chassis, but configures none of the modules, leaving them inaccessible. We also didn’t successfully reserve the device (DevIsReserved: 0), mostly likely because NI Max in Windows reserved the device when configuring the IP address and device name.

The first move is to reserve the device so that we can get the actual module information.

nidaqmxconfig --reserve cdaq-tt --force
Success

Now let’s see how our device listing has changed.

cdaq-tt
   BusType: TCP/IP
   DevSerialNum: 0x259AEA9
   ProductType: cDAQ-9185
   TCPIP.DevIsReserved: 1
   TCPIP.EthernetIP: 192.168.127.5
   TCPIP.EthernetMAC: 00:80:2F:43:54:CB
   TCPIP.EthernetMDNSServiceInstance: NI cDAQ-9185 0259AEA9
   TCPIP.Hostname: cdaq-tt
   cdaq-ttMod3
      CompactDAQ.ChassisDevName: cdaq-tt
      CompactDAQ.SlotNum: 3
      DevSerialNum: 0x2458AC3
      ProductType: NI 9401
   cdaq-ttMod1
      CompactDAQ.ChassisDevName: cdaq-tt
      CompactDAQ.SlotNum: 1
      DevSerialNum: 0x246DE72
      ProductType: NI 9237
   cdaq-ttMod2
      CompactDAQ.ChassisDevName: cdaq-tt
      CompactDAQ.SlotNum: 2
      DevSerialNum: 0x24B1084
      ProductType: NI 9237

Now we’re talking! The Linux operating system now has access to all the information it needs to generate the .ini file needed to configure ni daqmx for usage.

Step 3: Generate the .ini and import it

On Linux, we need to tell daqmx about the NI network, including serial numbers and device names. We can use autocore to help us with this.

From the autocore console, start the ni module:

system.load_module --name ni

Now, let’s have it generate a daqmx .ini configuration file that we can review. We will generate the file, but not apply it.

ni.generate_device_config
{
  "content": "[DAQmx]\nMajorVersion = 26\nMinorVersion = 0\n\n[DAQmxCDAQChassis cdaq-tt]\nProductType = cDAQ-9185\nDevSerialNum = 0x259AEA9\nBusType = TCP/IP\nTCPIP.Hostname = cdaq-tt.local\nTCPIP.EthernetIP = 192.168.127.5\nTCPIP.EthernetMAC = 00:80:2F:43:54:CB\nTCPIP.EthernetMDNSServiceInstance = NI cDAQ-9185 0259AEA9\nTCPIP.DevIsReserved = 1\n\n[DAQmxCDAQModule cdaq-tt-mod1]\nProductType = NI 9237\nCompactDAQ.ChassisDevName = cdaq-tt\nCompactDAQ.SlotNum = 1\n\n[DAQmxCDAQModule cdaq-tt-mod2]\nProductType = NI 9237\nCompactDAQ.ChassisDevName = cdaq-tt\nCompactDAQ.SlotNum = 2\n\n[DAQmxCDAQModule cdaq-tt-mod3]\nProductType = NI 9401\nCompactDAQ.ChassisDevName = cdaq-tt\nCompactDAQ.SlotNum = 3\n",
  "imported": false,
  "path": "/tmp/autocore_ni_devices.ini"
}

This combines the device configuration from step 2 with the runtime information from nilsdev to produce a .ini file. The temporary file is saved at /tmp/autocore_ni_devices.ini.

From the terminal of our device, we can easily review the .ini file to see if it’s correct.

nano /tmp/autocore_ni_devices.ini
[DAQmx]
MajorVersion = 26
MinorVersion = 0

[DAQmxCDAQChassis cdaq-tt]
ProductType = cDAQ-9185
DevSerialNum = 0x259AEA2
BusType = TCP/IP
TCPIP.Hostname = cDAQ9185-259AEA2
TCPIP.EthernetIP = 192.168.127.5
TCPIP.EthernetMAC = 00:80:2F:43:54:BD
TCPIP.EthernetMDNSServiceInstance = NI cDAQ-9185 0259AEA2
TCPIP.DevIsReserved = 1

[DAQmxCDAQModule cdaq-tt-mod1]
ProductType = NI 9237
DevSerialNum = 0x024B1059
CompactDAQ.ChassisDevName = cdaq-tt
CompactDAQ.SlotNum = 1

[DAQmxCDAQModule cdaq-tt-mod2]
ProductType = NI 9237
DevSerialNum = 0x024B106C
CompactDAQ.ChassisDevName = cdaq-tt
CompactDAQ.SlotNum = 2

[DAQmxCDAQModule cdaq-tt-mod3]
ProductType = NI 9401
DevSerialNum = 0x0247C440
CompactDAQ.ChassisDevName = cdaq-tt
CompactDAQ.SlotNum = 3

Note the DevSerialNum = xxxxxx field in all module definitions. This is important, and the solution won’t work without it.

Another issue to note is the host name for the cDAQ-9185. It may get changed to match the device name, but it needs to be:

cDAQ9185-${SERIAL NUMBER}

If we’re happy with what autocore-server has generated, sime can have it re-generate and import.

ni.generate_device_config --import true

Example from above:

TCPIP.Hostname = cDAQ9185-259AEA2

This generates the .ini file then imports it via nidaqmxconfig --import --replace. The generated file is saved alongside your project.json as ni_devices.ini.

Once this is done, we have to re-reserve the device; the ini import will close out reservations, although the cDAQ will think it’s still reserved.

user@ADC-SN-3833:~$ nidaqmxconfig --reserve cdaq-tt --force
Success
user@ADC-SN-3833:~$ nilsdev --verbose
cdaq-tt
   BusType: TCP/IP
   DevSerialNum: 0x259AEA9
   ProductType: cDAQ-9185
   TCPIP.DevIsReserved: 1
   TCPIP.EthernetIP: 192.168.127.5
   TCPIP.EthernetMAC: 00:80:2F:43:54:CB
   TCPIP.EthernetMDNSServiceInstance: NI cDAQ-9185 0259AEA9
   TCPIP.Hostname: cdaq-tt
   cdaq-tt-mod2
      CompactDAQ.ChassisDevName: cdaq-tt
      CompactDAQ.SlotNum: 2
      DevSerialNum: 0x24B1084
      ProductType: NI 9237
   cdaq-tt-mod1
      CompactDAQ.ChassisDevName: cdaq-tt
      CompactDAQ.SlotNum: 1
      DevSerialNum: 0x246DE72
      ProductType: NI 9237
   cdaq-tt-mod3
      CompactDAQ.ChassisDevName: cdaq-tt
      CompactDAQ.SlotNum: 3
      DevSerialNum: 0x2458AC3
      ProductType: NI 9401
When auto-generation doesn’t work.

Some NI hardware won’t place nice with others, and auto-generation won’t work, although it will give us a good starting point.

ni.generate_device_config
cp /tmp/autocore_ni_devices.ini ./ni_devices.ini
nano ni_devices.ini

Most likely, the serial number information is missing. Hopefully, nilsdev --verbose will have the information you need. If not, you’ll need to connect via NI MAX from Windows, get the serial number information, then re-reserve the chassis on Linux and fill in the serial information.

Once the ni_devices.ini file has all the proper information, have nidaqmxconfig import it.

nidaqmxconfig --import ./ni_devices.ini --eraseconfig

This command will have nidaxmx import the new configuration, replacing any existing configuration from previous configurations.

user@ADC-SN-3833:~$ nidaqmxconfig --import ./ni_devices.ini --eraseconfig
Processed:
   cdaq-tt
   cdaq-tt-mod1
   cdaq-tt-mod2
   cdaq-tt-mod3
Success


If the import fails with “Hardware product type specified is invalid” for the modules, try variations on the ProductType string — NI is inconsistent about naming. Possible formats:

NI 9218 vs NI-9218 vs NI9218 NI 9401 vs NI-9401 vs NI9401

Usually, this is the right format: NI 9218 Except for cDAQ modules: cDAQ-9185

Once this is done, we have to re-reserve the device; the ini import will close out reservations, although the cDAQ will think it’s still reserved.

user@ADC-SN-3833:~$ nidaqmxconfig --import ./ni_devices.ini --replace
Processed:
   cdaq-tt
   cdaq-tt-mod1
   cdaq-tt-mod2
   cdaq-tt-mod3
Success
user@ADC-SN-3833:~$ nidaqmxconfig --reserve cdaq-tt --force
Success
user@ADC-SN-3833:~$ nilsdev --verbose
cdaq-tt
   BusType: TCP/IP
   DevSerialNum: 0x259AEA9
   ProductType: cDAQ-9185
   TCPIP.DevIsReserved: 1
   TCPIP.EthernetIP: 192.168.127.5
   TCPIP.EthernetMAC: 00:80:2F:43:54:CB
   TCPIP.EthernetMDNSServiceInstance: NI cDAQ-9185 0259AEA9
   TCPIP.Hostname: cdaq-tt
   cdaq-tt-mod2
      CompactDAQ.ChassisDevName: cdaq-tt
      CompactDAQ.SlotNum: 2
      DevSerialNum: 0x24B1084
      ProductType: NI 9237
   cdaq-tt-mod1
      CompactDAQ.ChassisDevName: cdaq-tt
      CompactDAQ.SlotNum: 1
      DevSerialNum: 0x246DE72
      ProductType: NI 9237
   cdaq-tt-mod3
      CompactDAQ.ChassisDevName: cdaq-tt
      CompactDAQ.SlotNum: 3
      DevSerialNum: 0x2458AC3
      ProductType: NI 9401

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

Module Status FQDNs

ni.status is a command that returns a JSON blob — useful for the console and for HMI panels that want to show everything at once, but awkward to poll from a Rust control program or wire into a React tag. For programmatic use there are three boolean FQDNs published as Global Memory variables, plus the per-task timing FQDNs mentioned above.

Lifecycle booleans (module-level, always present)

FQDNTypeMeaning
ni.runningboolThe worker thread exists. Transitions to true on ni.start and back to false on ni.stop or if the worker exits unexpectedly.
ni.hardware_readyboolDAQmx task setup succeeded and the hardware is actually acquiring. Distinct from running because spawn_worker can succeed but the tasks can still fail to start() — e.g. PFI collisions (-201133), missing SampClkSrc (-200303).
ni.errorboolOne or more errors have been pushed onto the worker error log. Clears when you call ni.clear_errors or ni.start (which resets the log before spawning).

These three FQDNs are present even before a config is loaded — they exist at the module boundary, independent of whether any tasks are defined. They are written:

  • Immediately on start() / stop() state transitions, and
  • On every 100 ms heartbeat as a cheap refresh.

Per-task timing (one set per task in the config)

FQDNTypeMeaning
ni.<task>.actual_sample_ratef64The real sample rate polled from DAQmx via DAQmxGetSampClkRate after the task is running. This can differ from the rate you configured — the hardware picks the closest achievable divisor.
ni.<task>.time_incrementf64Reciprocal of actual_sample_rate in seconds. Use this to build sample-time arrays for captured data.

These are written once per task when the worker sends WorkerEvent::Started.

Correct Control-Program Gate

Don’t arm a DAQ capture based on ni.running alone. A running worker whose tasks haven’t yet committed will silently drop arm requests — the trigger state machine won’t be initialized. Always gate on both ni.running and ni.hardware_ready and !ni.error.

// In your control-program tick():
if ctx.gm.ni_running
    && ctx.gm.ni_hardware_ready
    && !ctx.gm.ni_error
{
    // Now it's safe to:
    ctx.gm.impact_arm = true;   // arm a DAQ trigger, or
    // start a test, read capture buffers, etc.
}

In React, the same three tags drive an “NI ready” indicator:

const { value: niRunning }       = AutoCoreHooks.useAutoCoreTag('niRunning');
const { value: niHardwareReady } = AutoCoreHooks.useAutoCoreTag('niHardwareReady');
const { value: niError }         = AutoCoreHooks.useAutoCoreTag('niError');

const canArm = niRunning && niHardwareReady && !niError;

The ni.running / ni.hardware_ready / ni.error FQDNs become GM variables named ni_running, ni_hardware_ready, ni_error when you call ni.save_config, which are exposed to TypeScript as niRunning, niHardwareReady, niError after acctl codegen-tags.

For human-readable diagnostics — error messages, channel list, DAQ names — keep using the ni.status command; the FQDNs are intentionally minimal so a control-program’s hot loop can read three bools without parsing JSON.

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.

Update-rate cap (~30 FPS by default)

Even when the controller scan rate is in the kilohertz range, the React HMI only updates at roughly 30 FPS. <AutoCoreTagProvider> coalesces incoming broadcasts per tag (latest-value-wins) and applies them in a single batched render at most once every flushIntervalMs (default 33). Without this cap, a 4 kHz scan with ten fast-changing tags would fire ~40,000 React reconciliations per second — Firefox in particular visibly stalls under that load.

To override the cap (e.g., 60 FPS for a kiosk on Chrome, or 0 to disable throttling and apply every broadcast immediately):

<AutoCoreTagProvider tags={acTagSpec} flushIntervalMs={16}>
  ...
</AutoCoreTagProvider>

The cap only affects React reconciliation pacing — the wire still delivers every change-of-value broadcast. To also reduce wire bandwidth (relevant on weak WiFi or remote access over Tailscale), see the connection-level rate cap covered in the server reference.

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

Servelet Hooks

@adcops/autocore-react/hooks/useServeletData provides three typed hooks on top of the servelet conventions described in Chapter 4. Each one does the same three things that application code usually rewrites by hand: (1) read the current value on mount, (2) subscribe to broadcasts so the value stays live, (3) expose write() / refresh(). Use them instead of calling invoke() by hand whenever the key is known at render time.

useMemoryStore(key, default?) — volatile runtime cache

Reads from and writes to the memorystore domain. Good for UI state you want to survive a page reload but not a server restart — e.g. the last-open tab, a cached editor buffer, scratch data between tabs.

import { useMemoryStore } from '@adcops/autocore-react/hooks/useServeletData';

const EditorTab: React.FC = () => {
    const { value: xml, write: saveXml, isLoading } = useMemoryStore<string>('ux://editor-current-xml', '');

    if (isLoading) return <Spinner />;

    return (
        <textarea
            value={xml ?? ''}
            onChange={e => saveXml(e.target.value)}
        />
    );
};

Wire topic: memorystore.{key}. Writes broadcast on the same topic, so any other component hooked onto the same key sees the new value without a second fetch.

useGnv(group, key, default?) — persistent configuration

Reads/writes a single key under the Global Non-Volatile store. Use for settings that must outlive a server restart — unit preferences, calibration constants, display labels. The server enforces group-then-key addressing; the hook handles both.

import { useGnv } from '@adcops/autocore-react/hooks/useServeletData';

const UnitsPanel: React.FC = () => {
    const { value: label, write: setLabel } = useGnv<string>('ux', 'position_label', 'mm');
    const { value: scale, write: setScale } = useGnv<number>('ux', 'position_units', 1);

    return (
        <>
            <input value={label ?? ''} onChange={e => setLabel(e.target.value)} />
            <input
                type="number"
                value={scale ?? 1}
                onChange={e => setScale(Number(e.target.value))}
            />
        </>
    );
};

Wire topic: gnv.{group}.{key}.

useServeletData(domain, key, default?) — escape hatch

The generic form behind useMemoryStore and useGnv. Use it when you need to hook into a servelet those specialized wrappers don’t cover (for example, a third-party servelet you wrote with the same Read/Write/Control convention).

import { useServeletData } from '@adcops/autocore-react/hooks/useServeletData';

const { value, write } = useServeletData<MyShape>('myservelet', 'some_key');

Wire topic: {domain}.{key}. The servelet on the other end must follow the standard conventions (Read/Write dispatched by message type, broadcast on the same topic after Write) for the hook’s subscription path to work.

When hooks aren’t the right fit

Hooks bind a component to a known, fixed key. Reach for invoke() directly when:

  • The key is dynamic (e.g. the user types it into a form and it changes per action).
  • You need a one-shot read or write that doesn’t also subscribe — an export handler, a click-to-delete.
  • You’re using operations that don’t fit the simple Read/Write envelope (MessageType.Control operations like list_files, create_group, delete; or application-level topics on ADS/MODBUS/PYTHON modules that still route by legacy fname strings).
// Dynamic file read
const { invoke } = useContext(EventEmitterContext);
const res = await invoke(`datastore.${fileName}`, MessageType.Read, {});

// List files (Control action)
const res = await invoke('datastore', MessageType.Control, {
    action: 'list_files',
    subdir: 'sequences',
    options: { filter: '*.json' },
});

// Legacy fname dispatch (ADS, PYTHON, etc.)
await invoke('ADS.register_symbol', MessageType.Request, { symbol_name: 'GIO.fMotorSpeed' });

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.


Autocore-React Component Library

The previous chapter covered the connection layer of @adcops/autocore-react — hooks like useMemoryStore, useGnv, and useServeletData for talking to the server. This chapter covers the presentation layer: the provider tree that supplies context, the component families ready to drop into an HMI, and the styling system (most importantly, the ac-form layout primitives) used to keep forms consistent across an autocore project.

If you’ve ever stared at a half-styled <TestSetupForm /> wondering why nothing lines up, this chapter is for you.

Provider tree

Every autocore-react HMI mounts a stack of context providers near the root. The order matters — outer providers must be in place before inner ones read from them. The canonical layout, from the 3830 traction tester:

<EventEmitterProvider>           {/* IPC / hub primitives */}
    <PrimeReactProvider>         {/* PrimeReact theming */}
        <AutoCoreTagProvider     {/* GM tag spec + scales */}
            tags={acTagSpec}
            scales={acScales}
            eagerRead
        >
            <TisProvider defaultMethodId="translational_traction">
                <AmsProvider>
                    <App />
                </AmsProvider>
            </TisProvider>
        </AutoCoreTagProvider>
    </PrimeReactProvider>
</EventEmitterProvider>

What each one provides:

ProviderSourceProvides
EventEmitterProvidercore/EventEmitterContextThe invoke, subscribe, unsubscribe, dispatch, write primitives that every other provider builds on.
PrimeReactProviderprimereact/apiPrimeReact’s own context (locale, ripple, etc.).
AutoCoreTagProvidercore/AutoCoreTagContextGM tag registry, type-aware reads/writes, scale conversions. Backs useAutoCoreTag().
TisProvidercomponents/tis/TisProviderTest Information System — schemas, selection (project/method/sample/run), staged config draft, run cache. Backs every TIS component.
AmsProvidercomponents/ams/AmsProviderAsset Management System — asset registry, schemas, roles, alerts. Backs every AMS component.

TisProvider and AmsProvider are independent — you can mount one without the other. If your project doesn’t use AMS, drop AmsProvider; AMS-aware components fall back to empty asset lists gracefully.

Component families

TIS components

Render the test workflow on the operator HMI. All read from TisProvider context — none take props for the project/method selection.

ComponentPurpose
<ProjectSelector />Project tab: list known projects, pick or create one. Pins selection.projectId.
<TestSetupForm />Test tab: per-test sample ID, method picker, config_fields. Auto-stages on every valid edit.
<TestMethodDialog />Modal picker for test method, used by TestSetupForm.
<ResultHistoryTable />Project tab: every recorded run for the active project, with quick-load to TestDataView.
<TestDataView />Live cycle scatter charts driven by the active method’s views block.
<TestRawDataView />Raw waveform plots driven by raw_data.columns.
<TisConfigEditor />Authoring tool — edit the test_methods block of project.json in-app (fields, views, raw_data shape, asset_refs, analysis). See the dedicated section below.

AMS components

Render the asset registry. Drop them into a Settings panel or a dedicated tab.

ComponentPurpose
<AssetRegistryTable />List every asset, filter by type/status, “+ Add” to register.
<AssetDetailView />Selected asset’s details — calibration history, usage counters, sub-locations.
<CalibrationEntryDialog />Form to add a new calibration record.
<SubLocationPicker />Edit per-instance state on multi-position assets (e.g., surface lanes).

Toolbar widgets

ComponentPurpose
<IndicatorButton />Two-state colored button (motor on/off, error reset, etc.).
<IndicatorColor> enumStandard color names — IndicatorGreen, IndicatorRed, IndicatorOrange, IndicatorOff.
<ToggleGroup />Mutually-exclusive button group for mode selection.

Editing test methods: <TisConfigEditor />

Everywhere else in this chapter, the TIS components consume the schema — they read test_methods from project.json and render the test workflow against it. <TisConfigEditor /> is the other side of that contract: it writes the schema. Open it in an admin tab, click around, save, and the HMI’s test workflow picks up the new methods on the next mount.

If you’ve ever hand-edited project.json with VS Code while squinting at the TestMethod struct in project.rs to make sure your braces line up, this is for you.

What it edits

The entire test_methods block of project.json — every field on every TestMethod, including:

  • label / description (the picker UX)
  • Four field arrays: project_fields, config_fields, cycle_fields, results_fields
  • views (chart definitions consumed by <TestDataView> and <TestRawDataView>)
  • raw_data (the DAQ blob shape — blob_name plus per-column source)
  • asset_refs (AMS dependencies snapshotted at start_test time)
  • analysis (post-cycle Python hook)

Unknown top-level fields in project.json (anything outside test_methods) are preserved on save — the editor does JSON-level surgery, not a full round-trip through the Rust Project struct, so any forward-compatible keys your team adds survive.

Mounting

import { TisConfigEditor } from '@adcops/autocore-react/components/tis-editor/TisConfigEditor';

<TisConfigEditor projectId="3830_traction" />

That’s the whole API. projectId is required and threads through every IPC call, but today the server resolves it to the loaded project — so any non-empty string is accepted. (See Multi-project posture below.)

The editor pulls live data via the standard provider tree (EventEmitterProvider for IPC, plus AmsProvider upstream if you want the asset_type dropdown to populate). No additional context required.

For testing or for an offline playground, pass a custom invoker:

<TisConfigEditor projectId="demo" invoker={mockInvoker} />

Where mockInvoker is a function of type TisIpcInvoker(topic: string, payload: object) => Promise<{ success, data, error_message }>. The autocore-react/playground repo includes a working mock you can copy.

The staged-then-saved model

This is the most important concept to grasp. Edits don’t land on disk immediately. They land in an in-memory copy on the server, and stay there until you click Save.

HMI types in editor   → tis.put_method   ┐
HMI deletes a method  → tis.remove_method├→ server-side stage (in memory)
HMI clicks "Revert"   → tis.discard_…    ┘                 │
HMI clicks "Save"     ──────────────────────→ tis.save_config
                                                          ↓
                                              atomic write of project.json
                                                  (+ project.json.bak)

Several consequences flow from this:

  • The dirty pill in the top-right of the editor (an orange “unsaved” badge) means: the server has staged edits that don’t match disk yet. Save and Revert are only enabled when this is true.
  • Stage survives across HMI page reloads — the server holds it. If you refresh the browser, your edits are still there.
  • Stage does not survive an autocore-server restart. If you bounce the server with unsaved edits, they’re gone.
  • Two HMIs editing the same server share one stage. Last writer wins. In practice the editor is used by one operator at a time on a station; if you need per-user drafts, that’s a future addition.

Layout

┌─ Test Methods [unsaved]                    [Save…] [Revert] ─┐
│                                                              │
│ ┌──── sidebar ────┐ ┌──── detail pane ─────────────────────┐ │
│ │ [New][Dup][Del] │ │ rotational_traction  •  [Apply]      │ │
│ │ ─────────────── │ │ ──────────────────────────────────── │ │
│ │ Method ID Label │ │ [Identity][Fields][Views][Raw…]…    │ │
│ │ ─────────────── │ │                                      │ │
│ │ rotational_…  ●  │ (the active tab's subform)            │ │
│ │ translational… │ │                                      │ │
│ │                 │ │                                      │ │
│ └─────────────────┘ └──────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
  • Top action bar: dirty indicator, Save (opens diff dialog), Revert.
  • Sidebar: master list of methods. Click a row to load it. The three buttons up top scope to “the entire methods map” (add a new one, duplicate the selected one, delete the selected one).
  • Detail pane: seven tabs editing one method’s contents at a time. The next to the method ID lights up when the in-tab form has unapplied edits.
  • Apply button: pushes the current tab’s edits to the server stage via tis.put_method. Until you hit Apply, your typing is in the browser only.

The two-step (“Apply, then Save”) is intentional — Apply runs server-side validation per method, Save runs the full whole-config validation and writes to disk. You get fast per-method feedback and a single atomic persist.

Walking through the tabs

Identity

Two fields: label (pretty name for the Test Method picker) and description (long-form guidance shown below the dropdown). Both are optional — when absent, the picker falls back to the canonical method_id.

Fields

Four field arrays, each rendered by the same component:

ArrayLifecycleOne row per
project_fieldsFilled once by the HMI when the method is selected. Not cycled.Test record (top of test.json)
config_fieldsOperator-input speeds, loads, surface IDs. Snapshotted at start_test.Test record (test.json::config)
cycle_fieldsPer-cycle measurements. Written by the control program each cycle.Cycle (cycles.jsonl line)
results_fieldsPost-test summary (min/max/avg, pass/fail). Written once at finish.Test record (test.json::results)

Click Add field or the row’s pencil icon to open the field dialog. Each TestField has:

  • Name (required) — the canonical wire-format key. Must be a valid identifier. Also becomes the CSV column header.
  • Typestring, i32/i64/u32/u64, f32/f64, bool. The dropdown is editable in case you need a type that’s not in the list yet.
  • Units — display string appended to form labels (e.g., m/s).
  • Label — pretty form label. Falls back to name when empty.
  • Required — gates form validation in <TestSetupForm>.
  • Source — optional gm.<var> binding. When set, the control program reads/writes this GM variable as the field’s storage.
  • Scale — display-time multiplier. display = raw × scale. Storage is always raw; only the HMI display and CSV export apply the scale. Leave blank for 1.0 (no conversion).
  • Description — hover tooltip in the form.

Row reorder (up/down arrows) is purely cosmetic — field order in the form matches array order; on-disk storage doesn’t care.

Views

The named chart definitions. Each entry is a ChartView keyed by a stable view ID (the same key your control program references when emitting cycle data).

The dialog asks for:

  • View ID — stable key like cof_scatter. Must be unique within the method.
  • Typecycle_scatter (one point per cycle, plotted by <TestDataView>) or raw_trace (waveform plot, by <TestRawDataView>).
  • X axis — either a field name (cycle_scatter) or a column name (raw_trace). The dialog auto-switches the input label based on the selected type. Both inputs offer autocomplete from the method’s known fields and raw_data columns.
  • Y series — one or more entries, each with the same field/column picker plus a y_axis: left | right selector for dual-Y charts.

If you reference a field or column that doesn’t exist, the editor flags it as a validation issue immediately — see Validation below.

Raw Data

Toggleable. The header checkbox enables or disables raw data capture entirely; when off, the method writes no raw_data/ blobs and views of type raw_trace won’t render anything.

When enabled, you set:

  • Blob name — base filename under raw_data/. Defaults to trace. Files end up as raw_data/<sample_id>_<n>_cycleNNNN.json.
  • Columns — one row per column. Each carries:
    • The column name (rename in place, blur to commit).
    • The source — one of:
      • time — synthesized linear time axis from the DAQ’s actual_samples and sample_rate. No physical channel.
      • ni.<daq>.channels.<channel_name> — a channel from a NI DAQ capture. The codegen resolves the channel index from project.json’s modules.ni.config.daq[<daq>].channels array.
      • derived — computed at codegen time from other declared columns via the formula field. Supports + - * / with abs(...), sqrt(...), and a top-level ddt(<column>) for the per-sample derivative.
    • The formula — required when source is derived, ignored otherwise. Identifiers reference other columns in the same map (forward references included; derived columns emit in a second pass).

Assets

Edits the method’s asset_refs — AMS dependencies resolved at tis.start_test and snapshotted into test.json::asset_snapshot.<field>.

Each ref carries:

  • Field — key under asset_snapshot (e.g., load_cell_z).
  • Asset type — populated from ams.list_schemas if AmsProvider is upstream. Falls back to a free-form text input otherwise.
  • Selectby_location (asset whose location matches a fixed value) or by_id_field (asset whose ID is read from a config field at start-time).
  • Location or From — surfaces conditionally based on the Select value. Location is an AMS location string (e.g., tsdr); From is a dotted config path (e.g., config.surface_asset_id).
  • Calibration policyignore / warn (default) / require. The start_test resolver emits warnings or hard-errors per this setting if the matched asset’s calibration is missing or expired.

Method-level asset_refs are combined with project-level asset_refs at start_test time. Method overrides win on field-name collisions. If your project declares surface at the project level, you don’t redeclare it here — but you can override its calibration policy by adding the same field name with a stricter setting.

Analysis

Toggleable hook into autocore-python. When enabled:

  • Script — path under autocore-python’s scripts directory.
  • Function — entry-point name within the script.

The codegen emits a run_analysis(ctx) helper on this method’s TestManager that dispatches python.run_analysis with the configured script + function. Whether/when to fire it is up to your control program.

JSON

Always available, every method. Shows the working copy as pretty-printed JSON in a Monaco editor with full syntax highlighting and folding.

This is the escape hatch. Use it when:

  • The form doesn’t (yet) expose a field you need.
  • You want to paste in a method definition from somewhere else.
  • You’re diagnosing a “the form claims this is invalid but I think it’s fine” situation — read the literal JSON.

Edits in the JSON tab feed back into the form tabs in real time (parse errors are shown inline; if it doesn’t parse, the form tabs keep their last-good state). The reverse is also true — form edits update the JSON text. The two views stay in sync.

Validation and the issue badge

Two layers of validation, by design:

  • Client-side, live. Mirrors the server’s validate_method exactly. Runs on every keystroke. Shows up as a red badge in the detail header (“3 issues” — hover for the list). Disables Apply.
  • Server-side, at Apply. Same checks plus the schema deserialization (catches “type” wasn’t a string, etc.). Returns the error list in the IPC response; surfaces in the detail pane.
  • Server-side, at Save. Full whole-config validation across all methods. Catches “your view references a field, the field exists in this method, but it’s the wrong type.” Surfaces in the save dialog with a clear error message.

Checks performed by both layers:

  • Field names are non-empty and unique within each array.
  • views axes reference known field or column names.
  • raw_data.blob_name is non-empty when raw_data is enabled.

Checks performed only server-side:

  • The full TestMethod payload matches the Rust struct (catches type-coercion drift if the schema evolves).

Checks not performed yet (worth knowing — possible Phase-4 work):

  • raw_data.columns sources reference real NI DAQ channels.
  • analysis.script exists on disk.
  • asset_refs.asset_type exists in the AMS catalog (the dropdown helps, but doesn’t enforce).

Save flow: the diff dialog

Clicking Save… doesn’t immediately write to disk. It opens the diff dialog, which:

  1. Fetches a fresh disk-state via tis.list_schemas (the read endpoint that bypasses staging).
  2. Compares to the current staged methods.
  3. Shows a summary: Added / Removed / Modified by method ID.
  4. For modified methods, shows expandable before/after JSON panes (red for before, green for after — line-level diffing is intentionally not done, the JSON dump is enough for “did I actually delete that field by accident?”).
  5. On Confirm, calls tis.save_config. The server writes project.json.bak, then atomically replaces project.json, then clears its stage.

If something goes wrong server-side (validation, write failure, active-test guard), the error surfaces in the dialog — Save remains pending.

Active-test guards

You cannot edit a method while a run is open against it. The guards fire in three places:

ActionServer checkWhat you see
tis.put_methodThe active_tests map contains <project_id>:<method_id> for the method being edited.The Apply button returns an error; the badge shows the message.
tis.remove_methodSame.Same.
tis.save_configANY active run, regardless of method. (A method removal here would orphan an open run schema-side.)The save dialog rejects with the active key list. Operator finishes/cancels the runs and retries.

Conservative by design. Editing the recipe of a test that’s mid-flight is a recipe for inconsistent on-disk data — better to make the operator explicitly finish or cancel.

IPC surface

For integrators who want to drive the editor functions from outside the component (e.g., from a CLI tool or a sync script), the wire contract is:

TopicRequest dataResponse dataSide effects
tis.show_config{ project_id }{ project_id, test_methods, default_method_id, dirty }None
tis.put_method{ project_id, method_id, method }{ status: "added" | "updated", method_id, dirty }Server stage gains the method. Validates first; rejects on schema or cross-field errors, or while a run is active against method_id.
tis.remove_method{ project_id, method_id }{ status: "removed", method_id, dirty }Server stage loses the method. Same active-run rejection. 404 if the method wasn’t in the stage.
tis.discard_config_changes{ project_id }{ status: "discarded", dirty: false }Server stage cleared. Idempotent — succeeds when nothing was staged.
tis.save_config{ project_id }{ status: "saved" | "noop", dirty: false }Atomic write of project.json (+ project.json.bak). Rejected while any run is active anywhere. noop when nothing was staged.

tis.list_schemas (already documented in chapter 15) still reads straight from disk — bypasses staging. Useful when you want the canonical on-disk version, e.g., for the diff dialog or for external diffing tools.

Multi-project posture

projectId is plumbed end-to-end through the React component, the useTisConfig hook, and every IPC payload — but the server currently resolves every value to “the loaded project.” The current single-project binding lives in main.rs where Project::load(&project_path) is called once.

When the time comes for real runtime project switching, the change is isolated to tis_servelet.rs — load alternate project.json files on demand keyed by project_id. The React side already speaks the right contract; no editor rewrite needed.

Gotchas

“My Apply succeeded but the badge says ‘unsaved’.” That’s expected — Apply pushes to the stage; the stage is dirty until you Save. The badge is a config-level dirty indicator, not a per-method one.

“I’m in the JSON tab and the form tabs look stale.” Switch tabs once. The form tabs re-parse the working copy on activation. (If the JSON doesn’t parse, the form tabs keep their last-good state until you fix the JSON.)

“The asset_type dropdown is just a text field.” That happens when no ams.list_schemas response was available — usually because AmsProvider isn’t mounted upstream. The editor degrades to a free-form input rather than blocking. Mount AmsProvider to get the dropdown back.

“I deleted a method by accident, what now?” If you haven’t clicked Save yet, hit Revert — that clears the entire server-side stage, including the deletion. If you have saved, restore from project.json.bak (sitting next to project.json). The editor always writes the backup first; it’s only one save behind.

“Two operators got into the editor at the same time and now I’m confused.” Stage is shared. Whichever one hits Save first wins; the other’s pending edits land too unless someone reverts. Phase-4 enhancement: per-client draft isolation. Until then, treat the editor as single-operator.

“I want to script a bulk schema change.” Skip the editor — talk to the IPC directly. Send a sequence of tis.put_method calls followed by one tis.save_config. Errors surface in each response. The active-test guard still applies.

Charting test data

The cycle and raw-trace charts on the HMI are driven entirely by the views block under each test method in project.json. There are two view types, served by two different components — pick the one that matches the axis of your data, not the chart shape:

typeComponent that renders itx/y refer toBest for
cycle_scatter<TestDataView /> main panelcycle_fields (one point per cycle)Trends across many cycles in a single run — wear-in, drift, COF degradation.
raw_trace<TestRawDataView />raw_data.columns (one point per sample)Per-cycle waveforms — force vs displacement, channel-vs-channel cross-plots.

cycle_scatter: one point per cycle

Lists cycle_fields-bound axes. The control program is responsible for calling record_cycle() (or the per-method add_cycle()) once per cycle; each call appends one row to cycles.jsonl and, if the run is active, broadcasts tis.cycle_added so the chart updates live.

"cof_per_cycle": {
  "title": "COF by Cycle",
  "type":  "cycle_scatter",
  "x": { "field": "cycle_index", "label": "Cycle" },
  "y": [
    { "field": "friction_coefficient", "label": "COF" }
  ]
}

Multi-series with split axes works the same way as raw_trace — y_axis: "left" (default) or "right". Useful when the series have different units; e.g. plotting actual_load (N) on the left axis and friction_coefficient (unitless) on the right.

A cycle_scatter view with cycle_count == 1 per run renders as a single dot. That’s a signal you should be using raw_trace instead — the data you care about lives at sample resolution, not cycle resolution. (Single-cycle test methods can still use cycle_scatter to track averages across many runs in the History tab, but the live chart in TestDataView will look unhelpfully sparse.)

raw_trace: one point per sample

Lists raw_data.columns-bound axes. The whole capture is sent in a single columnar JSON blob (raw_data/<blob_name>.json); axes index into the column map by name.

"loads_vs_time": {
  "title": "All Load Channels over Time",
  "type":  "raw_trace",
  "x": { "column": "t", "label": "Time (s)" },
  "y": [
    { "column": "tsdr_fx", "label": "Fx (N)",  "y_axis": "left"  },
    { "column": "tsdr_fy", "label": "Fy (N)",  "y_axis": "left"  },
    { "column": "tsdr_fz", "label": "Fz (N)",  "y_axis": "left"  },
    { "column": "tsdr_mx", "label": "Mx (Nm)", "y_axis": "right" },
    { "column": "tsdr_my", "label": "My (Nm)", "y_axis": "right" },
    { "column": "tsdr_mz", "label": "Mz (Nm)", "y_axis": "right" }
  ]
}

Cross-plots (Y over X where neither axis is time) are just as easy — point both x.column and the entries in y[*].column at any column declared in raw_data.columns:

"fz_vs_fx": {
  "title": "Normal vs Friction Force",
  "type":  "raw_trace",
  "x": { "column": "tsdr_fx", "label": "Fx (N)" },
  "y": [{ "column": "tsdr_fz", "label": "Fz (N)" }]
}

<TestRawDataView> lazy-fetches the trace blob on mount (or whenever the pinned run changes). Pan, scroll-zoom, pinch-zoom and shift-drag- zoom are wired by default; the “Reset Zoom” button restores the auto range. Charts with multi-axis series automatically split labels into “left axis / right axis” titles.

Mixing both view types in one method

A method can declare both kinds and the components ignore the views they don’t render. Stack <TestDataView /> and <TestRawDataView /> in the same tab to give the operator scatter + waveform side by side:

<div className="vblock">
  <TestDataView />
  <TestRawDataView chartHeight="50vh" />
</div>

The ac-form layout system

Forms in autocore HMIs use one of two complementary CSS class families. Don’t mix them. They have different markup expectations and trying to nest one inside the other gives you alignment that almost works but doesn’t quite — the trap that bit ManualControlView.

.ac-form + .ac-form-row — flexbox, row by row

A vertical flex column where each child is its own horizontally-laid-out row. Easiest to reason about; columns aren’t aligned across rows.

<div className="ac-form">
    <div className="ac-form-row">
        <span className="ac-form-label">Send X</span>
        <InputNumber className="ac-form-field" ... />
        <Button label="GO!" />
    </div>
    <div className="ac-form-row">
        <span className="ac-form-label">Send Y</span>
        <InputNumber className="ac-form-field" ... />
        <Button label="GO!" />
    </div>
</div>

When to use it:

  • Rows have different shapes (different widget types, different counts of cells)
  • You don’t care that “Send X”’s input doesn’t line up vertically with “Send Y”’s
  • You want a quick form without thinking about a grid

CSS recap (from themes/adc-dark/_extensions.scss):

.ac-form {
    display: flex;
    flex-direction: column;
    gap: 10px;
}
.ac-form-row {
    display: flex;
    flex-direction: row;
    align-items: center;
    gap: 8px;
}
.ac-form-field { flex: 1; min-width: 0; }
.ac-form-label { font-weight: 600; flex-shrink: 0; }

.ac-form-grid — CSS Grid, columns aligned across rows

A 4-column CSS Grid. Children flow into cells row-by-row, no row wrappers. Every label sits in column 1, every input in column 2, every units/suffix in column 3, every validity-check icon in column 4. Cross-row alignment is automatic.

<div className="ac-form-grid">
    <span className="ac-form-label">Send X</span>
    <InputNumber ... />
    <span>{positionUnits}</span>
    <i className="pi pi-check" />          {/* validity */}

    <span className="ac-form-label">Send Y</span>
    <InputNumber ... />
    <span>{positionUnits}</span>
    <i className="pi pi-check" />
</div>

When to use it:

  • Multiple rows of similar shape (data entry, parameter setup)
  • Labels and inputs must align vertically (it’s how operators scan a form)
  • You have a “validity check” or “info icon” cell — the 4th column was designed for this

CSS recap (from themes/theme-base/_common.scss):

.ac-form-grid {
    display: grid;
    grid-template-columns: auto 1fr auto auto;   // label | input | units | check
    gap: 16px 24px;
    align-items: center;
    max-width: 600px;
}

Override the column template when 4 isn’t right

For a 3-column form (label, input, action button), override gridTemplateColumns inline:

<div className="ac-form-grid"
     style={{ gridTemplateColumns: 'auto 1fr auto' }}>
    <span className="ac-form-label">Send X</span>
    <InputNumber ... />
    <Button label="GO!" />
    ...
</div>

<TestSetupForm /> does this for its 4-column case where the per-field columns are: label, input, info-icon, validity-check. See its gridStyle declaration for the pattern.

Spanning cells

When a row needs to span multiple columns — a section header, a long description, a wide note — use one of the span helpers:

ClassEffect
.ac-form-sectionSpans all columns. Renders as a small uppercase header with an underline. Use for “Test Configuration”, “Network Settings”, section dividers.
.ac-form-wideSpans all columns. No styling — for content that shouldn’t look like a section header.
.ac-form-spanSpans from column 2 to the end (skips the label column). Useful for descriptive text under an input that aligns with the input rather than the label.
<div className="ac-form-grid">
    <h3 className="ac-form-section">Press Configuration</h3>

    <span className="ac-form-label">Pre-load</span>
    <InputNumber ... />
    <span>N</span>
    <i />

    <p className="ac-form-span">
        Force applied before the cycle begins. Set near zero for unloaded tests.
    </p>

    <h3 className="ac-form-section">Cycle</h3>
    ...
</div>

<h3 className="ac-form-section"> is the single most common element after labels — every form on the HMI uses it for visual structure.

The big mistake: nesting

Don’t put .ac-form-row inside .ac-form-grid:

{/* DON'T do this */}
<div className="ac-form-grid">
    <div className="ac-form-row">                    {/* ← entire row collapses */}
        <span className="ac-form-label">Send X</span> {/*    into a single grid cell  */}
        <InputNumber ... />
        <Button label="GO!" />
    </div>
    ...
</div>

The grid sees the <div class="ac-form-row"> as one child, places it in column 1 of row 1, and lets the inner flex layout sort itself out. It looks close to right but the columns aren’t aligned across rows because each row’s cells live inside their own flex container, not in the grid’s columns.

Pick one system per form. If you need cross-row alignment, use the grid with direct children. If you need per-row independence, use the flex form.

ac-toolbar — icon-button toolbars

For operator toolbars (PANIC, motor power, axis status), use the ac-toolbar-* classes. They ensure consistent square button sizing and SVG-icon rendering weights across mixed icon sources (Lucide SVGs, PrimeIcons, custom).

ClassSizeUse for
.ac-toolbar-icon-btn~19mm × 12.7mm (compact)Standard toolbar action buttons
.ac-toolbar-icon-lg~25.4mm × 19mmLarger toolbar actions where touch target matters
.ac-toolbar-icon-panic25.4mm × 25.4mmThe PANIC button specifically — squarer, larger, severity=danger
.ac-toolbar-groupflex row, gap: 2pxGroup of related buttons (motor on/off cluster, etc.)
.ac-toolbar-tool-listflex columnStacked text-button list inside an OverlayPanel
.ac-toolbar-tool-itemleft-justifiedSingle text button inside a tool list

The toolbar classes also normalize SVG icons so Lucide icons (<PanelBottomOpen />) render at the same visible weight as PrimeIcons (<i class="pi pi-power-off" />). See the ac-toolbar-svg-normalize mixin in _extensions.scss for what gets applied.

Theme overrides

@adcops/autocore-react ships with the adc-dark theme as the default visual style. To override:

  1. Create a project-local SCSS file.
  2. Import it after the autocore-react theme so your rules win cascade ties (or use higher specificity).
  3. Match PrimeReact’s selector specificity when overriding their components — .p-dialog .p-dialog-content beats .p-dialog-content.

Common overrides land in the project’s CSS rather than the package — for single-machine customization (custom logo, brand colors). For shared overrides used across multiple HMIs, contribute back to autocore-react/src/themes/adc-dark/_extensions.scss.

A worked example of an override that DIDN’T work the first time, and why, appears in the autocore-react commit history under “Dialog padding never applied” — TL;DR: PrimeReact’s nested-class selectors silently outrank a single-class override, and you have to match their specificity to win.

Hooks reference (cross-context)

These belong to specific providers but are useful to know exist:

HookFromReturns
useAutoCoreTag(name)AutoCoreTagProvider{ value, write, tap } for a GM tag
useTis()TisProviderfull TIS context (selection, schemas, stagedConfig, …)
useTisSelection()TisProvider[selection, setSelection] tuple
useTisSchemas()TisProviderthe test method registry
useAms()AmsProviderfull AMS context
useAmsSchemas()AmsProviderthe asset_type registry
useAmsRoles()AmsProviderroles per asset_type (from project.json’s by_location asset_refs)
useAmsAssets()AmsProviderthe live asset list, sorted/filtered already
useAmsSelection()AmsProvider[selection, setSelection] for assetType/assetId pinning
useMemoryStore(name)core hooks{ value, write, refresh } for a memory-store entry
useGnv(group, key)core hooks{ value, write, refresh } for a gnv key

See chapter 10 for usage examples on the connection-layer hooks.

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 new-tis-project <name>Create a new project pre-wired with TIS (<TisProvider> + tick_with_autostart).
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

Subsystem Retrofit

Two idempotent commands flip subsystems on for an existing project:

CommandDescription
acctl add-tisAdds an empty test_methods: {} block to project.json so Project::normalize() injects the nine tis_* GM scalars next time codegen runs. See Chapter 15.
acctl add-amsAdds an empty asset_types: {} block to project.json so the three baseline ams_* GM scalars are injected and <AmsProvider> has something to mount against. See Chapter 16.
acctl add-axis --name <N> [--link <slave>] [--type pp] [--backend ethercat|virtual]Adds a CiA-402 axis to project.json. EtherCAT axes (--link <slave>) go in modules.ethercat.config.axes; --backend virtual creates a fieldbus-less simulated axis in modules.motion.config.axes. Idempotent on --name. Run acctl codegen afterward. See Chapter 8b.

Run them in any order on a project that started without those subsystems; re-running on a project that already has them is a no-op that prints “already enabled” and exits 0.

AMS Backup / Restore

CommandDescription
acctl ams export --output <file.json>Pull the full Asset Management System dataset (registry + per-asset history + usage) from the server into a single JSON document.
acctl ams import --input <file.json> [--dry-run]Apply an exported document to the current server. Default behaviour merges (preserves IDs, appends calibrations, takes max-of usage counters). --dry-run previews changes.

See Chapter 16 for the export shape and merge semantics.

Project Inspection

CommandDescription
acctl infoShow a human-readable project summary (modules, variables, control program, www status)
acctl validateCheck project.json for errors. Runs both the local cheap checks (syntax, variable types, duplicate names, broken links) and — if a server is reachable — the full server-side validator (AMS placeholder resolution, AMS registry/asset integrity, module schema).
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` runs two passes:

**Local pass** (always runs):
- 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 pass** (when a server is reachable — `system.validate_project` IPC command):
- `project_schema` — the file deserialises as a `Project`
- `module_schema` — each module entry has valid `enabled` / `args` / `executable` shape
- `ams.placeholder` — every `${ams.*}` placeholder anywhere in the project resolves against the server's AMS registry. **Warning-severity** (see "Severities" below).
- `ams.registry`, `ams.asset`, `ams.calibration` — the on-disk AMS state itself is well-formed
- `ams.asset_type`, `ams.asset_ref` — project asset_type entries don't shadow built-ins and asset_refs target known types
- `variable_duplicate` — two or more variables share the same hardware `link` (mirrors `acctl dedup-vars`)
- `variable_link_shape` — non-empty `link` values look like a dotted FQDN

If no server is reachable, the server pass is skipped with a `Note:` line and only the local pass counts. Connect to a server (`acctl set-target …`) before relying on this for AMS-aware checks.

The same server-side validator runs automatically as a pre-flight on:
- `acctl sync` when you push your local file to the server — error-severity findings block the push; warnings are shown and the push proceeds.
- `acctl codegen` before any code is generated — broken AMS placeholders don't get baked into `gm.rs`.

#### Severities

Each finding carries one of two severities:

- **`error`** (default for nearly every category) — blocks `acctl sync push` and `acctl codegen`. Indicates a structural problem (malformed JSON, broken schema, missing AMS registry, duplicate IDs, cross-module link typos).
- **`warning`** — surfaces in acctl output but does not block. Currently only `ams.placeholder` emits at this severity. The reasoning is that an unresolved AMS placeholder reflects asset-record state that the operator fixes through the AIS UI; blocking sync would leave them with no way to land the project the UI needs.

The `acctl sync` push output partitions findings:

Project validation warnings (not blocking):

ams.placeholder (18) /modules/ni/config/tasks/0/channels/0/create_args/max_val asset LC-... at location tsdr1 has no field capacity (looked under custom.capacity) …

Project validation: OK (18 warning(s); fix in the AIS UI when convenient)


A mix of warnings + errors prints warnings first in yellow, errors below in red, and exits non-zero on the errors.

> **Why warnings exist.** Before this split, a stale asset (missing
> field, wrong type) blocked the project from landing at all. The
> operator would need to edit asset.json by hand on the server to
> recover. Now the project lands, the AIS UI on the (now-up) server
> exposes the bad asset, the operator fixes it there. The module
> supervisor still refuses to spawn modules whose configs contain
> unresolved placeholders at runtime — `UnresolvedAmsPlaceholders` —
> so the eventual hardware path is still gated on the asset being
> correct. The warning surfaces the same information, just earlier
> and without locking the operator out of the UI.

#### 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 push assets` | `--no-reinit` | Publish local AMS data (`datastore/assets/` — registry, per-asset records, calibration history, usage) to the server. AMS records are machine-local, so `acctl sync` never auto-pushes them; this is the deliberate publish path. Calls `ams.reinitialize` after upload so the running server reloads from disk; `--no-reinit` skips that (restart the server yourself). |
| `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. Also scaffolds axis output/option variables (see Chapter 8b) and, before generating, checks that control/Cargo.lock’s autocore-std is new enough for the codegen output — aborting with the exact cargo update --precise to run if it’s too old, rather than letting you hit a cryptic Rust error.
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 (pull, push, or skip per-section; auto-runs codegen after), then pull the critical datastore files: autocore_gnv.ini and assets/.
acctl sync allSame project.json reconcile, then a full mtime-wins sync of the entire datastore/ tree (excluding results/). datastore is accepted as an alias for all.
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.

The plain acctl sync deliberately skips the bulk of the datastore (captures/, scripts/, …): on long-running projects those grow to hundreds of files and make every sync slow — especially over remote tailscale links — when usually you only want the project file and the machine-critical state backed up. Run acctl sync all when you do want the whole tree; transfers are batched into ~8 MiB requests so large datastores no longer overflow a single websocket message.

acctl sync all reconciles the datastore/ directory mtime-wins, but two kinds of file are pull-only — sync brings a fresher server copy down but never pushes the local copy up:

  • datastore/autocore_gnv.ini — non-volatile values written by the running control program. Restore deliberately with acctl push gnv.
  • datastore/assets/ — AMS asset and calibration records. These are machine-local (the transducer actually installed in this machine, its cert history, usage counters); the shared project.json is the same on every machine but this data is not. Auto-pushing would let one machine overwrite another’s assets on the shared server. Publish deliberately with acctl push assets.

results/ is excluded from sync entirely (pull deliberately with acctl pull-results). Everything else under datastore/ (e.g. scripts/, captures/) syncs both ways under acctl sync all.

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

Managing Tools and Editors

Tools and editors (such as labelit-studio) are registered with the server through the tool registry. acctl can list them and trigger a live rescan — see Tools and Editors for the full picture.

CommandDescription
acctl tools listList registered tools with running state, URL, and the module domains each edits
acctl tools rescanRe-read the registry and start/stop service tools without a server restart
acctl tools list
acctl tools rescan   # package install/uninstall scripts call this automatically

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

Two spawn paths: supervisor vs. ad-hoc

The server has two places that can launch a module process, and the difference matters:

  • Module supervisor (ModuleSupervisor::start_module). Spawns every module listed in project.json::modules, in order, on server startup. Resolves ${ams.*} placeholders against the AMS registry first; if any are unresolved, the spawn is refused and the module is marked Failed. All three CLI args are passed, including --config <resolved-json>.
  • Ad-hoc (system.load_module). Bootstraps a module that is registered in config.ini::[modules] but NOT declared in project.json. Only two CLI args are passed: --ipc-address and --module-name. No --config — by design, because there’s nothing in project.json to serialise.

A module that’s launched via the ad-hoc path receives no project configuration. That’s the right behaviour for system.load_module’s intended use (bootstrap before the module is in project.json), but it was historically the wrong behaviour as a fallback: if the supervisor refused a project-declared module, an ad-hoc launch would silently substitute a configless process, and <module>.status would report empty arrays with no obvious cause.

Current behaviour: system.load_module refuses any name that exists in project.json::modules and returns an error pointing at the supervisor log and acctl validate. The error reads:

Module ‘X’ is declared in project.json — refusing ad-hoc launch. If the supervisor refused to start it, check the server log for the reason (commonly UnresolvedAmsPlaceholders) and run acctl validate against this project.

Validating before spawn: system.validate_project

The server exposes system.validate_project as a read-only IPC command that runs the same checks the supervisor would apply at spawn time, plus all the AMS-entry integrity checks. acctl wires this into acctl validate, acctl sync (push), and acctl codegen so bad files never reach module-spawn time. See Chapter 12 for the categories of errors it reports.

Request:

{ "topic": "system.validate_project",
  "data": { "project_json": <Value>?, "module": "<name>"? } }
  • project_json (optional): validate this exact content instead of the currently-loaded file. acctl sends the local file here before pushing.
  • module (optional): scope AMS placeholder reporting to /modules/<name>. AMS-entry and cross-module checks still run.

Response:

{ "ok": true,
  "errors": [
    { "category": "ams.placeholder",
      "severity": "warning",
      "path": "/modules/ni/config/tasks/0/channels/8/create_args/scaled_max",
      "message": "no active asset registered at location `tsdr`",
      "extra": { "placeholder": "${ams.by_location.tsdr.sub.my.capacity}" } }
  ] }

Each finding carries a severity of "error" (default, blocks acctl sync push) or "warning" (surfaces in acctl output and the AIS Placeholder Health panel but does not block sync). The top-level ok is true when no error-severity findings are present — warnings alone do not flip it false. ams.placeholder is the only category currently emitted as a warning; everything else (json, module_schema, ams.registry, ams.calibrations, cross-module variable / link checks) stays as error.

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

Tools and Editors

AutoCore modules expose their configuration as a JSON Schema, and the IDE renders that as a form. Some modules need more than a form — the labelit vision module, for example, needs an image viewer with edge-detection overlays, draggable scan regions, and live placement preview. Those richer editors, and standalone utilities in general, are delivered as tools.

A tool is a standalone program — usually a small web server — that the platform knows about through the tool registry. The registry lets:

  • autocore-server supervise long-running tools (start them with the server, stop them on shutdown, restart them on demand);
  • autocore-ide discover which module domains have a rich editor and offer an Open editor action for them;
  • packages register and unregister tools automatically on install/remove.

The first tool is labelit-studio, the configuration editor for the vision (labelit) module. This chapter explains the registry, how to operate and configure tools, and how to add new ones.

Tools versus modules

ModuleTool
Connects to the server over IPCYesNo
Receives its config on the command lineYes (--config)No
Participates in the control loopYesNo
Typically a…hardware/service driverweb UI / editor / utility
Discovered fromproject.json modulesthe tool registry (tools.d/)
Examplesmodbus, ethercat, ni, labelitlabelit-studio

A tool is independent of any one project — its editor is available even when no project is loaded. The vision module (labelit) runs the camera and label placement; the vision tool (labelit-studio) edits that module’s config.

The registry on disk

Everything lives under the AutoCore config directory (/srv/autocore/config on a deployed machine; AUTOCORE_CONFIG_DIR overrides it):

/srv/autocore/config/
├── config.ini                          # server + module config
├── tools.d/                            # MANIFESTS — owned by the installing package
│   └── labelit-studio.json
└── tool-settings/                      # SETTINGS — runtime, survive upgrades
    └── labelit-studio.json

Two ideas matter:

  1. The manifest is package-owned; the settings are not. A manifest describes what a tool is (its executable, what it edits, defaults) and is shipped inside the .deb, so an upgrade replaces it freely. Settings are what an admin chose (the actual port, enabled/disabled, bind address) and live in a separate directory the package never touches, so upgrades never reset a field-configured port.
  2. dpkg owns the manifest file. A package contributes a tool simply by shipping its manifest into tools.d/. Installing the package registers the tool; removing it deregisters — handled by the package manager, with no shared file to edit.

So there are three distinct kinds of config; don’t confuse them:

ScopeLives inWho writes it
Module operational configproject.jsonmodules.<name>.configthe editor / the IDE
Tool manifesttools.d/<tool>.jsonthe installing package
Tool settingstool-settings/<tool>.jsonadmin / the tool

Manifest format

{
  "schema_version": 1,
  "name": "labelit-studio",
  "version": "1.1.3",
  "description": "Vision-pipeline configuration editor for autocore-labelit",
  "executable": "/opt/autocore/bin/modules/labelit-studio",
  "serves_http": true,
  "ui_path": "/",
  "launch": {
    "mode": "service",
    "default_port": 7878,
    "autostart": true,
    "args": []
  },
  "editors": [
    { "target_domain": "labelit", "target_path": null, "label": "Vision Editor" }
  ]
}
FieldMeaning
nameUnique tool id (kebab-case); must match the file stem.
executableAbsolute path to the binary.
serves_http / ui_pathWhether it serves a web UI, and the root path. Drives the security gate and lets a browser/IDE point at it.
launch.modeservice (long-running; the server supervises it) or on_demand (invoked by the IDE/CLI; never auto-started).
launch.default_portDefault HTTP port; the effective port comes from settings.
launch.autostartDefault for starting with the server; effective value comes from settings.
launch.argsExtra arguments inserted before the standard --port/--bind.
editors[]The config editors the tool contributes. Each binds to a module target_domain and an optional target_path (a JSON pointer into that module’s config). null path = the whole module config. One tool may contribute several.

Settings format

{
  "launch": {                 // the SERVER reads exactly these fields
    "enabled": true,
    "port": 7878,
    "bind": "127.0.0.1",
    "autostart": true
  },
  "extra": {                  // free-form, tool-private; the server never parses it
    "last_image_dir": "/home/me/snaps"
  }
}

The file is created on first run, seeded from the manifest’s launch defaults. After that, on-disk values win — so an admin’s port choice survives upgrades.

Security gate. A serves_http tool is an unauthenticated surface (it can edit config and, on a target, trigger the camera). Until AutoCore adds user authentication, bind defaults to 127.0.0.1 (loopback only). Binding a tool to a routable address is a deliberate admin choice — prefer reaching it over Tailscale rather than exposing it on the LAN.

How the server runs tools

On startup the server scans tools.d/. For every manifest with launch.mode = "service" whose settings have it enabled with autostart, it launches:

<executable> [launch.args...] --port <settings.port> --bind <settings.bind>

and stops it on shutdown — the same lifecycle as a module, but discovered from the registry instead of project.json. on_demand tools are not auto-started.

Two server commands manage this at runtime (also reachable with acctl, below):

CommandEffect
system.list_toolsList registered tools with live running state and URL. Used by the IDE to discover editors.
system.rescan_toolsRe-read the registry: start newly installed service tools, stop removed/disabled ones, leave the rest running.

acctl tools

acctl tools list      # what the server discovered, with running state + URL
acctl tools rescan    # reconcile running tools with the registry, no restart

acctl tools list prints each tool, whether it is running, its URL, and the module domains it edits. acctl tools rescan is what package scripts call so an install or uninstall takes effect on a running server without a restart.

Registering a tool from a package

A .deb registers a tool with two pieces:

  1. Ship the manifest as a packaged file into tools.d/. In Cargo.toml (cargo-deb):

    [package.metadata.deb]
    assets = [
        ["target/release/labelit-studio", "/opt/autocore/bin/modules/", "755"],
        ["packaging/tools.d/labelit-studio.json",
         "/srv/autocore/config/tools.d/labelit-studio.json", "644"],
    ]
    

    Because dpkg owns the file, install registers and remove deregisters — no maintainer-script logic needed for the registration itself.

  2. Poke a running server so the change is live, from postinst/postrm:

    poke_rescan() {
        for ACCTL in acctl /opt/autocore/bin/acctl; do
            if command -v "$ACCTL" >/dev/null 2>&1; then
                "$ACCTL" tools rescan >/dev/null 2>&1 || true
                return 0
            fi
        done
    }
    

    This is best-effort: if the server is down or acctl is absent, the tool is picked up on the next server start anyway.

labelit-studio: the worked example

labelit-studio is a web-based editor for the vision pipeline — it loads images, runs the exact production pipeline the labelit module runs, shows each stage (edges, masks, placement) with overlays, has TM-X-style tools (Canny, threshold, HSV mask, pixel probe), drag-to-set scan/background regions, and the calibration wizards (checkerboard, known-size, auto-canny). What it shows for a given config is, by construction, what the module will do with that config.

It runs in three contexts — the same binary every time:

1. Standalone (laptop / conference demo)

No server, no project. Build it (or install the .deb) and run it against local images:

# from an autocore-labelit checkout
cargo build --release --bin labelit-studio
./target/release/labelit-studio --config project.json --images ./snaps --port 7878
# then open http://localhost:7878/

--config points at any project.json (or a module-level config export); --images is a file or folder; you can also open files from inside the UI. Use --bind 0.0.0.0 only if you want another device on the network to reach it.

2. On a target (server-supervised)

Install autocore-labelit on the machine. The deb ships the binary and its manifest, and the running server starts it automatically (loopback, port 7878 by default). An operator with only a browser opens it:

http://<target>:7878/

To change the port, enable/disable it, or bind it wider, edit /srv/autocore/config/tool-settings/labelit-studio.json and run acctl tools rescan (or restart the server). Remember the security gate before changing bind.

3. In the IDE (autocore-ide)

Open your project in VS Code / VS Codium. In the autocore Project view, the labelit module shows an $(preview) Open editor action. It spawns labelit-studio as a local sidecar against your workspace project.json and hosts its UI in a panel — fully offline, no server needed. The IDE remains the sole writer of project.json (it preserves your comments and formatting). See the autocore-ide manual for details and the autocore.labelit.studioPath / autocore.labelit.imagesDir settings.

Building a self-contained studio

For a binary that runs on a clean machine with no apt install (the field .deb, or bundling into the IDE .vsix), labelit-studio is built camera-free and against a static OpenCV:

  • The studio never opens a camera directly (on a target it snaps through the module over IPC), so Aravis/GLib is behind the default-on camera feature and is dropped with --no-default-features.
  • A minimal static OpenCV removes the libopencv runtime dependency.
# in the autocore-labelit repo
OPENCV_VERSION=4.10.0 PREFIX=/opt/opencv-static ./scripts/build-static-opencv.sh
PREFIX=/opt/opencv-static ./scripts/build-studio-selfcontained.sh
# -> target/release/labelit-studio, ldd shows only glibc / ld-linux

The full recipe (and how to wire the result into the deb and the vsix) is in the autocore-labelit repo’s doc/static-build.md.

Adding a new editor

To give another module (say an EtherCAT slave-tree editor) a rich editor:

  1. Build the tool as a standalone HTTP server that accepts --port and --bind. (It can be anything — the registry only cares about the manifest.)
  2. Write a manifest declaring serves_http: true, launch.mode: "service", and an editors entry whose target_domain is the module domain it edits.
  3. Ship it in the tool’s package: the binary plus the manifest as a dpkg-owned file in tools.d/, and a postinst/postrm rescan poke.
  4. (Optional) Teach the IDE about it offline by adding it to the IDE’s bundled fallback list, so the Open editor action appears without a live server connection.

The server will discover and supervise it; the IDE will offer it for that module domain; acctl tools list will show it. No changes to autocore-server or autocore-ide core are required to add a tool.

Test Information System

The Test Information System (TIS) is AutoCore’s unified pipeline for collecting, storing, and exporting test data across the control program and the HMI. You declare your test schema once in project.json, and the server hands the control program a typed Rust struct, the HMI a typed TypeScript schema, and a four-tier filesystem layout that’s the same on every project.

Historical note. TIS was originally called the “Standardized Results System.” Old IPC topics (results.*), generated structs (ResultsSystem), and GM variables (results_*) were renamed wholesale during the rebrand. The original reference prose has been moved to the appendix at the bottom of this chapter for historical context — everything before the appendix is the current contract.

The four-level hierarchy

Every recorded test is identified by four canonical keys. The same names are used in the wire format, the generated code, the disk layout, and the HMI:

LevelUser-facing nameWire/code keyWhat it is
1Projectproject_idThe customer, contract, or product line. Owns many tests.
2Test Methodmethod_idThe standardised recipe: schema, chart views, validation rules. Defined in project.json under test_methods.
3Samplesample_idThe physical object in the machine right now. Operator types it on the setup form; required.
4Test Recordrun_idA single test event. Auto-generated ISO timestamp when tis.start_test fires.

On disk:

datastore/results/
└── <project_id>/
    └── <method_id>/
        └── <run_id>/
            ├── test.json          ← includes sample_id at the top level
            ├── cycles.jsonl
            ├── raw_data/<blob>.json
            └── filtered_data/<blob>.json

test.json:

{
  "project_id":  "plant_a",
  "method_id":   "translational_traction",
  "run_id":      "20260422T140231Z",
  "start_time":  "2026-04-22T14:02:31Z",
  "sample_id":   "SAMPLE-0042",
  "config":      { "specimen_notes": "…", "control_load": 1000 },
  "results":     { "avg_cof": 0.50, "max_cof": 0.55, "min_cof": 0.45 }
}

sample_id lives at the top level — peer of project_id, method_id, and run_id — because it’s part of the run’s structural identity, not a user-pickable config field.

Target integration workflow

Adding TIS to a new project takes four steps, no hand-wiring:

  1. Declare the test methods in project.json:

    {
      "test_methods": {
        "translational_traction": {
          "project_fields":  [ { "name": "customer", "type": "string", "required": true } ],
          "config_fields":   [ { "name": "control_load", "type": "f32", "units": "N" } ],
          "cycle_fields":    [
            { "name": "cycle_index", "type": "u32", "source": "gm.cycle_count" },
            { "name": "actual_load", "type": "f32", "source": "gm.zforce_load" }
          ],
          "results_fields":  [ { "name": "avg_load", "type": "f32" } ],
          "raw_data": {
            "blob_name": "trace",
            "columns": {
              "t":     { "source": "time" },
              "force": { "source": "ni.traction.channels.tsdr_fz" }
            },
            "units": { "t": "s", "force": "N" }
          },
          "views": {
            "load_per_cycle": {
              "type": "cycle_scatter",
              "x": { "field": "cycle_index", "label": "Cycle" },
              "y": [ { "field": "actual_load", "label": "Load (N)" } ]
            }
          }
        }
      }
    }
    
  2. Generate the typed code:

    acctl codegen-tags
    

    This regenerates control/src/gm.rs (with TestInformationSystem plus one *TestManager per method) and www/src/autocore/tis.ts (with one *Schema per method).

  3. In the control program:

    #![allow(unused)]
    fn main() {
    pub struct MyProgram {
        tis: TestInformationSystem,
        daq: DaqCapture,
    }
    
    impl ControlProgram for MyProgram {
        type Memory = GlobalMemory;
        fn process_tick(&mut self, ctx: &mut TickContext<Self::Memory>) {
            if let Some(_method) = self.tis.tick_with_autostart(ctx) {
                // first tick of a new run; initialise per-run state here
            }
            // ... run the cycle ...
            if let Err(e) = self.tis.translational_traction.record_raw_trace(&self.daq, ctx) {
                log::warn!("record_raw_trace failed: {}", e);
            }
            self.tis.record_cycle(ctx);
            self.tis.end_active(ctx);
        }
    }
    }
  4. In the HMI:

    <TisProvider>
      <TabView>
        <TabPanel header="Setup">   <TestSetupForm />        </TabPanel>
        <TabPanel header="Data">    <TestDataView />         </TabPanel>
        <TabPanel header="History"> <ResultHistoryTable />   </TabPanel>
      </TabView>
    </TisProvider>
    

That’s the entire surface area. No projectId props, no GM tag indirection, no manual stage_test wiring. acctl new-tis-project <name> lays this skeleton down for you in one command.

Lifecycle in detail

Three IPC verbs drive the lifecycle from the HMI side:

  • tis.stage_test — Operator filled in the form, the form is valid, the rig is ready. Payload: { project_id, method_id, sample_id, config }. Stage is persistent: a successful start_test does NOT consume it. The operator can run sample after sample by changing only sample_id and clicking Start. Cancel via tis.clear_staged.

  • tis.start_test — Operator (or the control program) opens a record on disk. Server creates test.json + the empty cycles.jsonl + raw_data/ + filtered_data/ directories. Sets tis.active = true and broadcasts the four tis.active_* scalars. The control program’s tick_with_autostart does this for you.

  • tis.finish_test — Closes the record. Flips tis.active = false. The control program’s end_active(ctx) calls this.

Auto-injected GM scalars (added by Project::normalize() at server load when test_methods is non-empty):

GM variableLinked toType
tis_stagedtis.stagedbool
tis_staged_project_idtis.staged_project_idstring
tis_staged_method_idtis.staged_method_idstring
tis_staged_sample_idtis.staged_sample_idstring
tis_activetis.activebool
tis_active_project_idtis.active_project_idstring
tis_active_method_idtis.active_method_idstring
tis_active_sample_idtis.active_sample_idstring
tis_active_run_idtis.active_run_idstring

These nine scalars are the contract between the control program and the server’s lifecycle state. Don’t add them to project.json yourself — normalize() will inject them, and they’re typed Internal (not bound to any hardware).

Filtered data mirrors raw data

Every test method that declares a raw_data block automatically gets a parallel filtered_data blob with the same columns and the same blob name. The server creates both directories on start_test, and exposes symmetric IPC verbs:

RawFiltered
tis.add_raw_datatis.add_filtered_data
tis.read_rawtis.read_filtered
tis.list_rawtis.list_filtered

Codegen emits record_raw_trace(daq, ctx) and set_filtered_trace(col_a, col_b, ..., ctx) per TestManager. The control program normally ships raw_data; a post-processing step (inline, Python, or offline) writes filtered_data. Absent filtered files are non-errors — consumers degrade gracefully.

raw_data.columns — typed channel sources

raw_data.columns is a map of column_name → { source }. The codegen reads source and emits the right channel pull for each:

  • "time" — synthesizes a linear time axis from data.actual_samples and data.sample_rate (no DAQ channel needed).
  • "ni.<daq>.channels.<chan>" — looks up the channel index in project.json’s modules.ni.config.daq[<daq>].channels array and emits a typed pull from DaqCapture.

A single record_raw_trace(daq: &DaqCapture, ctx) per TestManager ships the whole columnar blob:

#![allow(unused)]
fn main() {
match self.tis.active_test {
    Some(TestType::TranslationalTraction) =>
        self.tis.translational_traction.record_raw_trace(&self.daq, ctx),
    Some(TestType::RotationalTraction) =>
        self.tis.rotational_traction.record_raw_trace(&self.daq, ctx),
    None => Ok(()),
}
}

Failures (DAQ not ready, channel missing, schema error) return RawTraceError; the control program decides whether to log, retry, or surface the failure on the HMI.

The legacy string-array form of raw_data.columns (e.g., ["t", "force"]) is rejected at parse time. Migrate every column to the explicit { source: "..." } form.

A third source exists for columns computed from other columns — see the next section.

Derived columns

source: "derived" lets a column be computed from other declared columns at trace-write time. The control program does not need any extra wiring: the codegen emits the math into record_raw_trace alongside the primitive columns, and the resulting blob looks identical on disk and over the wire.

Declare a derived column with a formula field:

"raw_data": {
  "blob_name": "trace",
  "columns": {
    "t":        { "source": "time" },
    "tsdr_fx":  { "source": "ni.traction.channels.tsdr_fx" },
    "tsdr_fz":  { "source": "ni.traction.channels.tsdr_fz" },
    "enc_x":    { "source": "ni.traction.channels.enc_x" },
    "cof":      { "source": "derived", "formula": "abs(tsdr_fx) / abs(tsdr_fz)" },
    "velocity": { "source": "derived", "formula": "ddt(enc_x)" }
  }
}

Grammar

FormMeaning
<col>Bare reference to another column in the same columns map.
<number>f32 literal (1000.0, -2.5e3, …).
<expr> + <expr> / - / * / /Element-wise arithmetic.
-<expr>Unary negation. Same as neg(<expr>).
abs(<expr>)f32::abs.
sqrt(<expr>)f32::sqrt.
neg(<expr>)Unary negation.
ddt(<col>)Top-level only. Per-sample derivative (col[i] - col[i-1]) / dt, where dt = 1 / sample_rate. The first sample of the trace seeds to 0.0. The argument must be a bare column reference — nested expressions inside ddt are not supported.

Common formulas

"abs(tsdr_fx) / abs(tsdr_fz)"               # Coefficient of friction
"ddt(enc_x)"                                # Linear velocity from position
"ddt(enc_c)"                                # Angular velocity from angle
"sqrt(tsdr_fx*tsdr_fx + tsdr_fy*tsdr_fy)"   # In-plane force magnitude
"tsdr_fx / 1000.0"                          # Newtons → kN

Safety guarantees

  • Divide-by-zero is guarded. Every / in a formula is wrapped at codegen time as if rhs.abs() > f32::EPSILON { lhs / rhs } else { 0.0 }, so traces don’t go to NaN/Inf at discontinuities — operators see a zero on the chart and can reason about it.
  • References are validated at codegen time. Unknown column names fail with a clear “declare it first” error rather than at runtime.
  • No self-references and no forward derived→derived references. A derived column may reference any primitive column (Time or NI channel) regardless of declaration order; a derived column that references another derived column must sort alphabetically after it (the codegen emits primitives first, then derived columns in iteration order). Rename or restructure if you hit the ordering error — chains of more than two derived columns are usually a smell.

Units

Don’t forget to add a units entry for each derived column — the HMI uses it for axis labels and the columnar viewer’s column header:

"units": {
  "cof":      "",
  "velocity": "mm/s"
}

Control program API

The codegen produces one TestManager struct per declared test method plus a unified TestInformationSystem aggregator that dispatches to the active manager. Both live in the generated gm.rs. Every method below is non-blocking — IPC requests are queued on a pending_tids deque and drained on each tick().

TestInformationSystem (aggregator)

The high-level entry point your control loop uses. It owns one <Method>TestManager per declared method and routes calls to the active one. Auto-injected into GlobalMemory when any test_methods block exists.

MethodReturnsPurpose
new()SelfConstruct. Called automatically.
tick(&mut self, client)()Drain pending IPC responses on every manager. Call once per scan cycle.
tick_with_autostart(&mut self, ctx)Option<TestType>tick() + start any staged test whose method_id matches a known manager. Returns Some(TestType) on the tick a new test transitions to active.
try_start_staged_test(&mut self, ctx)Option<TestType>Lower-level: try to start a staged test without first ticking. Use when you need finer control.
record_cycle(&mut self, ctx)()Append one row to cycles.jsonl on the active manager. No-op when no test is active. Only emitted when every method’s cycle_fields are fully source-bound.
record_raw_trace(&mut self, cycle_index, daq, ctx)Result<(), RawTraceError>Ship a DAQ capture as raw_data/<sample>_<blob>_cycleNNNN.json on the active manager.
record_raw_trace_is_busy(&self)boolA trace is being built or sent.
record_raw_trace_is_error(&self)boolMost recent trace ended in error.
record_raw_trace_error_message(&self)&strDiagnostic for the last error, or empty string.
run_analysis(&mut self, ctx)AnalysisDispatchFire the active method’s Python analysis script.
is_analysis_busy(&self)boolAn analysis request is in flight.
is_analysis_error(&self)boolMost recent analysis failed.
analysis_error_message(&self)&strDiagnostic for the last analysis error.
finish_test(&mut self, ctx)()Close the active test record. Equivalent to end_active.
end_active(&mut self, ctx)()Close the active test if any. Idempotent.

<Method>TestManager (per-method)

The codegen emits one of these per test_methods.<name> entry — e.g. TranslationalTractionTestManager. They expose the same methods as TestInformationSystem.record_*, plus a few that only make sense per-method:

MethodReturnsPurpose
new()SelfConstruct.
tick(&mut self, client)()Drain pending IPC responses for this manager.
start_test(&mut self, project_id, client)()Open a new run for this method’s method_id. Normally called by the aggregator’s autostart; reach for this directly only when the control program is driving the lifecycle without the HMI’s stage.
add_cycle(&mut self, ctx)()Append one cycle row, sourced from GM per the method’s cycle_fields.
add_raw_data(&mut self, name, cycle_index, data, client)()Manually push a raw_data blob. Prefer record_raw_trace — this is the escape hatch for hand-constructed payloads.
add_filtered_data(&mut self, name, data, client)()Push a filtered_data blob.
set_filtered_trace(&mut self, col_a, col_b, …, ctx)()Strongly-typed wrapper around add_filtered_data with one argument per declared column. Use this from post-processing code (typically the Python analysis), not from the live control loop.
update_results(&mut self, <results_fields>, ctx)()Strongly-typed wrapper around tis.update_results — one argument per declared results_field.
record_raw_trace(&mut self, cycle_index, daq, ctx)Result<(), RawTraceError>Per-method version of the aggregator method.
record_raw_trace_is_busy / is_error / error_messageas abovePer-method versions.
run_analysis(&mut self, ctx)AnalysisDispatchPer-method version.
finish_test(&mut self, ctx)()Close this method’s active run.

Plus an associated constant:

#![allow(unused)]
fn main() {
RotationalTractionTestManager::METHOD_ID  // → "rotational_traction"
}

RawTraceError

#![allow(unused)]
fn main() {
pub enum RawTraceError {
    NotStarted,                      // start_test wasn't called
    DaqNotReady,                     // capture has no data yet
    ChannelMissing { channel: &'static str },
    SchemaError(&'static str),       // codegen rejected this method's raw_data
    Busy,                            // previous trace still in flight
    WorkerDead,                      // worker thread died — recreate the TestManager
}
}

Busy is the case you’ll hit most often. The typical pattern is to gate the next record_raw_trace call behind record_raw_trace_is_busy():

#![allow(unused)]
fn main() {
if !self.tis.record_raw_trace_is_busy() {
    match self.tis.record_raw_trace(cycle_index, &self.daq, ctx) {
        Ok(())   => self.last_trace_cycle = cycle_index,
        Err(e)   => log::warn!("record_raw_trace: {}", e),
    }
}
}

AnalysisDispatch

#![allow(unused)]
fn main() {
pub enum AnalysisDispatch {
    Dispatched,      // request sent; poll is_analysis_busy()
    Busy,            // previous analysis still running
    NotConfigured,   // this method has no `analysis` block
}
}

run_analysis is fire-and-forget — there’s no return value the control program needs to read. Once dispatched, the Python script writes results back via its own tis.update_results or tis.add_filtered_data calls.

Live broadcasts the control program watches

The auto-injected GM scalars listed in Lifecycle in detail are your primary observation surface. Two HMI-side broadcasts are not mirrored to GM and are observed from the React side only — tis.cycle_added and tis.results_updated. If your control program needs to react to them (rare), subscribe via the IPC client’s subscribe() directly.

RPC reference

This section is the full catalog of tis.* IPC commands. The quick-reference table below lists every command grouped by area; each subsection that follows documents the request and response shape with a worked example.

TopicCallerSection
Lifecycle
tis.stage_testHMILifecycle
tis.clear_stagedHMILifecycle
tis.start_testControlLifecycle
tis.finish_testControlLifecycle
tis.statusEitherLifecycle
Cycles and traces
tis.add_cycleControlCycles and traces
tis.update_resultsControlCycles and traces
tis.add_raw_dataControlCycles and traces
tis.add_filtered_dataPost-processCycles and traces
Reading runs
tis.list_testsHMIReading runs
tis.read_testHMIReading runs
tis.read_cyclesHMIReading runs
tis.list_rawHMIReading runs
tis.read_rawHMIReading runs
tis.list_filteredHMIReading runs
tis.read_filteredHMIReading runs
Projects
tis.list_projectsHMIProject management
tis.create_projectHMIProject management
tis.read_projectHMIProject management
tis.update_projectHMIProject management
tis.delete_projectHMIProject management
tis.list_methodsHMIProject management
tis.list_schemasHMIProject management
Admin and exports
tis.delete_testHMIAdmin and exports
tis.disk_usageHMIAdmin and exports
tis.export_test_csvHMIAdmin and exports
tis.export_test_data_csvHMIAdmin and exports
tis.export_project_csvHMIAdmin and exports
tis.export_project_zipHMIAdmin and exports

All TIS commands respond on the same WebSocket frame they arrived on, with a JSON envelope:

{
  "topic": "tis.<command>",
  "message_type": "Response",
  "success": true,                  // false on error
  "error_message": "",              // populated when success=false
  "data": { /* command-specific */ }
}

The control program’s generated *TestManager methods wrap these commands so you rarely need to type them by hand. The examples below are most useful when writing HMI code, debugging via wscat, or driving TIS from acctl / a test harness.

Lifecycle

tis.stage_test

Declare an intent to run. The stage is persistent — a successful start_test does not consume it, so the operator can run sample after sample without re-filling the form. Use tis.clear_staged to explicitly abandon.

// Request
{ "topic": "tis.stage_test", "data": {
    "project_id": "TT-01",
    "method_id":  "translational_traction",
    "sample_id":  "SAMPLE-0042",
    "config":     { "control_load": 500.0 }
}}

// Response
{ "success": true, "data": { "status": "staged" } }

Side effect: broadcasts tis.staged = true plus the three tis.staged_* scalars.

tis.clear_staged

// Request
{ "topic": "tis.clear_staged", "data": { "project_id": "TT-01", "method_id": "translational_traction" } }

// Response
{ "success": true, "data": { "status": "cleared" } }

tis.start_test

Open a new test record on disk. The server creates <base>/<project_id>/<method_id>/<run_id>/ (where run_id is a fresh ISO-8601 UTC timestamp), seeds test.json, and creates raw_data/ + filtered_data/. Sets tis.active = true and the four tis.active_* scalars.

// Request
{ "topic": "tis.start_test", "data": {
    "project_id": "TT-01",
    "method_id":  "translational_traction"
    // sample_id + config picked up from the staged record;
    // can be supplied here directly to bypass the stage.
}}

// Response
{ "success": true, "data": {
    "status":    "started",
    "run_id":    "2026-05-13T11:14:22.103Z",
    "sample_id": "SAMPLE-0042"
}}

The control program normally never calls this directly — the codegen’d tick_with_autostart reads the staged scalars from GM and dispatches to the correct manager.

tis.finish_test

Close the active run. Flips tis.active = false and clears the four tis.active_* scalars.

{ "topic": "tis.finish_test", "data": { "project_id": "TT-01", "method_id": "translational_traction" } }

tis.status

Diagnostic readback of the staged record. Prefer the tis.staged_* scalars for any kind of gating — status is intended for the Are we still staged? sanity dialog during development.

// Response when staged
{ "success": true, "data": {
    "project_id": "TT-01", "method_id": "translational_traction",
    "sample_id":  "SAMPLE-0042", "ready": true,
    "config":     { /* ... */ }
}}

// Response when nothing is staged for this pair
{ "success": true, "data": {
    "project_id": "TT-01", "method_id": "translational_traction",
    "ready": false
}}

Cycles and traces

tis.add_cycle

Append one row to cycles.jsonl. Server assigns a monotonic cycle_index and timestamps the row. Use the codegen’d TestManager::add_cycle(ctx) instead of raw IPC — it builds the payload from GM fields per the method’s cycle_fields.

// Request
{ "topic": "tis.add_cycle", "data": {
    "project_id": "TT-01", "method_id": "translational_traction",
    "cycle_data": {
        "cycle_index":          1,
        "actual_load":          499.4,
        "actual_surface_speed": 0.250,
        "friction_coefficient": 0.42
    }
}}

// Response
{ "success": true, "data": { "status": "added", "cycle_index": 1 } }

Broadcast on success: tis.cycle_added with { project_id, method_id, run_id, cycle }.

tis.update_results

Replace the results block of test.json. Codegen wrapper: TestManager::update_results(<field_1>, <field_2>, ..., ctx).

// Request
{ "topic": "tis.update_results", "data": {
    "project_id": "TT-01", "method_id": "translational_traction",
    "avg_cof": 0.41, "max_cof": 0.55, "min_cof": 0.32
}}

Broadcast on success: tis.results_updated with { project_id, method_id, run_id, results }.

tis.add_raw_data

Write raw_data/<sample_id>_<name>_cycleNNNN.json. Prefer record_raw_trace (the codegen wrapper) — it threads the DaqCapture clone through an off-thread worker and adds the cycle context automatically. add_raw_data is for hand-built blobs (simulators, replay, etc.).

{ "topic": "tis.add_raw_data", "data": {
    "project_id": "TT-01", "method_id": "translational_traction",
    "name": "trace", "cycle_index": 1,
    "data": {
        "cycle_index": 1,
        "context": { "sample_rate": 5000, "n_samples": 2500 },
        "data": { "t": [0.0, 0.0002, 0.0004], "tsdr_fz": [-1.2, -1.3, -1.25] }
    }
}}

tis.add_filtered_data

Write filtered_data/<name>.json. One per name per run, no cycle_index. Typically called by post-processing (the analysis script).

{ "topic": "tis.add_filtered_data", "data": {
    "project_id": "TT-01", "method_id": "translational_traction",
    "name": "trace",
    "data": { "t": [/* ... */], "tsdr_fz_filtered": [/* ... */], "cof_smoothed": [/* ... */] }
}}

Reading runs

tis.list_tests

Run list for a project (or for a project + method pair). method_id is optional; omit it to aggregate runs across every method. Sorted newest-first by start_time.

// Request
{ "topic": "tis.list_tests", "data": { "project_id": "TT-01" } }

// Response
{ "success": true, "data": { "tests": [
    { "project_id": "TT-01", "method_id": "translational_traction",
      "run_id":     "2026-05-13T11:14:22.103Z",
      "sample_id":  "SAMPLE-0042",
      "start_time": "2026-05-13T11:14:22.103Z",
      "config":     { /* ... */ },
      "results":    { /* ... */ } }
    /* ...next-newest test... */
]}}

tis.read_test

Full test.json for one run.

{ "topic": "tis.read_test", "data": {
    "project_id": "TT-01", "method_id": "translational_traction",
    "run_id":     "2026-05-13T11:14:22.103Z"
}}

tis.read_cycles

Paginated read of cycles.jsonl. Defaults: offset=0, limit=200, order="asc". Pass order="desc" for newest-first.

// Request
{ "topic": "tis.read_cycles", "data": {
    "project_id": "TT-01", "method_id": "translational_traction",
    "run_id":     "2026-05-13T11:14:22.103Z",
    "offset": 0, "limit": 50, "order": "asc"
}}

// Response
{ "success": true, "data": {
    "cycles": [ /* up to `limit` rows */ ],
    "offset": 0, "limit": 50, "total": 137
}}

tis.list_raw / tis.list_filtered

{ "topic": "tis.list_raw", "data": {
    "project_id": "TT-01", "method_id": "translational_traction",
    "run_id":     "2026-05-13T11:14:22.103Z"
}}
// Response: { "files": ["SAMPLE-0042_trace_cycle0001.json", ...] }

tis.read_raw / tis.read_filtered

Returns the parsed JSON file. Raw blobs are wrapped in a per-cycle envelope { cycle_index, cycle_fields, context, data: { col: [...] } }; filtered blobs are flat (no envelope).

{ "topic": "tis.read_raw", "data": {
    "project_id": "TT-01", "method_id": "translational_traction",
    "run_id":     "2026-05-13T11:14:22.103Z",
    "name":       "trace",
    "cycle_index": 1
}}

Project management

tis.list_projects

{ "topic": "tis.list_projects", "data": {} }
// Response: { "projects": ["TT-01", "TT-02", ...] }

tis.create_project

Creates <base>/<project_id>/ and seeds project.json with the supplied project_fields. Project IDs must be ASCII [A-Za-z0-9_-]+; the server rejects path-separator and .. attempts and refuses to overwrite an existing project.

{ "topic": "tis.create_project", "data": {
    "project_id":     "TT-01",
    "project_fields": { "customer": "ACME", "operator": "alice" }
}}

Broadcast on success: tis.project_created with { project_id }.

tis.read_project

Returns the project.json metadata blob.

// Response data
{ "project_id":     "TT-01",
  "created_at":     "2026-05-01T09:00:00Z",
  "updated_at":     "2026-05-13T11:14:22Z",
  "project_fields": { "customer": "ACME", "operator": "alice" }
}

tis.update_project

Replace project_fields and bump updated_at. The file is rewritten atomically (write-temp + rename). Reading first to merge by hand is the caller’s job; this is a replace, not a merge.

{ "topic": "tis.update_project", "data": {
    "project_id":     "TT-01",
    "project_fields": { "customer": "ACME Industries", "operator": "alice" }
}}

Broadcast on success: tis.project_updated with { project_id }.

tis.delete_project

Recursive delete of <base>/<project_id>/. Refuses if any test is currently active for this project (on any method) — finish or delete the live run first. Also drops any matching staged entries.

{ "topic": "tis.delete_project", "data": { "project_id": "TT-01" } }
// Response: { "status": "deleted", "project_id": "TT-01" }

Broadcast on success: tis.project_deleted with { project_id }.

tis.list_methods

Lists method directories under one project (i.e., the methods that actually have data on disk for this project — distinct from list_schemas, which lists every method declared in project.json).

{ "topic": "tis.list_methods", "data": { "project_id": "TT-01" } }
// Response: { "methods": ["translational_traction", "rotational_traction"] }

tis.list_schemas

Returns the entire test_methods block from project.json plus the server-suggested default method. Called by <TisProvider> on mount. The HMI re-uses this for form rendering, view selection, and schema-driven validation.

// Response data
{ "test_methods": {
    "translational_traction": {
        "config_fields":  [/* ... */],
        "cycle_fields":   [/* ... */],
        "results_fields": [/* ... */],
        "raw_data":       { /* ... */ },
        "views":          { /* ... */ }
    },
    "rotational_traction":    { /* ... */ }
  },
  "default_method_id": "translational_traction"
}

Admin and exports

tis.delete_test

Remove one run directory entirely. Refuses when the target run is the active test for this (project_id, method_id) — finish it first. The HMI’s Project Manager drives this with a confirmation dialog.

{ "topic": "tis.delete_test", "data": {
    "project_id": "TT-01", "method_id": "translational_traction",
    "run_id":     "2026-05-13T11:14:22.103Z"
}}
// Response: { "status": "deleted", ... }

Broadcast on success: tis.test_deleted with { project_id, method_id, run_id }.

tis.disk_usage

Linux-only statvfs on the TIS base_directory. Surfaces free / total space so the Project Manager panel can warn operators before the disk fills up.

{ "topic": "tis.disk_usage", "data": {} }

// Response
{ "success": true, "data": {
    "base_directory":  "/srv/autocore/results",
    "total_bytes":     1099511627776,
    "free_bytes":       512345600000,
    "available_bytes":  512345600000,
    "used_bytes":       587166027776
}}

tis.export_test_csv

Per-test Report CSV: metadata header (# project_id, sample, config), [cycles] table, and [results] block. Inline response — the CSV text lives in data.csv so the HMI can build a blob and trigger a download without an extra round trip.

// Request
{ "topic": "tis.export_test_csv", "data": {
    "project_id": "TT-01", "method_id": "translational_traction",
    "run_id":     "2026-05-13T11:14:22.103Z"
}}

// Response
{ "success": true, "data": {
    "filename": "TT-01_translational_traction_SAMPLE-0042_2026-05-13T11:14:22.103Z_report.csv",
    "csv":      "# project_id: TT-01\n# method_id: translational_traction\n..."
}}

tis.export_test_data_csv

Per-test Data CSV: raw cycles concatenated with filtered columns prefixed filtered_ (paired by row index against cycle 1). Same inline-response shape as export_test_csv.

{ "topic": "tis.export_test_data_csv", "data": {
    "project_id": "TT-01", "method_id": "translational_traction",
    "run_id":     "2026-05-13T11:14:22.103Z",
    "name":       "trace"
}}

name defaults to "trace" if omitted.

tis.export_project_csv

Project-wide report — every test in the project, oldest-first, separated by blank lines. Inline response (CSV in data.csv).

{ "topic": "tis.export_project_csv", "data": { "project_id": "TT-01" } }

// Response
{ "success": true, "data": {
    "filename":   "TT-01_project_report.csv",
    "csv":        "...",
    "test_count": 137
}}

tis.export_project_zip

ZIP archive of the whole <base>/<project_id>/ tree, written to the server’s /downloads/ directory. Response contains a URL the browser can GET to retrieve the file (the ZIP can be hundreds of MB; we don’t ship it inline).

{ "topic": "tis.export_project_zip", "data": { "project_id": "TT-01" } }

// Response
{ "success": true, "data": {
    "download_url": "/downloads/1715600000000_TT-01_project_archive.zip",
    "filename":     "TT-01_project_archive.zip",
    "size":         12345678
}}

Broadcasts (server → clients, no subscription needed)

TopicFires whenPayload
tis.stagedstage_test, clear_stagedbool (scalar)
tis.staged_project_idstage_teststring
tis.staged_method_idstage_teststring
tis.staged_sample_idstage_teststring
tis.activestart_test, finish_testbool (scalar)
tis.active_project_idstart_test, finish_teststring
tis.active_method_idstart_test, finish_teststring
tis.active_sample_idstart_test, finish_teststring
tis.active_run_idstart_test, finish_teststring
tis.last_start_errorstart_test succeeds (clears) / fails (sets)string
tis.cycle_addedadd_cycle succeeds{ project_id, method_id, run_id, cycle }
tis.results_updatedupdate_results succeeds{ project_id, method_id, run_id, results }
tis.statusstage_testfull staged record (diagnostic)
tis.project_createdcreate_project succeeds{ project_id }
tis.project_updatedupdate_project succeeds{ project_id }
tis.project_deleteddelete_project succeeds{ project_id }
tis.test_deleteddelete_test succeeds{ project_id, method_id, run_id }

The scalar broadcasts get auto-linked into GM by normalize(). The JSON-payload broadcasts (cycle_added, results_updated, the project_* / test_* mutation broadcasts) are consumed directly by the HMI components and don’t need to be added to project.json.

End-to-end worked example

A complete cycle, from “operator opens the HMI” to “results are visible on the History tab,” for a translational_traction test method that declares one DAQ trace and an analysis script:

1. Operator stages the test (HMI)

<TestSetupForm> reads tis.list_schemas, renders the method’s config_fields, and on validate sends:

{ "topic": "tis.stage_test", "data": {
    "project_id": "TT-01", "method_id": "translational_traction",
    "sample_id":  "SAMPLE-0042",
    "config":     { "control_load": 500.0 }
}}

Server flips tis.staged = true and the three tis.staged_* scalars. GM mirrors them: gm.tis_staged, gm.tis_staged_project_id, etc.

2. Control program autostarts (Rust)

The control program is sitting in its tick loop. Once per tick:

#![allow(unused)]
fn main() {
fn process_tick(&mut self, ctx: &mut TickContext<GlobalMemory>) {
    // Drain pending TIS IPC responses and try to start any staged test.
    if let Some(started_method) = self.tis.tick_with_autostart(ctx) {
        // First tick of a new run — initialise per-run state if needed.
        log::info!("Test started: {:?}", started_method);
        self.cycle_count = 0;
    }

    // ... drive the rig, record cycles ...
}
}

Behind the scenes, tick_with_autostart sent tis.start_test. The server opened the run directory, set tis.active = true, and the new tis.active_run_id scalar lands in GM as gm.tis_active_run_id.

3. Control program records a cycle + raw trace (Rust)

Mid-test, the control program completes one mechanical cycle.

#![allow(unused)]
fn main() {
fn on_cycle_complete(&mut self, ctx: &mut TickContext<GlobalMemory>) {
    ctx.gm.cycle_count = ctx.gm.cycle_count.saturating_add(1);
    ctx.gm.friction_coefficient = compute_cof(&self.daq);

    // Append one cycle row. Routes to the active manager.
    self.tis.record_cycle(ctx);

    // Ship the DAQ capture. Non-blocking — call returns immediately;
    // worker thread converts to JSON; tick() drains the response.
    if !self.tis.record_raw_trace_is_busy() {
        if let Err(e) = self.tis.record_raw_trace(ctx.gm.cycle_count, &self.daq, ctx) {
            log::warn!("record_raw_trace failed: {}", e);
        }
    }
}
}

The HMI’s <TestDataView> is subscribed to tis.cycle_added and re-renders in place as each cycle lands.

4. Control program finishes the test (Rust)

When the rig hits its end-of-test condition:

#![allow(unused)]
fn main() {
fn on_test_complete(&mut self, ctx: &mut TickContext<GlobalMemory>) {
    // Per-test aggregates. Codegen-typed: one arg per results_field.
    self.tis.translational_traction.update_results(
        self.cof_running_avg,
        self.cof_max,
        self.cof_min,
        ctx,
    );

    // Close the record. tis.active flips false.
    self.tis.end_active(ctx);

    // Optional: dispatch the Python analysis script. Non-blocking.
    match self.tis.run_analysis(ctx) {
        AnalysisDispatch::Dispatched    => log::info!("analysis dispatched"),
        AnalysisDispatch::Busy          => log::warn!("analysis still running from a prior run"),
        AnalysisDispatch::NotConfigured => {},
    }
}
}

5. Analysis writes filtered data (Python, server-side)

The Python analysis script reads the raw blobs, smooths them, and calls:

ipc.send("tis.add_filtered_data", {
    "project_id": project_id,
    "method_id":  method_id,
    "name":       "trace",
    "data":       { "t": t_arr, "tsdr_fz_smoothed": fz_smoothed, ... },
})

6. Operator reviews + exports (HMI)

  • <ResultHistoryTable> re-renders on tis.active = false and shows the new run at the top.
  • The operator clicks the row’s Report button → HMI calls tis.export_test_csv → response carries the inline CSV → browser download.
  • For the whole-project archive at end of shift, the operator clicks Download Archive → HMI calls tis.export_project_zip → server writes the file → HMI follows the download_url.
  • For administration (deleting a misfired test, freeing disk), the Project Manager tab calls tis.delete_test, tis.delete_project, and reads tis.disk_usage.

Where each piece is documented

StepTopic / APISection
Stage formtis.stage_testLifecycle
AutostartTestInformationSystem::tick_with_autostartControl program API
Cycle rowTestInformationSystem::record_cycletis.add_cycleControl program API, Cycles and traces
Raw traceTestInformationSystem::record_raw_tracetis.add_raw_dataControl program API, Cycles and traces
ResultsTestManager::update_resultstis.update_resultsControl program API, Cycles and traces
FinishTestInformationSystem::end_activetis.finish_testControl program API, Lifecycle
AnalysisTestInformationSystem::run_analysisControl program API
Filtered datatis.add_filtered_dataCycles and traces
Exports / admintis.export_*, tis.delete_*, tis.disk_usageAdmin and exports

HMI components

The four TIS components self-drive when wrapped in a <TisProvider>. All props are optional overrides — pass them only when you want to lock a particular axis (e.g., a per-method history page).

ComponentDefault behaviour
<TestSetupForm>Renders the schema for selection.methodId. Required Sample ID input alongside Project ID. Stages tests itself via tis.stage_test whenever the form validates.
<TestDataView>Reads the run pinned in selection, falls back to the active run, renders cycle scatter + cycle table + results. Header shows Sample ID prominently.
<TestRawDataView>Same selection rules; renders any type: "raw_trace" views from the schema.
<ResultHistoryTable>Project-scoped (across every method) by default — switching the loaded method on Setup tab doesn’t hide already-recorded runs. Sample ID is the primary column.

Hooks exposed by <TisProvider>

useTis()           // direct access to the whole context value
useTisSchemas()    // SchemaRegistry from tis.list_schemas
useTisState()      // live readiness scalars (staged*, active*)
useTisSelection()  // [{ projectId, methodId, sampleId, runId }, setSelection]
useTisRuns(projectId?, methodId?)  // run list + refresh()
useTisRun(runId?)  // { meta, cycles, results, rawData, loading }

Selection has two layers: explicit pins (set via setSelection({ runId: "..." })) and an active follower (each field auto-tracks its tis.active_* scalar until pinned). Pass null to a field to clear its pin.

Asset traceability — asset_refs

A test method may declare a list of asset_refs so each test record captures the active calibration values of the equipment that produced it. At tis.start_test, AMS resolves the refs and writes the snapshot into test.json::asset_snapshot:

"test_methods": {
  "translational_traction": {
    "config_fields": [/* … */],
    "asset_refs": [
      { "name": "load_cell_z", "asset_type": "load_cell",
        "select": "by_location", "location": "tsdr_z",
        "calibration_required": "warn" }
    ]
  }
}

calibration_required is one of ignore, warn (default), or require. See Chapter 16 for the full AMS surface.

See also

  • Chapter 12acctl new-tis-project, acctl add-tis, and acctl codegen-tags.
  • Chapter 16 — Asset Management System (ams.* RPCs, calibration history, surface lanes, the asset_refs/asset_snapshot integration).

Appendix: original reference (Standardized Results System)

The pre-rename reference prose follows. It documents the same filesystem layout and most of the same RPC verbs but uses the legacy identifiers (results.*, definition_id, ResultsSystem, results_staged_*). The plumbing has been replaced — code samples here will not compile against the current control-program API or match what the server now broadcasts. Treat this section as historical.


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: A dedicated servelet handles disk I/O, UTC timestamping, and checksumming.
  5. Filesystem-Based: Data is stored as standard JSON and JSONL files for maximum portability.

Auto-provided fields in the legacy contract:

FieldDescription
test_idISO-8601 timestamp string assigned on results.start_test. Becomes the directory name under datastore/results/<project_id>/<definition_id>/.
created_atUTC timestamp set when the test record is first created.
completed_atUTC timestamp set when the test is closed.
checksumSHA-256 of the final test.json payload.
schemaSnapshot of the definition used.
project_idSupplied by the UI in results.start_test / stage_test.

Legacy schema example (now test_methods / method_id):

{
  "test_definitions": {
    "impact_test": {
      "config_fields": [
        { "name": "drop_height", "type": "f32", "units": "mm", "source": "gm.drop_height_mm" }
      ],
      "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" }
      ]
    }
  }
}

The legacy results.* IPC verbs (results.stage_test, results.start_test, results.add_cycle, results.add_raw_data, results.update_results, results.finish_test, etc.) and broadcast topics (results.staged, results.active, results.cycle_added, results.results_updated) have been replaced 1:1 by their tis.* counterparts, with definition_idmethod_id in every payload and the addition of sample_id as a top-level structural field.

Asset Management System

The Asset Management System (AMS) is AutoCore’s record-keeper for the physical equipment in your machine: load cells, encoders, springs, and project-specific items like 3830’s traction surfaces. AMS owns three concerns:

  1. What’s installed — a registry of every asset, identified by a server-generated asset_id.
  2. What state it’s in — the active calibration values plus a full audit-trail of past calibrations.
  3. How much it has been used — cycle counts, hours run, and any custom counters declared per asset type.

AMS is intentionally a separate subsystem from TIS. Test records (TIS) grow per-test-run and are immutable once written. Asset records (AMS) persist across years and accumulate calibration / usage events over the equipment’s whole life. The integration point is one-way: when tis.start_test fires, the TIS servelet snapshots the active asset state into the test record, so traceability survives later recalibration.

Quick start. If you already have a project and just want to flip AMS on, skip ahead to Adding AMS to an existing project below.

The three-level data model

Every asset in AMS lives at the intersection of three keys:

LevelUser-facing nameWire/code keyWhat it is
1Asset Typeasset_typeThe shape of the equipment — built-in (load_cell, linear_encoder, spring) or custom (surface, etc.). Defined in code or in project.json::asset_types.
2Assetasset_idOne physical instance, with a serial number and install location. Server-generated identifier.
3Calibration Recordcal_idOne calibration event for an asset. Append-only.

On disk under <datastore>/assets/:

datastore/assets/
├── registry.json
├── load_cell/
│   └── LC-20251015T130022/
│       ├── asset.json
│       ├── calibrations/
│       │   ├── 20251015T130122.json
│       │   └── 20260301T091548.json
│       ├── usage.json
│       └── usage_log.jsonl    ← created on first usage reset
└── surface/
    └── SURF-20260114T093400/
        ├── asset.json         ← includes the lanes sub_locations
        ├── calibrations/
        └── usage.json

Syncing AMS data across machines

The same project.json is typically deployed to several physical machines, but datastore/assets/ is machine-local: it records the transducer actually installed in this machine, its calibration certificates, and its usage counters. That state must not travel with the shared project definition.

So acctl sync treats datastore/assets/ as pull-only — it brings a fresher copy down from the server, but never pushes the local copy up (the same rule it applies to autocore_gnv.ini). This stops one machine from overwriting another’s assets on the shared server during a routine sync.

When you do want to publish this machine’s AMS state — e.g. after registering or recalibrating an asset that the other machines should see — push it deliberately:

acctl push assets

This uploads datastore/assets/ and then calls ams.reinitialize so the running server reloads the registry from disk (otherwise its next AMS write would clobber the pushed files from stale in-memory state). The push is additive — it does not delete server-side assets that were removed locally; for full reconciliation use acctl ams export / import (see Backup and restore below).

Asset IDs are server-generated

Asset IDs are always assigned by the server. The format is <prefix><YYYYMMDDTHHMMSS> — for example, LC-20260301T091548. The trailing timestamp is the asset’s creation time (UTC, second resolution). On collision (rare; bulk import inside one second) the server bumps by one second until the path is free.

The prefix comes from the asset_type’s id_prefix:

Asset typeDefault prefix
load_cellLC-
linear_encoderENC-
springSP-
Custom typesA- (override via id_prefix)

The manufacturer’s serial number is stored as a separate free-form field on asset.json. It’s recorded for traceability but not used as a unique key — vendor serials collide and reuse across product lines and we have zero control over their assignment policy.

Built-in asset types

Three asset types ship with the server. You don’t need to declare them in project.json to use them.

load_cell

Force-measuring transducer. Two field sets — nameplate values (transducer specifications, stamped on the device at manufacture) and calibration values (per-cert measurements that change over the transducer’s life).

Nameplate fieldTypeRequiredNotes
capacityf32yesFull-scale load, expressed in the units given by capacity_units. Feeds NI bridge max_val and EL3356 0x8000:24 (converted to N at the consumer if capacity_units"N").
capacity_unitsstringyesEngineering units the capacity value is expressed in (e.g., "N", "lbf", "kg"). Also drives the calibration’s output units — calibrations produce values in this unit.
compression_sensitivity_mv_vf32yesNameplate output at full compressive load, in mV/V. Feeds NI bridge prescaled_max and EL3356 0x8000:23.
tension_sensitivity_mv_vf32noNameplate output at full tensile load, in mV/V. Optional — leave unset for compression-only cells. Many strain-gauge cells have asymmetric compression/tension response; record both when the data sheet provides them.
bridge_resistance_ohmf32yesWheatstone bridge resistance, in Ω. Feeds NI nominal_bridge_resistance.
excitation_vf32noData-sheet recommended bridge excitation, in V. Optional — the actual excitation comes from the amplifier / DAQ card configuration, not the cell. Use this for cross-check at install time and for projects that drive voltage_excit_val from the asset; projects that prefer to hardcode excitation at the channel config can leave this blank.
Calibration fieldTypeRequiredNotes
scalef32yesCounts-to-engineering-units multiplier. Output is in the cell’s nameplate capacity_units.
offsetf32yesZero-load counts.
rangef32noOptional override of nameplate capacity for the measurable range under this calibration (rarely differs).

Note on capacity_units. Units live on the nameplate rather than the calibration record: the physical cell is rated in a single set of units and recalibration doesn’t change that. EL3356-driven assets are assumed to want "N" at the SDO interface — if capacity_units"N", the consumer is responsible for converting before writing 0x8000:24.

Nameplate values feed module configs via the AMS placeholder resolver (see Placeholder resolver below) — projects that wire their NI bridge channels and EL3356 SDOs through ${ams.by_location.*} never inline these numbers, so a load-cell swap is just a registry edit.

linear_encoder

Position-measuring transducer. Tracks counts-per-mm and accumulated travelled distance.

Calibration fieldTypeNotes
counts_per_mmf32Required.
offset_mmf32Required. Zero-position offset.
directionstring"+" or "-" — sign of increasing counts.

Extra usage counter: total_distance_mm (f64).

spring

Mechanical compression/extension spring. “Recalibration” usually means a replacement; the calibration record serves as the install record.

Calibration fieldTypeNotes
stiffness_n_per_mmf32Required.
free_length_mmf32Required.
preload_nf32Optional.

Restricting which built-ins appear (enabled_builtin_asset_types)

By default all three built-ins show up in the HMI’s Add Asset type picker. A machine that only uses load cells doesn’t want operators scrolling past spring and linear_encoder — so project.json accepts an optional allowlist:

{
  "enabled_builtin_asset_types": ["load_cell"]
}
ValueEffect
omitted / absentAll built-ins available — the historical default.
["load_cell", …]Only the named built-ins are offered. The others are dropped from ams.list_schemas (so they vanish from the picker) and create_asset rejects them as unknown.

Custom asset_types are never filtered by this list — if you declared a type, it’s in use by definition. And a built-in that’s already used by a registered asset is kept regardless, so trimming this list can never strip the schema (labels, nameplate fields) from existing assets — you can only hide types you have no assets of.

Adding AMS to an existing project

acctl add-ams
acctl push project --restart
acctl codegen

acctl add-ams writes an empty asset_types: {} block to your project.json. That single change flips three things:

  1. Project::normalize() injects the baseline AMS GM scalars (ams_asset_count, ams_alert_calibration_overdue, ams_alert_lane_unavailable).
  2. The next acctl codegen regenerates control/src/gm.rs with typed calibration structs (e.g., LoadCellCalibration with scale: f32, offset: f32) and writes www/src/autocore/ams.ts with mirrored TypeScript types and the asset_type catalog.
  3. The <AmsProvider> and HMI components below have something to render against.

Re-running acctl add-ams on a project that already has AMS enabled is a no-op.

Defining a custom asset type

Custom types extend the catalog with project-specific equipment. The 3830 traction tester uses a surface type with twelve testable lanes:

{
  "asset_types": {
    "surface": {
      "id_prefix": "SURF-",
      "label": "Traction Surface",
      "description": "Serialized test surface installed in the bay.",
      "fields": [
        { "name": "material",  "type": "string", "required": true,  "label": "Material" },
        { "name": "thickness", "type": "f32",    "units": "mm",      "label": "Thickness" }
      ],
      "calibration_fields": [],
      "sub_locations": {
        "name": "lanes",
        "count": 12,
        "per_location_state": [
          { "name": "status",      "type": "enum", "values": ["available", "in_use", "worn", "retired"] },
          { "name": "cycles_used", "type": "u64" }
        ]
      }
    }
  }
}

When you create a surface asset, AMS auto-materializes twelve lane records (lane_01 through lane_12) with default state — available status and cycles_used: 0. Operators can update individual lanes via ams.update_sub_location (or via the <SubLocationPicker> UI).

The name field on sub_locations is the user-facing label; the per- location IDs are formed by trimming the trailing s (laneslane) and appending _NN. Use a singular name if your equipment doesn’t pluralize naturally (channelchannel_01).

Multi-axis assets (keyed-fields sub_locations)

A six-axis force/moment transducer is one physical asset with one calibration cert but carries six independent rows of nameplate values and six pairs of calibration scale/offset. Modelling it as six separate load_cell records would lie about the physical reality (you don’t replace one axis); modelling it as one asset with loose JSON would lose schema validation.

The keyed-fields sub_locations shape is the answer. Declare it once on the asset type:

{
  "asset_types": {
    "triaxial_transducer": {
      "extends":     "builtin",
      "id_prefix":   "TSDR-",
      "label":       "Triaxial Transducer",
      "fields": [
        { "name": "manufacturer", "type": "string", "label": "Manufacturer" },
        { "name": "model",        "type": "string", "label": "Model" }
      ],
      "sub_locations": {
        "label":     "Axes",
        "key_label": "Axis",
        "keys":      ["fx", "fy", "fz", "mx", "my", "mz"],
        "fields": [
          { "name": "capacity",                     "type": "f32",    "required": true },
          { "name": "capacity_units",               "type": "string", "required": true },
          { "name": "compression_sensitivity_mv_v", "type": "f32", "units": "mV/V", "required": true },
          { "name": "bridge_resistance_ohm",        "type": "f32", "units": "Ω",    "required": true }
        ],
        "calibration_fields": [
          { "name": "scale",  "type": "f32", "required": true },
          { "name": "offset", "type": "f32", "required": true }
        ]
      }
    }
  }
}

Two distinct sub_locations shapes are recognised, distinguished by the presence of keys:

  • Keyed-fields (above) — fixed set of named sub-locations, each carrying the same per-key field schema. Used for multi-axis transducers, multi-channel amplifiers, and similar fixed-channel- count parts. AIS renders the Add dialog as a row-per-key matrix.
  • Positional (the surface-lanes example earlier) — count + per_location_state. Used for assets with mutable per-position state like a surface’s lanes. AIS renders these with <SubLocationPicker>.

For a keyed-fields type the on-disk asset record looks like:

{
  "asset_id": "TSDR-20260301T091548",
  "asset_type": "triaxial_transducer",
  "location": "tsdr",
  "custom": { "manufacturer": "Interface", "model": "T-3300" },
  "sub_locations": {
    "fx": { "capacity": 13431, "capacity_units": "N",  "compression_sensitivity_mv_v": 1.49885, "bridge_resistance_ohm": 709 },
    "fy": { "capacity": 13245, "capacity_units": "N",  "compression_sensitivity_mv_v": 1.501583,"bridge_resistance_ohm": 710 },
    "fz": { "capacity": 22322, "capacity_units": "N",  "compression_sensitivity_mv_v": 1.39277, "bridge_resistance_ohm": 708 },
    "mx": { "capacity":   341, "capacity_units": "Nm", "compression_sensitivity_mv_v": 1.243281,"bridge_resistance_ohm": 360 },
    "my": { "capacity":   341, "capacity_units": "Nm", "compression_sensitivity_mv_v": 1.232061,"bridge_resistance_ohm": 360 },
    "mz": { "capacity":   341, "capacity_units": "Nm", "compression_sensitivity_mv_v": 1.967126,"bridge_resistance_ohm": 361 }
  }
}

And the calibration record’s values carries the matching per-axis shape:

{
  "cal_id": "20260301T091548",
  "values": {
    "fx": { "scale": 9.81234, "offset": -0.0042 },
    "fy": { "scale": 9.81000, "offset":  0.0017 },
    "fz": { "scale": 9.80000, "offset":  0.0001 },
    "mx": { "scale": 0.50000, "offset":  0.0000 },
    "my": { "scale": 0.50100, "offset":  0.0000 },
    "mz": { "scale": 0.49900, "offset":  0.0000 }
  }
}

ams.create_asset, ams.update_asset, and ams.add_calibration validate the posted shape against the schema and reject with a per-key, per-field problem list when a required field is missing or an axis is absent. No corrupt half-records.

Placeholder resolver: ${ams.by_location.*}

Module configs (NI channels, EtherCAT SDOs, anything with free-form JSON in project.modules.<name>.config) can substitute AMS values at module-start time via placeholders. The server walks each module’s config just before launching the child process, replaces every ${ams.*} string with a concrete value pulled from the on-disk registry, and hands the resolved JSON to the spawned process. Hardware modules stay AMS-agnostic — autocore-ni and autocore-ethercat never have to talk to AMS themselves.

Grammar:

${ams.by_location.<location>.<field>}
${ams.by_location.<location>.<field> | hex<bits>}
${ams.by_location.<location>.<field> | neg}
${ams.by_location.<location>.sub.<key>.<field>}
${ams.by_location.<location>.cal.<field>}
${ams.by_location.<location>.cal.<key>.<field>}
  • <field> resolves against asset.custom.<field> (the nameplate values declared by the asset_type’s fields) or one of the top-level Asset keys (asset_id, serial, location, install_date, current_calibration_id).
  • cal.<field> reads from the asset’s active calibration’s values.<field>.
  • sub.<key>.<field> and cal.<key>.<field> are the per-axis forms used by multi-axis types — they index into asset.sub_locations.<key>.<field> and calibration.values.<key>.<field> respectively.

Render modifiers (chain left-to-right with |):

  • | hex<bits> — zero-padded uppercase hex with bits/4 digits (bits must be a multiple of 8 — matches the EtherCAT SDO value parser, which is byte-width-padded). Negative values and floats are rejected; overflow is rejected.
  • | neg — flip the sign. Useful for paired bridge min_val/max_val where min = -capacity.
  • | default <json_literal> — bring-up escape hatch. When the lookup would fail (no asset, no current calibration, field absent, value is null), substitute the literal value instead of hard-erroring. Argument is a JSON literal so numbers, quoted strings, booleans, and null all work. Composes with the others — see below.

A value is “missing” if any of these is true: no active asset at the location, no current calibration when the path reads cal.*, the requested field absent from custom / sub_locations, or the resolved value is JSON null. All four cases trigger the inline default if present.

Example — an NI bridge channel for a six-axis transducer’s fx axis:

{
  "name": "tsdr_fx",
  "physical_channel": "cdaq-tt-mod1/ai0",
  "create_function": "CreateAIForceBridgeTwoPointLinChan",
  "create_args": {
    "bridge_config": 10182,
    "max_val":   "${ams.by_location.tsdr.sub.fx.capacity}",
    "min_val":   "${ams.by_location.tsdr.sub.fx.capacity | neg}",
    "nominal_bridge_resistance":
                 "${ams.by_location.tsdr.sub.fx.bridge_resistance_ohm}",
    "prescaled_max":
                 "${ams.by_location.tsdr.sub.fx.compression_sensitivity_mv_v}",
    "prescaled_min": 0,
    "scaled_max": "${ams.by_location.tsdr.sub.fx.capacity}",
    "scaled_min": 0,
    "voltage_excit_source": 10200,
    "voltage_excit_val": 10
  }
}

An EtherCAT EL3356 SDO entry rendering capacity as a 32-bit hex value:

{ "index": "0x8000", "sub_index": "0x24", "bits": 32,
  "value": "${ams.by_location.z_load.capacity | hex32}" }

Whole-string placeholders preserve native types. A field whose entire value is one placeholder resolves to a JSON number when the asset field is numeric; the wire format that hits the NI module matches what its arg_f64 reader expects without any string-coercion gymnastics. Embedded placeholders (placeholder inside a larger string, like a comment) splice as strings.

Bring-up defaults

On a fresh install AIS is empty — every ${ams.*} placeholder would hard-error and no module would start. The | default <value> modifier lets the project author declare a sensible bring-up number right next to the use:

"max_val":  "${ams.by_location.tsdr.sub.fx.capacity | default 13431}",
"min_val":  "${ams.by_location.tsdr.sub.fx.capacity | default 13431 | neg}",
"value":    "${ams.by_location.z_load.capacity     | default 2000  | hex32}"

Resolution order:

  1. Live asset value (if registered, field set, and not null).
  2. Inline | default <json> modifier on the placeholder, if specified.
  3. Schema-level default declared on the asset_type field definition.
  4. Hard-error.

default substitutes the value; subsequent modifiers transform it just as they would a real lookup. So default 5000 | hex32 resolves to "00001388", exactly what you’d get if the asset were registered with capacity: 5000. The same applies to schema-level defaults — ${... | neg} against a schema default of 13431 produces -13431.

The argument is parsed as a JSON literal — quote strings, omit quotes for numbers/booleans/null:

Modifier textSubstituted value
default 5000the number 5000
default -500the number -500
default 3.14the number 3.14
default "Interface"the string Interface
default truethe boolean true
default nullJSON null

Schema-level field defaults

For values that don’t change across every placeholder that references them — a load cell’s nameplate capacity, a bridge resistance, an encoder ppr — drop the default next to the field definition in the asset_type schema instead of repeating it inline. The resolver picks it up automatically when the live value is missing AND the placeholder didn’t supply its own | default ....

"asset_types": {
  "triaxial_transducer": {
    "extends": "builtin",
    "sub_locations": {
      "keys": ["fx", "fy", "fz", "mx", "my", "mz"],
      "fields": [
        { "name": "capacity", "type": "f32", "default": 13431,
          "label": "Capacity" },
        { "name": "capacity_units", "type": "string", "default": "N",
          "label": "Capacity Units" },
        { "name": "compression_sensitivity_mv_v", "type": "f32", "default": 1.5,
          "label": "Compression Sensitivity", "units": "mV/V" },
        { "name": "bridge_resistance_ohm", "type": "f32", "default": 710,
          "label": "Bridge Resistance", "units": "Ω" }
      ]
    }
  }
}

With that schema, channel configs can drop the | default … modifier entirely:

"max_val":  "${ams.by_location.tsdr.sub.fx.capacity}",
"min_val":  "${ams.by_location.tsdr.sub.fx.capacity | neg}",

Precedence is what you’d expect: a live registered value wins; the inline | default X modifier wins over the schema default; the schema default wins over hard-error. acctl validate flags a schema default whose JSON type doesn’t match the field’s declared type (e.g. "default": "x" on an f32 field).

Field defaults are supported in all four field arrays: fields, calibration_fields, sub_locations.fields, and sub_locations.calibration_fields. Schema defaults only apply to locations that some project.asset_refs entry declares — the resolver uses the asset_ref to learn which asset_type’s schema to consult.

Placeholder failure policy

Two layers, with deliberately different severity:

  1. At project push (acctl sync): ams.placeholder findings are warnings — the project file lands on the server, the operator gets a yellow report listing every unresolved reference. Sync is not blocked. The reasoning is that an unresolved placeholder is an operator-fixable asset-record state, not a structural project problem: refusing the push would leave the operator without a way to drive into the AIS UI to fix the underlying record.

  2. At module spawn (system.start_control): an unresolved placeholder in a specific module’s config is a hard error for that module only. The supervisor’s start_module returns a typed SupervisorError::UnresolvedAmsPlaceholders listing every offending placeholder so the operator can fix them all in one pass:

    Module 'ni' refused to start — 2 unresolved AMS placeholder(s) in its config.
    Register the missing asset(s) in the AIS UI and try again:
      • ${ams.by_location.tsdr_fx.capacity} at /daq/0/channels/0/create_args/max_val
        — no active asset registered at location `tsdr_fx`
      • ${ams.by_location.tsdr_fy.capacity} at /daq/0/channels/1/create_args/max_val
        — no active asset registered at location `tsdr_fy`
    

    Other modules whose configs are clean still spawn. The system comes up partially while the offending asset gets fixed in the UI; once resolved, the next start of the affected module picks up the new value.

The split lets project edits propagate quickly during commissioning (don’t get stuck at sync because of a stale asset record) while still preventing a module from starting with placeholder strings actually landing in its serialized config (which would be very confusing to debug from inside the module).

When a project’s module config has placeholders but no AMS data directory is configured on the server, the failure points at the missing ams_base_directory config key instead of the asset, so the operator fixes the right thing.

Pre-flight verification: ams.diagnose_placeholders

The AIS <PlaceholderHealthPanel> calls this IPC and shows one row per ${ams.by_location.*} reference in every enabled module’s config, with green/red status and either the resolved value or the typed reason. Use this before powering hardware up — the panel catches a missing calibration in one click instead of a failed module spawn three minutes later.

Live asset-update callbacks (AssetWatch)

Module-start binding is one half of the story; recalibration during operation is the other. Every AMS mutation broadcasts on a per- location topic:

ams.asset_updated.<location>

with payload { asset_id, asset_type, location, trigger, asset, current_calibration }. Triggers are one of created, nameplate_updated, status_changed, location_changed, or calibration_added.

Control programs subscribe via autocore_std::AssetWatch:

#![allow(unused)]
fn main() {
use autocore_std::{AssetWatch, AssetWatchTrigger};

pub struct ForcePlateAxis {
    watch: AssetWatch,
}

impl ForcePlateAxis {
    pub fn new(client: &mut autocore_std::CommandClient) -> Self {
        Self { watch: AssetWatch::new("tsdr_z", client) }
    }

    pub fn tick(&mut self, ctx: &mut autocore_std::TickContext<GlobalMemory>) {
        for update in self.watch.pump(ctx.client) {
            match update.trigger {
                AssetWatchTrigger::CalibrationAdded
                | AssetWatchTrigger::NameplateUpdated
                | AssetWatchTrigger::InitialSync => {
                    // Re-fire EL3356 SDOs using update.asset.custom.*
                    // and update.current_calibration.values.*.
                    // The control program owns timing — defer when
                    // a test cycle is in flight.
                }
                AssetWatchTrigger::StatusChanged
                | AssetWatchTrigger::LocationChanged
                | AssetWatchTrigger::InitialSyncEmpty => {
                    // Asset retired or moved — flip the subsystem to
                    // inoperative and refuse to start tests against
                    // this role until a fresh asset is registered.
                }
                _ => {}
            }
        }
    }
}
}

AssetWatch::new does two things at construction: subscribes the CommandClient to the topic and issues a one-shot ams.list_assets scoped to the location, so the first pump() always begins with a baseline snapshot (InitialSync or InitialSyncEmpty). Broadcasts that land while the initial-sync response is still in flight stay queued; they arrive after the bootstrap event in arrival order.

The server never writes hardware. The control program is the only place that knows whether the slave is in PreOp / OP and whether a test cycle is in flight. A canonical worked example for an EL3356-0010 force terminal — including which CoE objects to re-fire on each trigger and the state-machine gating — lives in doc/ams_asset_watch.md in the repo root.

Gating control behaviour on asset presence

Control programs frequently need to refuse to enter auto mode when the load cell isn’t registered, or fall through into a safe state when an asset is retired. Two layered ways to query this.

AssetWatch::is_active() (event-driven, in the control loop)

The AssetWatch from the section above already tracks the latest lifecycle state internally. Three accessors expose it:

#![allow(unused)]
fn main() {
let tsdr = AssetWatch::new("tsdr", ctx.client);
// later, in process_tick:
let _ = tsdr.pump(ctx.client);  // drains events; updates internal state

if !tsdr.is_active() {
    ctx.gm.process_state = ProcessState::CantRunNoAsset as i32;
    return;
}
let _ = tsdr.active_asset_id();         // Option<&str> — "running against X"
match tsdr.active_status() {
    AssetWatchStatus::Active        => { /* run */ }
    AssetWatchStatus::Retired
    | AssetWatchStatus::OutForService
    | AssetWatchStatus::Missing     => { /* refuse */ }
}
}

The watcher’s state starts Missing until the first pump() lands the initial-sync result. Initial sync is the watcher asking ams.list_assets for the location; subsequent pumps fold every ams.asset_updated.<location> broadcast into the same state machine.

ams_active_<field>_present GM scalar (always-on, no subscription)

For control programs that don’t want to hold an AssetWatch and would rather check a bool in shared memory, Project::normalize() auto-injects three scalars per asset_ref:

GM variableTypeSource
ams_active_<field>_asset_idstringtis.start_test (test-time view)
ams_active_<field>_calibration_idstringtis.start_test (test-time view)
ams_active_<field>_presentboolAMS (any time the registry changes)

The _asset_id and _calibration_id scalars carry the resolved asset and calibration ids for the currently running test (set when tis.start_test resolves the method’s refs). The _present scalar is the AMS-time signal: it’s published by the AMS servelet on init and after every mutation (create_asset, update_asset that changes status or location, delete_asset).

#![allow(unused)]
fn main() {
// Control program check, with no subscription bookkeeping:
if !ctx.gm.ams_active_tsdr_present {
    refuse_auto();
}
}

Pick whichever fits your control program’s style. AssetWatch gives you the trigger-by-trigger event stream too; the scalar is the simpler “is there one or isn’t there” gate.

Reading current calibration from the control program

acctl codegen emits one <TypeName>Calibration Rust struct per asset type, plus a from_ams_response helper that parses the values out of an ams.read_calibration response. The control program reads the current calibration for an asset by fetching it once at startup and stashing it on the program struct:

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

pub struct MyProgram {
    load_cell: Option<LoadCellCalibration>,
    // …
}

impl MyProgram {
    pub fn new() -> Self {
        MyProgram { load_cell: None }
    }

    pub fn load_calibrations(&mut self, client: &mut CommandClient, asset_id: &str) {
        if let Some(asset) = client.invoke("ams.read_asset", json!({ "asset_id": asset_id })) {
            if let Some(cal_id) = asset.data.get("current_calibration_id").and_then(|v| v.as_str()) {
                if let Some(resp) = client.invoke("ams.read_calibration",
                    json!({ "asset_id": asset_id, "cal_id": cal_id }))
                {
                    self.load_cell = Some(LoadCellCalibration::from_ams_response(&resp.data));
                }
            }
        }
    }

    pub fn process_tick(&mut self, ctx: &mut TickContext<GM>) {
        if let Some(cal) = &self.load_cell {
            let raw_counts = ctx.gm.load_cell_z_raw as f32;
            let force_n = cal.scale * raw_counts + cal.offset;
            ctx.gm.zforce_load = force_n;
        }
    }
}
}

For testing methods that declare an asset_refs list, the control program can read the resolved asset_id from the auto-injected GM scalar — ctx.gm.ams_active_load_cell_z_asset_id — instead of hard-coding the ID.

Wiring usage counters

Phase 4 of AMS ships RPC-driven usage counters. Call ams.tick_usage from your control program’s record_cycle flow:

#![allow(unused)]
fn main() {
// Inside your program's tick:
self.tis.record_cycle(ctx);

if let Some(asset_id) = ctx.gm.ams_active_load_cell_z_asset_id.as_str_opt() {
    client.invoke("ams.tick_usage", json!({
        "asset_id":     asset_id,
        "delta_cycles": 1,
        "delta_hours":  ctx.dt.as_secs_f64() / 3600.0,
    }));
}
}

For surface lanes specifically, where each lane has its own cycles_used counter under sub_locations.items[*], update the lane directly:

#![allow(unused)]
fn main() {
client.invoke("ams.update_sub_location", json!({
    "asset_id":    surface_id,
    "location_id": active_lane_id,
    "partial":     { "cycles_used": new_count },
}));
}

Audit trail for resets. ams.reset_usage zeros the counters and appends an entry to usage_log.jsonl next to usage.json so the history of “we rebuilt this asset on date X” survives the reset.

TIS ↔ AMS integration: asset_refs and asset_snapshot

A project declares its system hardware once, at the top level. Every test method picks the same snapshot up automatically — load cells don’t appear and disappear between methods, so they shouldn’t be re-declared per method.

{
  "asset_refs": [
    { "name": "load_cell_z",
      "asset_type": "load_cell",
      "select": "by_location", "location": "tsdr_z",
      "calibration_required": "warn",
      "defaults": { "capacity": 13431, "capacity_units": "N" } },
    { "name": "encoder_x",
      "asset_type": "linear_encoder",
      "select": "by_location", "location": "x_axis",
      "calibration_required": "require" },
    { "name": "surface",
      "asset_type": "surface",
      "select": "by_id_field", "from": "config.surface_asset_id",
      "calibration_required": "ignore" }
  ],
  "test_methods": {
    "translational_traction": { /* no asset_refs needed; inherits */ },
    "rotational_traction":    { /* same */ }
  }
}

Methods may still declare their own asset_refs for method-specific accessories (e.g., an extra reference standard mounted only for a calibration test). When a project-level and a method-level ref share the same name, the method wins — the explicit per-test declaration is treated as an override. This is the documented escape hatch; in practice, leave system hardware at the project level.

by_id_field refs are also project-level. The from path resolves against the active method’s staged config — so the same surface ref above reads config.surface_asset_id from translational_traction’s config when that method is active and from rotational_traction’s when that one is.

Each asset_ref has:

  • name: the role identifier. Written as the key into test.json::asset_snapshot, and the suffix on the auto-injected ams.active_<name>_* broadcast topics.
  • asset_type: which catalog entry to look up.
  • select: how to pick the right asset.
    • by_location — pick the unique active asset whose location matches location below.
    • by_id_field — read the asset_id directly from a config field (dotted from path into the staged payload).
  • calibration_required: stage-time policy.
    • ignore — never blocks, never warns.
    • warn (default) — surfaces a warning on stage but lets the test start.
    • require — blocks staging with a clear error.

When tis.start_test fires, AMS resolves each ref and writes the snapshot into test.json:

{
  "project_id": "plant_a",
  "method_id":  "translational_traction",
  "run_id":     "20260422T140231Z",
  "sample_id":  "S-0042",
  "asset_snapshot": {
    "load_cell_z": {
      "asset_id":        "LC-20251015T130022",
      "asset_type":      "load_cell",
      "calibration_id":  "20260301T091548",
      "values":          { "scale": 9.81234, "offset": -0.0042, "units": "N" }
    },
    "encoder_x": { /* … */ },
    "surface":   { /* … */ }
  }
}

This is the audit trail. When someone re-calibrates LC-20251015T130022 in 2027, the 2026 test results still show the calibration that was active when they were taken.

For each asset_ref, AMS auto-injects two GM scalars so the control program and HMI can react:

  • ams_active_<name>_asset_id
  • ams_active_<name>_calibration_id

(For example: ams_active_load_cell_z_asset_id.)

Per-role defaults

An asset_ref may carry a defaults map of <field_name>: <value> pairs. Two consumers read it:

  1. Placeholder resolution. When ${ams.by_location.<location>.<field>} resolves and no live asset value is on disk (and the placeholder has no | default X modifier), the asset_ref’s default for <field> is substituted. This works on built-in asset_types too — load_cell and friends have no schema-level default slot, so the asset_ref is the per-role hook for declaring expected nameplate values.
  2. AIS form pre-fill. When the operator opens the create-asset form for this role, AIS seeds the nameplate inputs from the defaults map. The operator confirms or edits — values are never enforced, never replace a missing registration.
{
  "name": "f1",
  "asset_type": "load_cell",
  "select": "by_location", "location": "tsdr1",
  "label": "Force-plate load cell 1",
  "calibration_required": "warn",
  "defaults": {
    "capacity": 13431,
    "capacity_units": "N",
    "bridge_resistance_ohm": 350
  }
}

Every key in defaults must name a field on the role’s asset_type schema — validation rejects typoed keys at project load (with a “did you mean” hint), so misspelled defaults can’t silently no-op. Value types must coerce to the field’s declared type (number for f32/f64, string for string, etc.).

Default precedence (when no live asset value is on disk):

placeholder `| default X`  >  asset_ref defaults  >  asset_type schema default  >  unresolved

The placeholder-author’s | default is the most local escape hatch and wins; asset_ref defaults are per-role intent; schema defaults (custom asset_types only) are per-type intent.

Building an HMI for asset management

Drop <AmsProvider> once at the top of your HMI. The components below are zero-prop and read from context.

import {
    AmsProvider,
    AssetRegistryTable,
    AssetDetailView,
    SubLocationPicker,
    PlaceholderHealthPanel,
    MissingAssetsBanner,
} from '@adcops/autocore-react';

function AssetsTab() {
    return (
        <AmsProvider>
            <MissingAssetsBanner />
            <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
                <AssetRegistryTable />
                <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
                    <AssetDetailView />
                    <SubLocationPicker />
                </div>
            </div>
            <PlaceholderHealthPanel />
        </AmsProvider>
    );
}
ComponentResponsibility
<MissingAssetsBanner>Warning panel listing every by_location asset_ref declared in project.json that has no active asset registered. Each row gets a “Register” button that pre-populates <AssetRegistryTable>’s Add dialog with the right asset_type + location. Hides itself when zero missing.
<AssetRegistryTable>List every asset with type/status filters and an “Add Asset” button. The Add dialog reads the asset_type’s fields schema and renders one input per top-level field; when the type declares a keyed-fields sub_locations schema, a per-key matrix is rendered below — required cells gate Create. Click a row to pin selection. Listens for the banner’s prefill event.
<AssetDetailView>Show the selected asset’s header (id, type, serial, location, status), the top-level nameplate panel, the per-axis matrix (when applicable), the current calibration’s per-axis values (when applicable), the calibration history table, and three action buttons: Retire (active assets), Delete (retired assets — permanent), and + Calibration. Modules consuming this asset’s role are called out as “Feeds module configs: ni — their ${ams.by_location.<loc>.*} placeholders will resolve to the values below.”
<CalibrationEntryDialog>Form generated from the asset_type’s calibration_fields. Renders a flat key/value form for single-cell types and a per-axis grid for asset types that declare per-axis calibration_fields under sub_locations. The dialog posts the right wire shape automatically — operators never hand-edit JSON.
<SubLocationPicker>Grid view for asset types using the positional surface-lanes shape. Click a cell to mark a lane available / in_use / worn / retired. (Keyed-fields sub_locations are rendered inside <AssetRegistryTable> and <AssetDetailView>; <SubLocationPicker> stays focused on the mutable-state positional path.)
<PlaceholderHealthPanel>Pre-flight check for ${ams.*} resolution. Calls ams.diagnose_placeholders and shows one row per reference with green/red status, the resolved value, the asset_id it bound to, and the config path. Auto-refreshes on assets mutations so a fresh registration flips a row from red to green without manual reload.

The provider also exposes hooks for custom UI:

import { useAms, useAmsAlerts, useAmsAssets } from '@adcops/autocore-react';

function CalibrationStatusBadge() {
    const { calibrationOverdue } = useAmsAlerts();
    if (calibrationOverdue === 0) return null;
    return (
        <Tag value={`${calibrationOverdue} cal overdue`} severity="warning" />
    );
}

Calibration alerts

AMS publishes three derived counters on every mutation:

Broadcast topicWhat it counts
ams.asset_countNumber of active assets in the registry.
ams.alert_calibration_overdueActive assets whose current calibration’s expires_at is in the past.
ams.alert_lane_unavailableSub_location entries marked worn or retired across all active assets.

The <AmsProvider> keeps these in useAmsAlerts() automatically. Control programs can link to them as GM scalars (ams_alert_*) and gate features accordingly — for example, refusing to start a test when overdue calibrations exist on require-policy refs.

Backup and restore

The full AMS dataset round-trips through a single JSON document.

Export

acctl ams export --output ams_backup_2026-04-29.json

Pulls the registry, every asset’s full record, every calibration, and usage counters from the running server. The output is a single JSON file with this shape:

{
  "version":     1,
  "exported_at": "2026-04-29T16:00:00Z",
  "registry":    { "assets": [/* registry entries */] },
  "assets": [
    {
      "asset":        { /* full asset.json */ },
      "calibrations": [/* every cal record */],
      "usage":        { /* usage.json */ }
    }
  ]
}

Import

# Dry-run first to see what would change.
acctl ams import --input ams_backup_2026-04-29.json --dry-run

# Apply it.
acctl ams import --input ams_backup_2026-04-29.json

Default behaviour is merge: assets that already exist on the target server are left in place; assets that don’t are created with their original IDs preserved; calibrations are appended (the server honours the explicit cal_id in the document); usage counters take the max of existing-and-imported, so a stale backup never decreases counts.

--dry-run walks the document and prints what would happen without writing anything.

Round-trip fidelity. Asset_id and cal_id are preserved on import, which means the audit trail in test.json::asset_snapshot still resolves correctly after a restore.

Deleting an asset

Assets can be permanently removed from AMS in a two-step flow:

  1. Retire. ams.update_asset with status: "retired". The asset stays on disk but is excluded from active queries, the placeholder resolver, and any by_location snapshot. The AIS UI surfaces a Retire button on <AssetDetailView> for active assets.

  2. Delete. ams.delete_asset is the hard purge. It refuses with a clear “retire first” message if the asset is still active. Files removed: asset.json, calibrations/, usage.json, usage_log.jsonl, the entire <datastore>/assets/<asset_type>/<asset_id>/ directory. The registry entry goes too. The AIS UI surfaces a Delete button on retired assets, gated behind a confirmation dialog.

Why the audit trail survives. test.json::asset_snapshot was already enriched to carry the full asset and calibration records inline at tis.start_test time. Deleting the source asset from AMS later has no impact on historical test records — they can be read in isolation. The trade-off: test records are larger (an extra few KB per test for the asset and cal payloads).

Broadcasts on delete:

  • ams.asset_changed (so the registry refreshes)
  • ams.asset_deleted with { asset_id, asset_type } (subscribers can filter on type if they care)
  • ams.asset_count (the alert counter recomputes)
  • ams.active_<field>_present is re-published for every role whose location lost an asset (so control programs’ _present GM scalars flip false on the next tick)

Migration: how this differs from a hand-rolled equipment registry

If you’re migrating from an ad-hoc spreadsheet or Linear-tracked list of “what’s installed”:

  1. Run acctl add-ams to enable the subsystem.
  2. Use <AssetRegistryTable>’s “Add Asset” dialog (or the ams.create_asset RPC directly) to register each piece of equipment. Record the manufacturer serial under the serial field for traceability.
  3. For each asset, add the most recent known calibration via <CalibrationEntryDialog>. Backfill historical calibrations only if you need the audit trail; otherwise just enter the current one.
  4. Update each test method’s asset_refs block to point at the new asset locations. Run acctl codegen to refresh the GM scalars.

The same add- family retrofits TIS into a project that started without it: acctl add-tis. Both commands are idempotent.


Python Analysis Integration

AutoCore provides a dedicated external module, autocore-python, which securely runs Python scripts alongside your control program.

The primary use-case for this integration is post-test analysis. Rather than forcing automation engineers to write complex mathematical analysis (like calculating averages, detecting peaks, or analyzing trace arrays) in Rust, you can offload this math to Python. This allows customers, technicians, or data scientists to safely modify the analysis math without recompiling the machine’s control program.

Architecture Concept: The Pure Calculator

In AutoCore, Python acts purely as a “Calculator”. It does not control the machine, read sensors directly, or manage system state.

Instead, the workflow is entirely driven by your Rust Control Program:

sequenceDiagram
    participant Rust as Control Program (Rust)
    participant Disk as AutoCore Datastore
    participant Py as autocore-python

    Rust->>Disk: 1. Test finishes. Rust saves traces & data to disk.
    Rust->>Py: 2. Rust requests Python analysis via IPC.
    Py->>Disk: 3. Python reads the saved test data.
    Py->>Py: 4. Python executes your script.
    Py-->>Rust: 5. Python returns the calculated results.
    Rust->>Disk: 6. Rust saves the final results.

Key Benefits of this approach:

  1. Safety: The Python interpreter runs in a completely separate process. If a customer writes a script that crashes or loops infinitely, it will never lock up the control program or crash the machine.
  2. Stateless: The Python script starts with a “clean slate” on every single run. There is no leftover memory or state between tests, preventing hidden bugs.
  3. Familiarity: Python receives standard dictionaries and lists, exactly as they appear in standard Python data science workflows.

Enabling the Python Module

Because autocore-python is an external module, you must explicitly enable it in your project.json file before your control program can talk to it.

Add the following block to the "modules" section of your project.json:

"modules": {
  ...
  "python": {
    "enabled": true,
    "executable": "autocore-python",
    "config": {
      "datastore_directory": "/srv/autocore/datastore"
    }
  }
}

The datastore_directory tells the Python module where to look for the results/ folder (where the saved test data lives) and the scripts/ folder (where your .py files live). If omitted, it defaults to ./datastore relative to wherever the server is running.

Writing the Python Script

Python scripts are stored in the datastore/scripts/ directory.

To write an analysis script, you only need to define a single function. This function must accept exactly one argument: a dictionary called ctx (short for “context”).

The ctx Dictionary

The autocore-python module automatically bundles everything about the current test run into this single ctx dictionary. It looks like this:

# The structure of the `ctx` argument
ctx = {
    # Data from your test setup screen
    "project": { "customer": "ACME Corp", "operator": "John Doe" },
    "configuration": { "control_load": 500.0, "speed": 10.0 },
    
    # Existing results (if any have been saved so far)
    "results": { "test_duration_s": 45.2 },
    
    # An array of all cycle data recorded during the test
    "cycle_history": [
        { "cycle_index": 1, "actual_load": 498.2 },
        { "cycle_index": 2, "actual_load": 501.1 }
    ],
    
    # Any raw trace data (like force over time arrays) 
    # saved using set_raw_trace()
    "raw_data": {
        "trace": { 
            "t": [0.0, 0.1, 0.2, 0.3], 
            "force": [0.0, 100.5, 200.1, 300.8] 
        }
    },
    
    # Information identifying the test
    "run_id": "2026-04-20T12-00-00Z",
    
    # Any custom data the Rust program explicitly sent
    "custom_params": {} 
}

Example Analysis Script

Here is an example script (datastore/scripts/analyze_traction.py) that calculates the maximum and average force from a raw data trace:

def calculate_results(ctx):
    # 1. Extract the data we need from the ctx dictionary
    # We use .get() to prevent crashes if the trace data is missing
    raw_data = ctx.get("raw_data", {})
    trace = raw_data.get("trace", {})
    
    force_array = trace.get("force", [])
    
    # If there is no force data, return zeros
    if len(force_array) == 0:
        return {
            "max_force": 0.0,
            "avg_force": 0.0
        }
    
    # 2. Perform the mathematical analysis
    max_force = max(force_array)
    avg_force = sum(force_array) / len(force_array)
    
    # 3. Return a dictionary of the calculated results.
    # The keys in this dictionary should perfectly match the 
    # `results_fields` declared in your project.json!
    return {
        "max_force": max_force,
        "avg_force": avg_force
    }

Triggering Analysis from the Control Program

To trigger the Python script, your Rust program uses ctx.client.send(...) to send a request to the autocore-python module.

Because autocore-python must read the test files from the disk, you must ensure the test data is written to disk before calling Python.

Implementation Example

Assume you have a simple state machine. When the test finishes, you call set_raw_trace to write the high-speed data to the disk, and immediately trigger Python.

use autocore_std::{CommandClient, TickContext};
use crate::generated_results::{ResultsSystem, TestType};
use serde_json::json;

pub struct MyProgram {
    results: ResultsSystem,
    state: State,
    python_request_id: Option<u32>, // Store the transaction ID to wait for the reply
    
    // Test data
    trace_t: Vec<f32>,
    trace_force: Vec<f32>,
}

enum State { Running, Calculating, Idle }

impl MyProgram {
    pub fn tick(&mut self, ctx: &mut TickContext<GlobalMemory>) {
        self.results.tick(&mut ctx.client);

        match self.state {
            State::Running => {
                // ... Accumulate data ...
                
                if ctx.gm.test_finished {
                    // 1. Write the raw trace data to disk immediately
                    self.results.translational_traction.set_raw_trace(&self.trace_t, &self.trace_force, ctx);
                    
                    // 2. We need the Project ID, Definition ID, and Run ID to tell
                    // Python where to find the files on the disk. 
                    let project_id = ctx.gm.results_active_project_id.as_str();
                    let def_id = ctx.gm.results_active_definition_id.as_str();
                    let run_id = ctx.gm.results_active_run_id.as_str();

                    // 3. Send the analysis request to autocore-python
                    let payload = json!({
                        "script": "analyze_traction.py",
                        "function": "calculate_results",
                        "project_id": project_id,
                        "definition_id": def_id,
                        "run_id": run_id,
                        "custom_params": {} // Optional extra data
                    });
                    
                    // Send the message and store the Transaction ID
                    let tid = ctx.client.send("python.run_analysis", payload);
                    self.python_request_id = Some(tid);
                    
                    self.state = State::Calculating;
                }
            }

            State::Calculating => {
                // We are waiting for Python to finish running.
                if let Some(tid) = self.python_request_id {
                    
                    // Check if a response has arrived for our specific transaction ID
                    if let Some(response) = ctx.client.take_response(tid) {
                        
                        if response.success {
                            // Success! Extract the dictionary returned by Python
                            let python_results = response.data;
                            
                            // Map the python output to your final result variables
                            let max_f = python_results["max_force"].as_f64().unwrap_or(0.0) as f32;
                            let avg_f = python_results["avg_force"].as_f64().unwrap_or(0.0) as f32;
                            
                            // 4. Update the final results system
                            self.results.translational_traction.update_results(max_f, avg_f, ctx);
                        } else {
                            log::error!("Python script failed: {}", response.error_message);
                            // Always close the test even on failure to prevent hanging
                            self.results.translational_traction.update_results(0.0, 0.0, ctx);
                        }
                        
                        // Clear the active test
                        self.results.active_test = None;
                        self.python_request_id = None;
                        self.state = State::Idle;
                    }
                }
            }
            
            State::Idle => { /* Waiting for next test... */ }
        }
    }
}

Best Practices

  • Never loop forever in Python: If your Python script contains an infinite loop (while True:), the autocore-python process will get stuck. Your control program handles this safely (it just stays in the Calculating state and your machine doesn’t crash), but the operator will be stuck waiting for results.
  • Fail gracefully: If an operator forgets to connect a sensor, your data arrays might be empty. Always check array lengths in Python before calling functions like max() or sum() to prevent unhandled script crashes.
  • Use custom_params for dynamic thresholds: If you have dynamic “Pass/Fail” limits set by the operator on the HMI (but not tracked by the Test Information System schema directly), you can inject them directly into the custom_params block in Rust so Python can check them.

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?

Module running but its status reports an empty config

The module is launching but has no NI/EtherCAT/Modbus configuration loaded (e.g., ni.status shows "channels": [], ni.start returns “No NI configuration loaded”). This means the module was launched without --config. Almost always: the supervisor refused to start it from project.json because validation failed, and something fell back to launching it ad-hoc. Recent server builds refuse that fallback for project-declared modules — older builds did not.

Check, in order:

  1. Run acctl validate. If it reports any ams.placeholder errors, you have an unguarded ${ams.*} reference somewhere. The most common case: a placeholder with no | default … clause when the asset isn’t registered. Fix the placeholder (${... | default <value>} or register the asset) and push the project again.
  2. Check the server log at /srv/autocore/logs/autocore-server.log for an UnresolvedAmsPlaceholders line. That’s the supervisor refusing to start the module — the validator above should have caught it, but the log gives the exact placeholder path.
  3. Confirm the module’s actual launch command with ps -ef | grep <module-name>. If the row has only --ipc-address and --module-name — no --config — then the supervisor never spawned it; only the ad-hoc path did. On current builds that path refuses project-declared modules; on older builds it silently substitutes. Update the server.
  4. After fixing the project, run acctl push project --restart to apply.

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 (software offset only, lost on restart)
set_position_persistent(client, user_units)Set current position as the given value, written to the drive’s home offset (0x607C) and saved to absolute-encoder EEPROM where supported
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.

Setting position non-volatilely on an absolute encoder:

set_position_persistent(client, user_units) writes the drive’s home offset register (0x607C) in encoder counts and runs a CiA 402 homing cycle with method [AxisConfig::soft_home_method]. After it completes, 0x6064 (the drive’s reported position) reads user_units. For drives with absolute encoders configured in linear mode, the drive auto-saves the resulting encoder offset to EEPROM — so the new reference survives power cycles. No motor motion occurs.

// Drive must be enabled and at rest.
self.drive.set_position_persistent(&mut ctx.client, 42.0);

// Then poll for completion in subsequent ticks:
if !self.drive.is_busy() {
    // Position is now persisted at 42.0 user units.
}

Compare with set_position(user_units), which only adjusts a software offset in the autocore-server process — that’s fine for transient adjustments but is lost on autocore restart, while set_position_persistent survives both autocore restart and (on absolute drives in linear mode) a drive power cycle.

The same write-607C-then-trigger sequence also runs as the terminal step of any software-homing method (LimitSwitchPosPnp and friends), so you get the same non-volatile behavior automatically when homing to a sensor. The only difference: set_position_persistent skips the sensor-finding prelude — use it when you already know the current mechanical position.

Drives differ on which method code they accept for “present position = home offset”:

  • CiA 402 method 37 (newer spec) — used by Teknic ClearPath and most modern drives.
  • CiA 402 method 35 (older spec) — used by Inovance SV660N (its 6098h value range is 1–35).

Both methods do the same thing. Override per-axis with AxisConfig::soft_home_method (default 37):

let mut config = AxisConfig::new(8_388_608).with_user_scale(360.0);
config.soft_home_method = 35;  // Inovance SV660N

For the SV660N specifically, the inovance_sv660n.json device profile in autocore-ethercat/ext_definitions/ sets 0x2002:02 = 1 (Absolute encoder system mode = linear) on startup so the EEPROM auto-save behavior is enabled. See SV660N Advanced User Guide §7.11.2.

Halt handshake quirk: clear_halt_during_cancel

Axis::halt() is a multi-tick close-out: it asserts the halt bit (CW 8), waits for the motor to stop, issues a cancel setpoint, then waits for setpoint_ack (SW 12) to rise and fall so the PP handshake is clean for the next move. Drives disagree on whether the halt bit may remain asserted while that ack handshake runs:

  • Teknic ClearPath acknowledges the cancel setpoint with halt still asserted. If halt is cleared early, the drive resumes the previous move when halt drops — the cancel setpoint is effectively discarded. Halt must stay asserted until the handshake completes.
  • Inovance SV660N refuses to set setpoint_ack while halt is asserted. The WaitCancelAck stage stalls and Axis reports Halt timeout: cancel not acknowledged. Halt must be cleared as part of the cancel.

Override per-axis with AxisConfig::clear_halt_during_cancel (default false, Teknic-safe):

let mut config = AxisConfig::new(8_388_608).with_user_scale(360.0);
config.clear_halt_during_cancel = true;  // Inovance SV660N

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

Test Information 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

Appendix E: Network Bridge — Reaching Devices Through Tailscale

This appendix is a step-by-step manual for the Network Bridge feature in the AutoCore Console. It is written assuming you have never used Tailscale before, do not necessarily know what an IPv6 address is, and have not edited a hosts file. If any of those describe you, you are the audience for this chapter — read it in order.

If you already know Tailscale and just want the operational reference, skip to Quick Reference at the end.

The chapter is in two halves:

  • Part 1: For Administrators is for the person who has admin access to your company’s Tailscale account (the “tailnet admin”). You configure the bridge once per project, approve the route, and hand a small piece of text to the developers.

  • Part 2: For Developers and Users is for everyone else — people who need to reach a device through the bridge from their own laptop but do not have admin access.

A Glossary at the end defines every term used here.

What this feature does

The embedded computer running autocore-server is normally connected to two networks at once:

  1. Tailscale — a private virtual network that lets you reach the embedded computer from anywhere in the world, as long as your laptop is also signed in to the same company tailnet.

  2. The process network — a small, isolated LAN on the embedded computer’s second adapter, typically using the address range 192.168.127.x. The PLC, NI rack, panel PC, industrial robot, and other field devices live on this LAN. The embedded computer is the only thing connected to both networks.

Out of the box, your laptop can only reach the embedded computer itself. You cannot reach the other devices on the process network because Tailscale does not know about them, and those devices have no direct connection to the Internet.

The Network Bridge feature solves this by telling Tailscale to act as a router: traffic on the tailnet destined for the process network is forwarded through the embedded computer, which already knows how to talk to those devices. Once configured, your laptop can reach the panel PC, the NI rack, and any other device on the process network just by typing its address — almost as if your laptop were sitting in the lab next to it.

When to use it (and when not to)

Use the Network Bridge when:

  • You need to open the panel PC’s web UI from your desk to check a HMI screen.
  • You need to talk to an NI rack’s measurement service over its native TCP/UDP protocol.
  • You need to push firmware or programs to an industrial robot whose proprietary tool only knows how to talk on the local LAN.
  • Anything else where “I wish I could be on that LAN” is the right shape of the problem.

Do not use the Network Bridge for:

  • Running production HMIs over a flaky Internet link. Tailscale is reliable, but field-side latency and bandwidth still apply.
  • Letting people outside your company access these devices. Tailscale is private to your tailnet, but inside your tailnet it is wide open by default. If sensitive devices live on the process network, ask the admin to set up Tailscale ACLs (out of scope for this manual).

Concepts you need to know

Five terms come up over and over. Skim these now; come back to the glossary if you forget what one of them means later.

  • Tailscale is a software tool that builds a private virtual network (“tailnet”) between computers. When two computers are signed in to the same tailnet, they can talk to each other no matter where they are physically.

  • Subnet routing is a Tailscale feature where one node on the tailnet (here: the embedded computer) declares “I can reach the following address range on a network you can’t see; if you send me traffic for that range, I’ll forward it.” Other computers on the tailnet then treat that range as if it were directly reachable.

  • IPv4 vs IPv6 — there are two flavours of Internet addresses. IPv4 looks like 192.168.127.5. IPv6 looks like fd7a:115c:a1e0:b1a:0:ef9:c0a8:7f05. Tailscale uses both. Modern laptops and operating systems handle both transparently — you just have to type the right kind of address.

  • 4via6 (pronounced “four via six”) is a specific Tailscale trick we use to solve a problem you’ll hit if you have more than one ADC machine in the field. Every ADC embedded computer uses the same 192.168.127.x address range internally. If both machines just advertised that range straight, Tailscale wouldn’t know which one you meant. 4via6 gives each machine a unique IPv6 address space that maps onto its own copy of the 192.168.127.x range. From your laptop, you address devices by IPv6; Tailscale translates back to IPv4 at the embedded computer.

  • Site ID is the number that distinguishes one machine’s 4via6 space from another’s. Every ADC machine is given a site ID at setup time; it is the same number as the serial number on the chassis sticker (adc-sn-3833 → site ID 3833). You will see this number embedded in the IPv6 addresses the console produces.

Quick reference

If you are already familiar with everything above:

  • Admins: Console → Network Bridge tab → click Advertise on the local subnet → open the Tailscale admin console (login.tailscale.com) → Machines → click your node → Edit route settings → approve the fd7a:115c:a1e0:b1a:0:<site_id>::/96 route → back in AutoCore, Scan and label devices → click Copy block under “Hosts-file snippet” → send to users.

  • Users: Install Tailscale, sign in to the company tailnet, ask your admin for the hosts snippet they generated, paste it into your laptop’s hosts file, then use the friendly names (e.g. panel-pc-3833) in your browser or other tools.

The rest of this appendix walks through each of those steps slowly.

Part 1: For Administrators

This part is for the person who has admin access to the Tailscale admin console at login.tailscale.com. If you do not, skip to Part 2 — but it will be useful to read this part too so you understand what your admin is doing.

1.1 Prerequisites — what must already be true

Before you set up a Network Bridge, the following must be true. The short version: the embedded computer must be on both networks and Tailscale must already be working.

  1. The embedded computer is running autocore-server. If you can reach the AutoCore Console in your web browser, this is already true.

  2. Tailscale is installed and signed in on the embedded computer. You can verify by SSHing to the embedded computer and running:

    tailscale status
    

    The first line of the output should not say “Logged out.” If it does, run sudo tailscale up and sign in following the URL it prints. You only do this once.

  3. You have admin (Owner or Admin role) access to the company tailnet at login.tailscale.com. If you can see other people’s machines in the Machines panel when you sign in, you have the right level of access.

  4. The embedded computer is connected to the process network. Run:

    ip -4 addr show
    

    You should see at least two adapters with addresses — for example, one on 192.168.x.x (the customer’s office network or a router) and one on 192.168.127.1 (the process network). The exact numbers depend on the project.

If any of those four things is not true, fix them first. The rest of this appendix assumes they are all in place.

1.2 Open the Network Bridge tab in the console

  1. In your web browser, go to the AutoCore Console URL for the embedded computer. This will be something like:

    http://adc-sn-3833/console
    

    or, if name resolution isn’t set up, the embedded computer’s Tailscale IPv4 address — find it by running tailscale ip -4 on the embedded computer.

  2. At the top of the console you will see two tabs: Console and Network Bridge. Click Network Bridge.

  3. The page that loads has four sections, top to bottom:

    1. Network Bridge header — your machine’s identity.
    2. Local Subnets — the LANs the embedded computer is connected to.
    3. Devices on <address> — devices the embedded computer can see on a chosen subnet.
    4. Hosts-file snippet — the text you will send to your users.

The rest of this part walks through them in order.

1.3 Reading the header

The Network Bridge header tells you four things. Take a moment to find each of them on screen now:

  • Hostname: should be something like adc-sn-3833. This is the name of the embedded computer.

  • Site ID: a number, usually equal to the serial number in the hostname (3833 in the example above). The label (from hostname) means it was parsed automatically. (from config) means someone set an override in the configuration file.

  • 4via6 prefix: an IPv6 address ending in ::/96, like fd7a:115c:a1e0:b1a:0:ef9::/96. This is the unique IPv6 space reserved for this embedded computer. The ef9 part is the hexadecimal form of the site ID (3833 in decimal = ef9 in hex). You do not need to do this conversion yourself; the console does it.

  • Tailscale state: a small badge that should say Running. If it says NeedsLogin or Stopped, go back to step 1.1.2 and finish setting up Tailscale before continuing.

If anything in the header looks wrong (for example, the site ID is “could not derive”), stop and address it before proceeding. Common fixes:

  • “Could not derive site_id from hostname” — the embedded computer was hostnamed something other than adc-sn-NNNN. Either rename the machine (sudo hostnamectl set-hostname adc-sn-3833, then reboot) or add a [bridge] site_id = 3833 entry to /opt/autocore/config/config.ini and restart autocore_server.

1.4 Advertise the local subnet

Find the Local Subnets section. It lists every IPv4 network the embedded computer is currently connected to.

You want to advertise the process network — the one with the field devices on it. In a typical project that’s the 192.168.127.x/24 network. Do not advertise the customer’s general office network or a network connected directly to the Internet, both of which would expose far more than you intend.

  1. Find the row whose CIDR column begins 192.168.127. (The CIDR is the address-and-prefix notation you see in the column; on a typical ADC machine you’ll see 192.168.127.1/24.)

  2. Click the Advertise button at the right of that row.

  3. The page will refresh after a moment. You should see two things change:

    • A new orange notice appears below the table: “Route is advertised but not yet approved…”
    • The Tailscale state badge area now also shows Approval pending.

The embedded computer is now telling the Tailscale admin server “please let other tailnet members reach my process network through me.” But until you approve that request, nobody actually can. The next step is approving it.

1.5 Approve the route in the Tailscale admin console

This is the only step that happens outside the AutoCore Console.

  1. In a new browser tab, go to:

    https://login.tailscale.com
    
  2. Sign in if you aren’t already.

  3. You will see a list of “machines” — every device signed in to your tailnet. Find the row for the embedded computer (its name will be the hostname, e.g. adc-sn-3833).

  4. There may be a yellow badge or icon next to that row that says “Subnets” or shows a number — that is Tailscale’s way of telling you the machine has unapproved routes waiting.

  5. Click the row to open the machine’s details. (On some Tailscale UI versions you instead click the three-dot menu at the right of the row and choose Edit route settings.)

  6. You will see a section labelled Subnet routes with the advertised route listed — it will be the same long IPv6 address you saw in the AutoCore Console header, ending in ::/96. There will be a checkbox or toggle next to it.

  7. Tick the checkbox to approve the route.

  8. Click Save (button label varies by Tailscale version).

  9. Switch back to the AutoCore Console browser tab. Click the Refresh button at the top of the Network Bridge panel.

  10. The orange “Approval pending” notice should disappear, and you should see a green Route approved tag in the header.

The bridge is now live. Devices on the process network are reachable from any laptop on your tailnet — but only at IPv6 addresses your users would not know offhand. The next sections fix that.

1.6 Scan and label devices

Without labels, your users have to remember that the panel PC is at fd7a:115c:a1e0:b1a:0:ef9:c0a8:7f64. With labels, they can type panel-pc-3833 instead. Setting labels is worth the few minutes.

  1. In the Local Subnets table, click Pick on the process network row to select it as the scan target.

  2. Scroll down to Devices on 192.168.127.1 (or whatever your local address is).

  3. Click Scan. The button label changes to Scanning… for a couple of seconds, then a table populates.

    What’s happening: the console asks the kernel “what devices have you seen on this network adapter recently?” It then sends a ping to every address in the range to wake up devices that have been quiet, and asks the kernel again.

  4. For each device you recognise:

    • Type a label in the Label column (e.g. NI Rack, Panel PC, Robot 1).
    • Click the small save icon to the right of the input.

    Labels are saved in /opt/autocore/config/config.ini and survive restarts.

  5. Devices you don’t recognise can be left alone. Devices that are just other ADC machines (look at the MAC column — anything starting with a familiar ADC MAC prefix) don’t need labels.

The IPv6 (4via6) column shows the address your users will actually type. There is a copy button at the right of each cell.

1.7 Build and distribute the hosts snippet

The hosts snippet is the bridge between the long IPv6 addresses and the friendly names you just typed in.

  1. Scroll to the Hosts-file snippet section at the bottom of the page. After the scan, the box should now contain several lines.

  2. The block looks something like this — this is exactly what your users will paste:

    # autocore Network Bridge — site_id 3833 (adc-sn-3833) — 4via6 prefix fd7a:115c:a1e0:b1a:0:ef9::/96
    fd7a:115c:a1e0:b1a:0:ef9:c0a8:7f05	ni-rack-3833
    fd7a:115c:a1e0:b1a:0:ef9:c0a8:7f14	robot-3833
    fd7a:115c:a1e0:b1a:0:ef9:c0a8:7f64	panel-pc-3833
    

    The -3833 suffix on every name is the site ID — it disambiguates this site from any other ADC machine your users might also have access to. If a developer works on three projects, they can paste three blocks into their hosts file and end up with panel-pc-3833, panel-pc-4012, panel-pc-4509 all distinct.

  3. Click Copy block. The full text is now on your clipboard.

  4. Send it to every developer who needs access. Email, Slack, your team’s wiki — anywhere is fine. There is no secret material in the block; it is safe to share.

The text in the box is regenerated every time you change a label; you do not need to manually keep it in sync. After you add or rename labels, hit Rebuild then re-send.

1.8 Disabling or changing the bridge

To stop advertising the route (because the project is over, the embedded computer is being retired, or you suspect a security issue):

  1. In the Local Subnets table, click Disable on the row whose bridge is currently active.

  2. The route is removed immediately from the advertised list. Note that this is asymmetric: the Tailscale admin console may continue to list the route as approved, but the embedded computer is no longer advertising it, so traffic stops.

  3. If you also want to revoke approval in the admin console for cleanliness:

    • Go back to login.tailscale.comMachines → your node → Edit route settings → uncheck the route → Save.

To advertise a different subnet, click Disable first, then Advertise on the new row.

The bridge can only advertise one process subnet at a time — that’s the model the feature is designed around. If you need two distinct subnets routed, contact ADC; we’ll add the configuration knobs.

1.9 Troubleshooting for administrators

“I clicked Advertise but nothing happened.” Check the Tailscale state badge. If it says Stopped or NeedsLogin, the underlying Tailscale daemon isn’t ready. SSH to the embedded computer and run sudo tailscale up.

“The Route approved tag never appears.” You probably didn’t tick the checkbox in the Tailscale admin console, or you saved the page without committing. Reload login.tailscale.com, find the node, and check the route’s state. Tailscale’s UI sometimes places the save button in a sub-modal — make sure you see a confirmation toast that says the change was saved.

“A user says ‘I can’t reach panel-pc-3833’.” Walk through these, in order:

  1. Is the route still approved on this node? — Check the green Route approved badge in the console. If it’s missing, you’ve regressed to step 1.5.
  2. Is the device actually on? — Use the Scan button. If the device doesn’t appear, or appears with state FAILED, the embedded computer can’t reach it locally, so a remote user definitely can’t.
  3. Does the user have Tailscale running and signed in to the right tailnet? — Easy to forget; especially common after the user’s laptop reboots and Tailscale didn’t auto-start.
  4. Did the user copy the latest hosts snippet? — If you renamed labels recently, the user may have an out-of-date block in their hosts file. The IPv6 itself hasn’t changed (it’s tied to the device’s IPv4), but the names might have.
  5. Is something on the customer’s network blocking outbound IPv6 to the embedded computer? — Rare, but possible behind aggressive corporate firewalls. See Glossary: 4via6 for what traffic is actually flowing.

“I see two ADC machines advertising the same site ID.” This shouldn’t happen — site IDs come from machine serials and serials are unique — but if it does (e.g., someone manually overrode the site_id in config.ini to a value that collides), Tailscale will treat both advertisements as routes for the same IPv6 prefix and pick one as primary. Resolve by changing one of the site IDs in /opt/autocore/config/config.ini under [bridge] site_id =, then restart autocore_server.

“After enabling the bridge, the embedded computer can’t reach the Internet.” The ip_forward sysctl we install does not change the machine’s own routing; only its willingness to forward packets it receives. Internet trouble after enabling is coincidence — likely a DHCP renewal or unrelated NetworkManager event. Check with ping 8.8.8.8.

Part 2: For Developers and Users

This part is for everyone who uses a Network Bridge but didn’t set it up. If you don’t have an account on login.tailscale.com with admin access, you’re in this part.

The pattern is: your admin has put a bridge in place, generated a short text block, and sent it to you. You have to do two things, once, on your laptop:

  1. Install Tailscale and sign in to the company tailnet.
  2. Paste the text block into your laptop’s hosts file.

After that, you use the friendly names (panel-pc-3833, ni-rack-3833, etc.) just like any other hostname.

2.1 What you need from your admin

Before you start, message your admin and confirm:

  1. You have an invite to the company Tailscale tailnet. This will come as an email from Tailscale. If you’ve never accepted it, do that now.

  2. They have a “hosts snippet” for this project. This is the block of text generated from the AutoCore Console’s Network Bridge panel. It looks like this:

    # autocore Network Bridge — site_id 3833 (adc-sn-3833) — 4via6 prefix fd7a:115c:a1e0:b1a:0:ef9::/96
    fd7a:115c:a1e0:b1a:0:ef9:c0a8:7f05	ni-rack-3833
    fd7a:115c:a1e0:b1a:0:ef9:c0a8:7f64	panel-pc-3833
    

    If you don’t have one, ask. Tell them which project you’re working on so they generate it from the right machine.

  3. The route is approved on the embedded computer. This is the admin’s job; you can’t verify it yourself. If anything below doesn’t work, the most likely cause is “the admin forgot to approve the route in the Tailscale admin console.” Ask them to check.

2.2 Install Tailscale on your laptop

You only do this once.

Windows

  1. Go to tailscale.com/download/windows and download the installer.
  2. Run the installer. Click through the defaults.
  3. A small Tailscale icon will appear in your system tray (bottom right of the screen, near the clock — you may need to click the “show hidden icons” arrow).
  4. Right-click the icon, choose Log in, and follow the link to sign in. Use your company email account — your admin should have already added you to the tailnet.

macOS

  1. Install from the Mac App Store: search for Tailscale.
  2. Open the Tailscale app. A small icon appears in the menu bar at the top of the screen.
  3. Click the icon, choose Log in, and sign in.

Linux

  1. Follow the official instructions at tailscale.com/download/linux for your distribution. For Ubuntu, that’s:

    curl -fsSL https://tailscale.com/install.sh | sh
    
  2. Sign in:

    sudo tailscale up
    

    This prints a URL. Open it in a browser and sign in with your company account.

Verify Tailscale is running

After signing in, on Linux or macOS open a terminal and run:

tailscale status

On Windows, open PowerShell or Command Prompt and run the same command.

You should see your laptop’s name at the top, followed by a list of other devices in the tailnet, including the embedded computer (e.g. adc-sn-3833). If you see the embedded computer in the list, you’re ready for the next step.

If the list is empty or you see Logged out, repeat the sign-in step above. If you don’t see the embedded computer specifically, ask your admin to confirm you’re on the right tailnet.

2.3 Add the hosts snippet to your laptop

The hosts file on your laptop is a small text file that maps hostnames (like panel-pc-3833) to IP addresses. When you type a hostname in your browser, your laptop checks this file first; if it finds a match, it uses that IP and skips DNS entirely. That’s how the friendly names from the snippet will work without your admin having to register them anywhere.

The file is in different places on each OS. Editing it requires administrator rights on your laptop (because it affects all users).

Linux / macOS

  1. Open a terminal.

  2. Open the file with sudo (you will be prompted for your laptop password):

    sudo nano /etc/hosts
    

    You can use any other text editor if you prefer — sudo vim /etc/hosts, etc. — but nano is the simplest if you don’t have a preference.

  3. Use the arrow keys to move to the end of the file. Press Enter to start a new line.

  4. Paste the snippet (in nano, paste is usually Ctrl+Shift+V on Linux or Cmd+V on macOS; in other terminals it may differ).

  5. Save and exit:

    • In nano: Ctrl+O, Enter, then Ctrl+X.
    • In vim: Esc, then :wq, then Enter.
  6. Verify by running:

    getent hosts panel-pc-3833
    

    (Replace panel-pc-3833 with one of the names from your snippet.) You should see one line of output: the IPv6 address followed by the name. If you get no output, the paste didn’t take or you saved the wrong file.

Windows

  1. Press Windows key, type notepad, right-click the Notepad app, and choose Run as administrator. (Notepad must be opened this way; opening it normally won’t let you save changes to the hosts file.)

  2. In Notepad, choose File → Open.

  3. In the file path field at the bottom, type:

    C:\Windows\System32\drivers\etc\hosts
    

    and press Enter. (If Notepad shows “no files found,” change the file-type dropdown from “Text Documents (*.txt)” to All Files.)

  4. Move to the end of the file and press Enter to add a new line.

  5. Paste the snippet (Ctrl+V).

  6. Save with File → Save (Ctrl+S).

  7. Verify by opening PowerShell or Command Prompt and running:

    ping -6 panel-pc-3833
    

    (Use one of the names from your snippet.) If your laptop can find the name, the ping will resolve and either succeed or time out — either result means the name resolved. If you get “Ping request could not find host…”, the paste didn’t take.

2.4 How to use the names

Once the snippet is in place, you can use the friendly names anywhere you’d normally type an address or hostname:

In a web browser:

http://panel-pc-3833/

Just like any other URL. You do not need square brackets when you use the friendly name — the brackets are only needed for raw IPv6 addresses.

If you ever need to use the raw IPv6 address (without a name) in a browser, you do need brackets:

http://[fd7a:115c:a1e0:b1a:0:ef9:c0a8:7f64]/

For pings (use the -6 flag on Windows; on Linux/macOS, plain ping works for both):

ping panel-pc-3833           # Linux, macOS
ping -6 panel-pc-3833        # Windows

In vendor tools (NI MAX, robot teach pendant, etc.): type the friendly name wherever the tool asks for an IP address or hostname. Most modern tools resolve hostnames automatically. Older tools that demand a raw IP can be given the IPv6 address from the snippet directly — but check whether the tool actually understands IPv6 first. Many older industrial tools do not, in which case the friendly name approach above won’t help either; you’ll need to use SSH port forwarding instead (see Older Tools That Only Speak IPv4 below).

In code: most networking libraries handle both name resolution and IPv6 transparently. requests.get("http://panel-pc-3833/api") in Python or fetch("http://panel-pc-3833/api") in JavaScript will both work as you’d expect.

2.5 Common problems

“I can’t reach any of the names”

Most common cause: Tailscale isn’t running on your laptop, or your laptop disconnected from the tailnet and didn’t reconnect.

  1. Check the Tailscale icon in your system tray / menu bar — it should look “active” (the icon varies by version, but a struck-out or grey icon is bad).

  2. Run tailscale status in a terminal. The first line should not say Logged out.

  3. Verify you can reach the embedded computer itself (not just the bridged devices):

    ping adc-sn-3833
    

    If that fails, the problem is your tailnet connection, not the bridge. Sign back in to Tailscale.

“I can reach the embedded computer, but not the bridged devices”

This means Tailscale is working, but the bridge isn’t.

  1. Ask your admin: “is the route approved for adc-sn-NNNN?” If they say no or aren’t sure, they need to revisit Part 1, section 1.5.

  2. Check you actually pasted the snippet into the hosts file. Try:

    getent hosts panel-pc-3833      # Linux, macOS
    ping -6 -n 1 panel-pc-3833      # Windows
    

    No output / “could not find host” → snippet not in your hosts file. Re-do section 2.3.

  3. Check the device is actually online. The admin can use the Scan button in the Network Bridge panel to confirm; if it doesn’t appear in their scan, no remote access will work either.

“It worked yesterday and now it doesn’t”

Most likely one of:

  • Your laptop’s Tailscale signed out (laptop suspended for a long time, lost auth token). Sign back in.
  • The embedded computer rebooted but Tailscale didn’t auto-start. Ask your admin.
  • The customer’s local network changed and the embedded computer lost its IP. Ask your admin to confirm tailscale status on the embedded computer.
  • Someone disabled the bridge from the AutoCore Console. Ask your admin.

“I’m getting some weird IPv6 error from a tool I’m using”

Some older networking tools (especially Windows-side industrial software) don’t understand IPv6. If a tool refuses to connect to one of the friendly names but everything else works:

  1. Try entering the raw IPv6 address (with [brackets] for URLs) directly. If the tool accepts that, it just didn’t like the friendly name; report this to the tool’s vendor.
  2. If the tool refuses the IPv6 address too, it can’t speak IPv6. See Older Tools That Only Speak IPv4 below.

2.6 Older tools that only speak IPv4

A small number of industrial tools — usually old proprietary Windows software — simply cannot connect to anything but an IPv4 address. For those, the Network Bridge feature as documented above won’t help, and you’ll need to use SSH port forwarding instead. This is the “PuTTY trick” you may have heard about.

The short version:

  1. SSH into the embedded computer (using its friendly Tailscale name, e.g. ssh user@adc-sn-3833).
  2. Tell SSH to forward a port on your laptop to the target device’s IPv4 + port on the embedded computer’s side.
  3. Point the old tool at localhost:<the-forwarded-port>.

A worked example, talking to a panel PC’s web UI:

ssh -L 8080:192.168.127.100:80 user@adc-sn-3833

Now you can open http://localhost:8080/ in your browser and you’ll be talking to the panel PC’s port 80 over the SSH tunnel. Leave the SSH connection open as long as you need the tunnel.

For permanent setups, use a PuTTY saved session with the Connection → SSH → Tunnels panel filled in similarly. If you need help setting that up, ask your admin or one of the more experienced developers on the team — port forwarding is a slightly deeper topic than this manual covers.

2.7 When to escalate

Send a message to your admin (or to ADC support) when:

  • tailscale status shows you signed in to a different tailnet than your colleagues are on. Your admin needs to fix tailnet membership.
  • You followed every step here, the route is approved (your admin confirmed), and you still can’t reach any device by name or by raw IPv6.
  • You can reach some devices but not others, and the admin’s scan shows everything is fine on their end.
  • You see your laptop’s Tailscale list contains the embedded computer in red or with an “offline” tag, even though you know the embedded computer is on.

Include in your message:

  • The friendly name (or IPv6) you’re trying to reach.
  • The exact command or URL you tried.
  • The exact error you got back (a screenshot is fine).
  • The output of tailscale status from your laptop.

That’s everything ADC support or your admin needs to diagnose 90% of issues.

Glossary

This glossary defines every special term used in this appendix. If you have a moment, skim it once even if you think you know the terms — the definitions here are tied specifically to how AutoCore uses each concept.

4via6 — A specific Tailscale feature for routing IPv4 subnets through an IPv6 prefix. We use it because multiple ADC machines deployed in the field share the same 192.168.127.x IPv4 range internally; 4via6 lets each machine appear under its own unique IPv6 prefix from the laptop’s point of view, so traffic to “Project A’s panel PC” and “Project B’s panel PC” doesn’t collide. Pronounced “four via six.”

ADC machine — An embedded computer running autocore-server that ADC has built and shipped. Identified by a serial number; hostnamed adc-sn-<serial>.

Admin console (Tailscale) — The web UI at login.tailscale.com where tailnet admins manage machines, users, ACLs, and routes. Distinct from the AutoCore Console; they have nothing to do with each other beyond both being “consoles.”

AutoCore Console — The web UI hosted by an ADC machine at http://<machine-name>/console. It contains the Network Bridge tab this appendix is about, alongside other admin tools.

CIDR — Address-and-prefix notation, like 192.168.127.0/24. The /24 means “the first 24 bits of the address identify the network; the last 8 bits identify a specific host.” 192.168.127.0/24 is a range of 256 addresses, 192.168.127.0 through 192.168.127.255.

hosts file — A small text file on every laptop and server that manually maps hostnames to IP addresses. When you type a hostname, your operating system checks the hosts file before asking DNS, so hosts-file entries always win. Lives at /etc/hosts on Linux/macOS and C:\Windows\System32\drivers\etc\hosts on Windows.

Hosts snippet — The block of text the AutoCore Console produces under “Hosts-file snippet.” Pasted into a laptop’s hosts file by the developer, it maps friendly names (like panel-pc-3833) to the IPv6 addresses Tailscale 4via6 expects.

IPv4 — The original Internet address format. Looks like four numbers separated by dots: 192.168.127.5. Most things still use it on local networks.

IPv6 — The newer Internet address format. Eight groups of hex digits separated by colons: fd7a:115c:a1e0:b1a:0:ef9:c0a8:7f05. Most modern operating systems and tools handle it transparently, but a few older tools don’t. In URLs, IPv6 addresses must be wrapped in [square brackets].

Process network — The isolated LAN attached to the embedded computer’s second network adapter, hosting the field devices (NI rack, panel PC, robot, PLC, etc.). Typically 192.168.127.0/24.

Route approval — In Tailscale, a node advertising a subnet route is only a request — the route doesn’t actually carry traffic until a tailnet admin clicks Approve in the Tailscale admin console. This two-step model exists so a misconfigured (or hostile) node can’t silently start announcing routes for sensitive networks.

Site ID — A 16-bit integer (1–65535) that identifies one ADC machine’s process-network space distinct from any other ADC machine’s. Derived from the machine’s serial number. Appears inside the IPv6 addresses generated by 4via6 — e.g. the ef9 in fd7a:115c:a1e0:b1a:0:ef9:c0a8:7f05 is hex for site ID 3833.

Subnet — A range of IP addresses, usually contiguous. The process network 192.168.127.0/24 is a subnet; so is your home WiFi (typically 192.168.0.0/24 or similar).

Subnet routing — A Tailscale feature where one machine in the tailnet acts as a router into a network that other tailnet members can’t directly see. Without 4via6 it’s plain IPv4 routing; with 4via6 it’s IPv4-via-IPv6 routing. This is the core mechanism behind the Network Bridge feature.

Tailnet — A private network of devices that have all signed in with the same Tailscale credentials. Your company likely has one tailnet; ADC has its own. Within a tailnet, every member can usually reach every other member by name.

Tailscale — Commercial software (with a generous free tier) that builds a private overlay network between authenticated devices. The underlying technology is WireGuard plus some clever NAT-traversal and identity infrastructure. We use it because it works reliably across home networks, cellular hotspots, hotel WiFi, and corporate firewalls, none of which are easy to penetrate with a plain VPN.